Compare commits

...

188 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
zhom 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
zhom 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
zhom 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
zhom 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] 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] 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] 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] 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] 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] 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] 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] 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
zhom 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] 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
zhom eab5def6b1 refactor: explicitly name bin as donutbrowser 2025-11-29 21:06:52 +04:00
zhom 8da0dae545 refactor: rename lib to donutbrowser_lib 2025-11-29 21:00:22 +04:00
zhom 371abf33c1 build: fix wix issue on Windows 2025-11-29 20:10:30 +04:00
zhom 9ddc63931f refactor: add /open-url, /kill, and fix the /run endpoint to properly handle initial URL 2025-11-29 18:19:48 +04:00
zhom d2f4988635 build: execute next build directly 2025-11-29 17:40:48 +04:00
zhom 68228dcf3c build: verify frontend dir before donut-proxy build step 2025-11-29 17:04:05 +04:00
zhom 0805c37d33 refactor: move actions for selected items into its own component 2025-11-29 16:33:58 +04:00
zhom 61dcbbc715 build: don't fail if dist is already created 2025-11-29 16:23:36 +04:00
zhom 287b5a2190 test: cleanup stale proxies from previous test runs and better error handling 2025-11-29 15:44:12 +04:00
zhom 23f5921eb6 feat: add randomized fingerprint option 2025-11-29 15:38:51 +04:00
zhom 035d36e387 test: run donut-proxy tests in sequence 2025-11-29 15:23:21 +04:00
zhom 131ef92370 build: use next build directly 2025-11-29 15:03:19 +04:00
zhom c8b259e6ae build: build frontend before donut-proxy 2025-11-29 14:55:34 +04:00
zhom 98b4e9d145 chore: suppress cfg warning 2025-11-29 14:37:17 +04:00
zhom 8af318b5ce build: build frontend for rust linting 2025-11-29 14:36:33 +04:00
zhom 13c6946798 build: create dist folder for rust linting 2025-11-29 14:24:48 +04:00
zhom aed24c4df6 refactor: prompt manual update on linux 2025-11-29 14:21:18 +04:00
zhom a48d03a1e4 chore: linting 2025-11-29 14:12:46 +04:00
zhom 5d7ed0430e build: don't check for sidecars during donut-proxy build 2025-11-29 14:02:31 +04:00
zhom 84af35c4f5 build: copy donut-proxy binary in linting 2025-11-29 13:52:49 +04:00
zhom cff69fbd11 chore: fix syntax in issue validation workflow 2025-11-29 13:49:48 +04:00
zhom 2f639652c9 chore: fix syntax in issue validation workflow 2025-11-29 13:46:51 +04:00
zhom 30a787e50d refactor: improve process termination logic 2025-11-29 13:44:30 +04:00
zhom 71e3f4a078 chore: codegen 2025-11-29 13:31:56 +04:00
zhom 6e3dc6b657 build: copy donut-proxy into the binaries folder 2025-11-29 13:27:15 +04:00
zhom 8f0bb4a335 chore: fix syntax in issue validation workflow 2025-11-29 13:24:53 +04:00
zhom 9b770dc2e3 chore: codegen 2025-11-29 12:29:15 +04:00
zhom 955cf887a0 build: improve speed 2025-11-29 12:29:07 +04:00
zhom 0acfa66e16 refactor: improve chromium process killing mechanism 2025-11-26 20:52:15 +04:00
zhom 26099b3f7f chore: remove useless logging 2025-11-26 20:51:56 +04:00
zhom 0b63ad6556 refactor: add proper logging 2025-11-26 20:21:17 +04:00
zhom bab9301c31 style: make ui for proxies and groups similar 2025-11-26 16:17:30 +04:00
zhom a4cb3c6b1d style: add tooltip to more actions button 2025-11-26 13:57:59 +04:00
zhom e22838ca55 chore: linting 2025-11-26 13:46:54 +04:00
zhom 18bfb1ed5b fix: always show scroll indicator 2025-11-26 01:42:52 +04:00
zhom c7c910d1ca style: tight table design 2025-11-26 01:42:33 +04:00
zhom 35ba7e2d96 style: better margins 2025-11-26 00:55:49 +04:00
zhom 689eeafc75 style: prevent text from being selected on drag-to-scroll 2025-11-26 00:43:46 +04:00
zhom 40886f2ded refactor: disable deep links on linux 2025-11-26 00:21:33 +04:00
zhom e02f588a90 style: add drag-to-scroll 2025-11-26 00:00:50 +04:00
zhom 3690ceb734 style: improve group functionality 2025-11-25 23:33:28 +04:00
zhom d90a333eb0 style: simplify profile import dialog 2025-11-25 20:53:57 +04:00
zhom 93b85e760e style: make window smaller 2025-11-25 20:53:00 +04:00
zhom 64328e91a2 refactor: migrate proxy functionality from nodecar to rust sidecar 2025-11-25 20:43:12 +04:00
zhom 8a1943f84e feat: add proxy check button 2025-11-25 14:37:17 +04:00
zhom cc22384c54 chore: update contributors workflow 2025-11-25 13:26:20 +04:00
zhom a720f914b0 chore: improve greetings workflow for the first-time contributors 2025-11-22 15:53:53 +04:00
zhom b899af0983 chore: use personal token for automerge 2025-11-22 15:53:53 +04:00
zhom 43277a9579 chore: linting 2025-11-22 15:53:53 +04:00
zhom f4a36996db refactor: fix deprecation warning 2025-11-22 15:53:53 +04:00
zhom 15e8a1029a chore: rename "Donut Browser" to "Donut" 2025-11-22 15:53:53 +04:00
zhom 43b9f405ca chore: conditionally generate suggestions 2025-11-22 15:53:53 +04:00
zhom f9a527637f chore: update dependencies 2025-11-22 15:53:53 +04:00
zhom be0d3053e7 Merge pull request #139 from zhom/dependabot/github_actions/github-actions-3ad8007637
ci(deps): bump the github-actions group with 4 updates
2025-11-22 15:52:38 +04:00
dependabot[bot] 070e40ffe0 ci(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action), [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action) and [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action).


Updates `actions/checkout` from 5.0.0 to 6.0.0
- [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/08c6903cd8c0fde910a37f88322edcfb5dd907a8...1af3b93b6815bc44a9784bd300feb67ff0d1eeb3)

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

Updates `ridedott/merge-me-action` from 2.10.134 to 2.10.138
- [Release notes](https://github.com/ridedott/merge-me-action/releases)
- [Changelog](https://github.com/ridedott/merge-me-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ridedott/merge-me-action/compare/a8b93e4510b1cb03192d058ddef97e6b1de25522...18dd4f01d259faf0a2d900a56cd6b7e765009209)

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

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.138
  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.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-22 09:05:45 +00:00
zhom 416bec77bc Merge pull request #135 from zhom/dependabot/github_actions/github-actions-8a794122f6
ci(deps): bump the github-actions group with 3 updates
2025-11-15 14:47:28 +04:00
dependabot[bot] d3a6c568dc ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action), [tauri-apps/tauri-action](https://github.com/tauri-apps/tauri-action) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `ridedott/merge-me-action` from 2.10.133 to 2.10.134
- [Release notes](https://github.com/ridedott/merge-me-action/releases)
- [Changelog](https://github.com/ridedott/merge-me-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ridedott/merge-me-action/compare/a2e29d4313d8ee783692b40abfce8f2ad60d3f0c...a8b93e4510b1cb03192d058ddef97e6b1de25522)

Updates `tauri-apps/tauri-action` from 0.5.24 to 0.6.0
- [Release notes](https://github.com/tauri-apps/tauri-action/releases)
- [Changelog](https://github.com/tauri-apps/tauri-action/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tauri-action/compare/3b50ac4d4512105f96edbaa78a6e2f9392805589...19b93bb55601e3e373a93cfb6eb4242e45f5af20)

Updates `crate-ci/typos` from 1.39.0 to 1.39.2
- [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/07d900b8fa1097806b8adb6391b0d3e0ac2fdea7...626c4bedb751ce0b7f03262ca97ddda9a076ae1c)

---
updated-dependencies:
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.134
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: tauri-apps/tauri-action
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.39.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-15 09:06:25 +00:00
zhom 0659d11ee7 Merge pull request #123 from zhom/dependabot/github_actions/github-actions-42af67f577
ci(deps): bump the github-actions group with 3 updates
2025-11-01 13:54:08 +00:00
dependabot[bot] 3175ecccf0 ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [google/osv-scanner-action](https://github.com/google/osv-scanner-action), [tauri-apps/tauri-action](https://github.com/tauri-apps/tauri-action) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `google/osv-scanner-action` from 2.2.3 to 2.2.4
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/e92b5d07338d4f0ba0981dffed17c48976ca4730...9bb69575e74019c2ad085a1860787043adf47ccb)

Updates `tauri-apps/tauri-action` from 0.5.23 to 0.5.24
- [Release notes](https://github.com/tauri-apps/tauri-action/releases)
- [Changelog](https://github.com/tauri-apps/tauri-action/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tauri-action/compare/e834788a94591d81e3ae0bd9ec06366f5afb8994...3b50ac4d4512105f96edbaa78a6e2f9392805589)

Updates `crate-ci/typos` from 1.38.1 to 1.39.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/80c8a4945eec0f6d464eaf9e65ed98ef085283d1...07d900b8fa1097806b8adb6391b0d3e0ac2fdea7)

---
updated-dependencies:
- dependency-name: google/osv-scanner-action
  dependency-version: 2.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: tauri-apps/tauri-action
  dependency-version: 0.5.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-01 09:05:50 +00:00
zhom 7b641e9b41 Merge pull request #116 from zhom/dependabot/github_actions/github-actions-ac4cdb36ca
ci(deps): bump the github-actions group with 2 updates
2025-10-19 08:58:57 +00:00
dependabot[bot] f438621bc8 ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action).


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

Updates `ridedott/merge-me-action` from 2.10.131 to 2.10.133
- [Release notes](https://github.com/ridedott/merge-me-action/releases)
- [Changelog](https://github.com/ridedott/merge-me-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ridedott/merge-me-action/compare/a3b9ffd551d69f9f4375a87e9fa56235a0749518...a2e29d4313d8ee783692b40abfce8f2ad60d3f0c)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.133
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-18 09:05:40 +00:00
zhom 4fc2cb7730 Merge pull request #110 from zhom/dependabot/github_actions/github-actions-36c42c0093
ci(deps): bump the github-actions group with 2 updates
2025-10-13 06:30:41 +00:00
dependabot[bot] c41a5d84b2 ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `pnpm/action-setup` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/a7487c7e89a18df4991f7f222e4898a00d66ddda...41ff72655975bd51cab0327fa583b6e92b6d3061)

Updates `crate-ci/typos` from 1.37.2 to 1.38.1
- [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/7436548694def3314aacd93ed06c721b1f91ea04...80c8a4945eec0f6d464eaf9e65ed98ef085283d1)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.38.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-11 09:06:58 +00:00
zhom fda2887aef Merge pull request #106 from zhom/dependabot/github_actions/github-actions-61623bb75b
ci(deps): bump the github-actions group with 5 updates
2025-10-04 09:30:48 +00:00
dependabot[bot] f58b790293 ci(deps): bump the github-actions group with 5 updates
Bumps the github-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [google/osv-scanner-action](https://github.com/google/osv-scanner-action) | `2.2.2` | `2.2.3` |
| [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action) | `2.10.130` | `2.10.131` |
| [actions/first-interaction](https://github.com/actions/first-interaction) | `3.0.0` | `3.1.0` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.36.3` | `1.37.2` |
| [actions/stale](https://github.com/actions/stale) | `10.0.0` | `10.1.0` |


Updates `google/osv-scanner-action` from 2.2.2 to 2.2.3
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e...e92b5d07338d4f0ba0981dffed17c48976ca4730)

Updates `ridedott/merge-me-action` from 2.10.130 to 2.10.131
- [Release notes](https://github.com/ridedott/merge-me-action/releases)
- [Changelog](https://github.com/ridedott/merge-me-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ridedott/merge-me-action/compare/a310eac074af628e0fd6c6d78858bba5bcf01179...a3b9ffd551d69f9f4375a87e9fa56235a0749518)

Updates `actions/first-interaction` from 3.0.0 to 3.1.0
- [Release notes](https://github.com/actions/first-interaction/releases)
- [Commits](https://github.com/actions/first-interaction/compare/753c925c8d1ac6fede23781875376600628d9b5d...1c4688942c71f71d4f5502a26ea67c331730fa4d)

Updates `crate-ci/typos` from 1.36.3 to 1.37.2
- [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/0c17dabcee8b8f1957fa917d17393a23e02e1583...7436548694def3314aacd93ed06c721b1f91ea04)

Updates `actions/stale` from 10.0.0 to 10.1.0
- [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/3a9db7e6a41a89f618792c92c0e97cc736e1b13f...5f858e3efba33a5ca4407a664cc011ad407f2008)

---
updated-dependencies:
- dependency-name: google/osv-scanner-action
  dependency-version: 2.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.131
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/first-interaction
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.37.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-04 09:05:48 +00:00
zhom 518a02f782 chore: version bump 2025-10-02 20:35:20 +04:00
zhom 0999a265dc chore: hide allow addon new tab 2025-10-02 20:33:09 +04:00
zhom 984f529505 chore: remove unused dependency 2025-10-02 19:50:22 +04:00
zhom 3b030df37f build: install dependencies in correct order 2025-10-02 19:49:10 +04:00
zhom 03b8cae825 feat: allow user configuring allowAddonNewTab 2025-10-02 19:43:18 +04:00
zhom 00e486cc85 build: set pnpm version only in package.json 2025-09-30 10:34:35 +04:00
zhom 640185ff2e build: set pnpm to 10 and install dependencies manually 2025-09-30 10:24:52 +04:00
zhom 22fa2cfef0 fix: don't create 2 .desktop files 2025-09-30 10:13:23 +04:00
zhom a1db587314 chore: pnpm update 2025-09-30 09:34:10 +04:00
zhom 8862630a09 chore: cleanup 2025-09-30 09:34:10 +04:00
zhom 5956daeb9a Merge pull request #99 from zhom/dependabot/github_actions/github-actions-7ebf98940a
ci(deps): bump crate-ci/typos from 1.36.2 to 1.36.3 in the github-actions group
2025-09-27 15:20:42 +04:00
dependabot[bot] dfde9df72e ci(deps): bump crate-ci/typos in the github-actions group
Bumps the github-actions group with 1 update: [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `crate-ci/typos` from 1.36.2 to 1.36.3
- [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/85f62a8a84f939ae994ab3763f01a0296d61a7ee...0c17dabcee8b8f1957fa917d17393a23e02e1583)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-version: 1.36.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-27 09:04:52 +00:00
zhom 3cbbd75618 Merge pull request #92 from zhom/dependabot/github_actions/github-actions-8939090574
ci(deps): bump the github-actions group with 2 updates
2025-09-20 13:25:16 +04:00
dependabot[bot] 8a32d73a25 ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [swatinem/rust-cache](https://github.com/swatinem/rust-cache) and [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action).


Updates `swatinem/rust-cache` from 2.8.0 to 2.8.1
- [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/98c8021b550208e191a6a3145459bfc9fb29c4c0...f13886b937689c021905a6b90929199931d60db1)

Updates `ridedott/merge-me-action` from 2.10.129 to 2.10.130
- [Release notes](https://github.com/ridedott/merge-me-action/releases)
- [Changelog](https://github.com/ridedott/merge-me-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ridedott/merge-me-action/compare/884aad0742ac6ee2eb4ff7c4496786d73df4ff69...a310eac074af628e0fd6c6d78858bba5bcf01179)

---
updated-dependencies:
- dependency-name: swatinem/rust-cache
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.130
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 09:05:28 +00:00
zhom 2007080d4b Merge pull request #87 from zhom/dependabot/github_actions/github-actions-3a7b1f4069
ci(deps): bump ridedott/merge-me-action from 2.10.128 to 2.10.129 in the github-actions group
2025-09-14 23:40:26 +04:00
dependabot[bot] feb604ffaa ci(deps): bump ridedott/merge-me-action in the github-actions group
Bumps the github-actions group with 1 update: [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action).


Updates `ridedott/merge-me-action` from 2.10.128 to 2.10.129
- [Release notes](https://github.com/ridedott/merge-me-action/releases)
- [Changelog](https://github.com/ridedott/merge-me-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ridedott/merge-me-action/compare/60142b76c22362f5845c877672fd2822b4d07c13...884aad0742ac6ee2eb4ff7c4496786d73df4ff69)

---
updated-dependencies:
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.129
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-13 09:04:59 +00:00
zhom 14659180d7 Merge pull request #83 from zhom/dependabot/github_actions/github-actions-2ae6d0a682
ci(deps): bump the github-actions group with 4 updates
2025-09-06 20:36:18 +04:00
zhom 82ebd7dc18 Merge pull request #84 from zhom/dependabot/npm_and_yarn/frontend-dependencies-fa3fda6213
deps(deps): bump the frontend-dependencies group with 76 updates
2025-09-06 20:36:08 +04:00
zhom 1c995e676c Merge pull request #85 from zhom/dependabot/cargo/src-tauri/rust-dependencies-ebac297506
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 19 updates
2025-09-06 20:35:58 +04:00
dependabot[bot] e5fd63d03d deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 19 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.8.4` | `2.8.5` |
| [tauri-plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.2` | `2.4.3` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.3.3` | `2.4.0` |
| [zip](https://github.com/zip-rs/zip2) | `4.5.0` | `5.0.0` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.18.0` | `1.18.1` |
| [windows](https://github.com/microsoft/windows-rs) | `0.61.3` | `0.62.0` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.4.0` | `2.4.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.34` | `1.2.36` |
| [deadpool](https://github.com/bikeshedder/deadpool) | `0.12.2` | `0.12.3` |
| [libz-rs-sys](https://github.com/trifectatechfoundation/zlib-rs) | `0.5.1` | `0.5.2` |
| [log](https://github.com/rust-lang/log) | `0.4.27` | `0.4.28` |
| [rust-ini](https://github.com/zonyitoo/rust-ini) | `0.21.1` | `0.21.3` |
| [tao](https://github.com/tauri-apps/tao) | `0.34.2` | `0.34.3` |
| [time](https://github.com/time-rs/time) | `0.3.41` | `0.3.43` |
| [time-core](https://github.com/time-rs/time) | `0.1.4` | `0.1.6` |
| [time-macros](https://github.com/time-rs/time) | `0.2.22` | `0.2.24` |
| [windows-version](https://github.com/microsoft/windows-rs) | `0.1.4` | `0.1.5` |
| [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs) | `0.5.1` | `0.5.2` |
| [zstd-sys](https://github.com/gyscos/zstd-rs) | `2.0.15+zstd.1.5.7` | `2.0.16+zstd.1.5.7` |


Updates `tauri` from 2.8.4 to 2.8.5
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.4...tauri-v2.8.5)

Updates `tauri-plugin-deep-link` from 2.4.2 to 2.4.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.2...http-v2.4.3)

Updates `tauri-plugin-dialog` from 2.3.3 to 2.4.0
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/dialog-v2.3.3...fs-v2.4.0)

Updates `zip` from 4.5.0 to 5.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/v4.5.0...v5.0.0)

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

Updates `windows` from 0.61.3 to 0.62.0
- [Release notes](https://github.com/microsoft/windows-rs/releases)
- [Commits](https://github.com/microsoft/windows-rs/commits/0.62.0)

Updates `tauri-build` from 2.4.0 to 2.4.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.4.0...tauri-build-v2.4.1)

Updates `cc` from 1.2.34 to 1.2.36
- [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.34...cc-v1.2.36)

Updates `deadpool` from 0.12.2 to 0.12.3
- [Changelog](https://github.com/deadpool-rs/deadpool/blob/main/release.toml)
- [Commits](https://github.com/bikeshedder/deadpool/compare/deadpool-v0.12.2...deadpool-v0.12.3)

Updates `libz-rs-sys` from 0.5.1 to 0.5.2
- [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.1...v0.5.2)

Updates `log` from 0.4.27 to 0.4.28
- [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.27...0.4.28)

Updates `rust-ini` from 0.21.1 to 0.21.3
- [Release notes](https://github.com/zonyitoo/rust-ini/releases)
- [Commits](https://github.com/zonyitoo/rust-ini/compare/v0.21.1...v0.21.3)

Updates `tao` from 0.34.2 to 0.34.3
- [Release notes](https://github.com/tauri-apps/tao/releases)
- [Changelog](https://github.com/tauri-apps/tao/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tao/compare/tao-v0.34.2...tao-v0.34.3)

Updates `time` from 0.3.41 to 0.3.43
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.41...v0.3.43)

Updates `time-core` from 0.1.4 to 0.1.6
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/commits)

Updates `time-macros` from 0.2.22 to 0.2.24
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.2.22...v0.2.24)

Updates `windows-version` from 0.1.4 to 0.1.5
- [Release notes](https://github.com/microsoft/windows-rs/releases)
- [Commits](https://github.com/microsoft/windows-rs/commits)

Updates `zlib-rs` from 0.5.1 to 0.5.2
- [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.1...v0.5.2)

Updates `zstd-sys` from 2.0.15+zstd.1.5.7 to 2.0.16+zstd.1.5.7
- [Release notes](https://github.com/gyscos/zstd-rs/releases)
- [Commits](https://github.com/gyscos/zstd-rs/commits)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-deep-link
  dependency-version: 2.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: uuid
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows
  dependency-version: 0.62.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.36
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: deadpool
  dependency-version: 0.12.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libz-rs-sys
  dependency-version: 0.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: log
  dependency-version: 0.4.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rust-ini
  dependency-version: 0.21.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tao
  dependency-version: 0.34.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: time
  dependency-version: 0.3.43
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: time-core
  dependency-version: 0.1.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: time-macros
  dependency-version: 0.2.24
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows-version
  dependency-version: 0.1.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zlib-rs
  dependency-version: 0.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zstd-sys
  dependency-version: 2.0.16+zstd.1.5.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-06 09:38:49 +00:00
dependabot[bot] 11200dbe09 deps(deps): bump the frontend-dependencies group with 76 updates
Bumps the frontend-dependencies group with 76 updates:

| Package | From | To |
| --- | --- | --- |
| [@tauri-apps/plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.2` | `2.4.3` |
| [@tauri-apps/plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.3.3` | `2.4.0` |
| [ahooks](https://github.com/alibaba/hooks) | `3.9.4` | `3.9.5` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.1.12` | `4.1.13` |
| [@tauri-apps/cli](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.3.0` | `24.3.1` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `16.1.5` | `16.1.6` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.12` | `4.1.13` |
| [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css) | `1.3.7` | `1.3.8` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.2.1` | `17.2.2` |
| [fingerprint-generator](https://github.com/apify/fingerprint-suite) | `2.1.70` | `2.1.72` |
| [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) | `7.28.3` | `7.28.4` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.2` | `2.2.3` |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-loongarch64-gnu](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-ppc64-gnu](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |
| [@tailwindcss/node](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-node) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-android-arm64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/android-arm64) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-darwin-arm64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/darwin-arm64) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-darwin-x64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/darwin-x64) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-freebsd-x64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/freebsd-x64) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-linux-arm-gnueabihf](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm-gnueabihf) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-linux-arm64-gnu](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm64-gnu) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-linux-arm64-musl](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm64-musl) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-linux-x64-gnu](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-x64-gnu) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-linux-x64-musl](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-x64-musl) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-wasm32-wasi](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-win32-arm64-msvc](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/win32-arm64-msvc) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide-win32-x64-msvc](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/win32-x64-msvc) | `4.1.12` | `4.1.13` |
| [@tailwindcss/oxide](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node) | `4.1.12` | `4.1.13` |
| [@tauri-apps/cli-darwin-arm64](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-darwin-x64](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-linux-arm-gnueabihf](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-linux-arm64-gnu](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-linux-arm64-musl](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-linux-riscv64-gnu](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-linux-x64-gnu](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-linux-x64-musl](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-win32-arm64-msvc](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-win32-ia32-msvc](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [@tauri-apps/cli-win32-x64-msvc](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.8.4` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.25.3` | `4.25.4` |
| [dayjs](https://github.com/iamkun/dayjs) | `1.11.13` | `1.11.18` |
| [electron-to-chromium](https://github.com/kilian/electron-to-chromium) | `1.5.209` | `1.5.214` |
| [generative-bayesian-network](https://github.com/apify/fingerprint-suite) | `2.1.70` | `2.1.72` |
| [get-east-asian-width](https://github.com/sindresorhus/get-east-asian-width) | `1.3.0` | `1.3.1` |
| [header-generator](https://github.com/apify/fingerprint-suite) | `2.1.70` | `2.1.72` |
| [listr2](https://github.com/listr2/listr2) | `9.0.2` | `9.0.3` |
| [nano-spawn](https://github.com/sindresorhus/nano-spawn) | `1.0.2` | `1.0.3` |
| [node-releases](https://github.com/chicoxyzzy/node-releases) | `2.0.19` | `2.0.20` |
| [rollup](https://github.com/rollup/rollup) | `4.49.0` | `4.50.0` |


Updates `@tauri-apps/plugin-deep-link` from 2.4.2 to 2.4.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.2...http-v2.4.3)

Updates `@tauri-apps/plugin-dialog` from 2.3.3 to 2.4.0
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/dialog-v2.3.3...fs-v2.4.0)

Updates `ahooks` from 3.9.4 to 3.9.5
- [Release notes](https://github.com/alibaba/hooks/releases)
- [Commits](https://github.com/alibaba/hooks/compare/v3.9.4...v3.9.5)

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

Updates `@tailwindcss/postcss` from 4.1.12 to 4.1.13
- [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.13/packages/@tailwindcss-postcss)

Updates `@tauri-apps/cli` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v2.8.3...@tauri-apps/cli-v2.8.4)

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

Updates `lint-staged` from 16.1.5 to 16.1.6
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.1.5...v16.1.6)

Updates `tailwindcss` from 4.1.12 to 4.1.13
- [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.13/packages/tailwindcss)

Updates `tw-animate-css` from 1.3.7 to 1.3.8
- [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases)
- [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.3.7...v1.3.8)

Updates `dotenv` from 17.2.1 to 17.2.2
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.2.1...v17.2.2)

Updates `fingerprint-generator` from 2.1.70 to 2.1.72
- [Release notes](https://github.com/apify/fingerprint-suite/releases)
- [Commits](https://github.com/apify/fingerprint-suite/compare/v2.1.70...v2.1.72)

Updates `@babel/runtime` from 7.28.3 to 7.28.4
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.4/packages/babel-runtime)

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

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

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

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

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

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

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

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

Updates `@rollup/rollup-android-arm-eabi` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-android-arm64` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-darwin-arm64` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-darwin-x64` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-freebsd-arm64` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-freebsd-x64` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-arm64-musl` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-loongarch64-gnu` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-ppc64-gnu` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-x64-gnu` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-linux-x64-musl` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@rollup/rollup-win32-x64-msvc` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

Updates `@tailwindcss/node` from 4.1.12 to 4.1.13
- [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.13/packages/@tailwindcss-node)

Updates `@tailwindcss/oxide-android-arm64` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/android-arm64)

Updates `@tailwindcss/oxide-darwin-arm64` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/darwin-arm64)

Updates `@tailwindcss/oxide-darwin-x64` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/darwin-x64)

Updates `@tailwindcss/oxide-freebsd-x64` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/freebsd-x64)

Updates `@tailwindcss/oxide-linux-arm-gnueabihf` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/linux-arm-gnueabihf)

Updates `@tailwindcss/oxide-linux-arm64-gnu` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/linux-arm64-gnu)

Updates `@tailwindcss/oxide-linux-arm64-musl` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/linux-arm64-musl)

Updates `@tailwindcss/oxide-linux-x64-gnu` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/linux-x64-gnu)

Updates `@tailwindcss/oxide-linux-x64-musl` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/linux-x64-musl)

Updates `@tailwindcss/oxide-wasm32-wasi` from 4.1.12 to 4.1.13
- [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.13/crates/node)

Updates `@tailwindcss/oxide-win32-arm64-msvc` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/win32-arm64-msvc)

Updates `@tailwindcss/oxide-win32-x64-msvc` from 4.1.12 to 4.1.13
- [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.13/crates/node/npm/win32-x64-msvc)

Updates `@tailwindcss/oxide` from 4.1.12 to 4.1.13
- [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.13/crates/node)

Updates `@tauri-apps/cli-darwin-arm64` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-darwin-x64` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-linux-arm-gnueabihf` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-linux-arm64-gnu` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-linux-arm64-musl` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-linux-riscv64-gnu` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-linux-x64-gnu` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-linux-x64-musl` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-win32-arm64-msvc` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-win32-ia32-msvc` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `@tauri-apps/cli-win32-x64-msvc` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.8.3...tauri-v2.8.4)

Updates `browserslist` from 4.25.3 to 4.25.4
- [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.25.3...4.25.4)

Updates `dayjs` from 1.11.13 to 1.11.18
- [Release notes](https://github.com/iamkun/dayjs/releases)
- [Changelog](https://github.com/iamkun/dayjs/blob/v1.11.18/CHANGELOG.md)
- [Commits](https://github.com/iamkun/dayjs/compare/v1.11.13...v1.11.18)

Updates `electron-to-chromium` from 1.5.209 to 1.5.214
- [Changelog](https://github.com/Kilian/electron-to-chromium/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kilian/electron-to-chromium/compare/v1.5.209...v1.5.214)

Updates `generative-bayesian-network` from 2.1.70 to 2.1.72
- [Release notes](https://github.com/apify/fingerprint-suite/releases)
- [Commits](https://github.com/apify/fingerprint-suite/compare/v2.1.70...v2.1.72)

Updates `get-east-asian-width` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/sindresorhus/get-east-asian-width/releases)
- [Commits](https://github.com/sindresorhus/get-east-asian-width/compare/v1.3.0...v1.3.1)

Updates `header-generator` from 2.1.70 to 2.1.72
- [Release notes](https://github.com/apify/fingerprint-suite/releases)
- [Commits](https://github.com/apify/fingerprint-suite/compare/v2.1.70...v2.1.72)

Updates `listr2` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/listr2/listr2/releases)
- [Changelog](https://github.com/listr2/listr2/blob/master/release.config.js)
- [Commits](https://github.com/listr2/listr2/compare/listr2@9.0.2...listr2@9.0.3)

Updates `nano-spawn` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sindresorhus/nano-spawn/releases)
- [Commits](https://github.com/sindresorhus/nano-spawn/compare/v1.0.2...v1.0.3)

Updates `node-releases` from 2.0.19 to 2.0.20
- [Commits](https://github.com/chicoxyzzy/node-releases/commits)

Updates `rollup` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.49.0...v4.50.0)

---
updated-dependencies:
- dependency-name: "@tauri-apps/plugin-deep-link"
  dependency-version: 2.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-dialog"
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: ahooks
  dependency-version: 3.9.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli"
  dependency-version: 2.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 24.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: lint-staged
  dependency-version: 16.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: tw-animate-css
  dependency-version: 1.3.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: dotenv
  dependency-version: 17.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: fingerprint-generator
  dependency-version: 2.1.72
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@babel/runtime"
  dependency-version: 7.28.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-gnu"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/node"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-android-arm64"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darwin-arm64"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darwin-x64"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-freebsd-x64"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm-gnueabihf"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm64-gnu"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm64-musl"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-x64-gnu"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-x64-musl"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-wasm32-wasi"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-win32-arm64-msvc"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-win32-x64-msvc"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide"
  dependency-version: 4.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-arm64"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-x64"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm-gnueabihf"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-gnu"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-musl"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-riscv64-gnu"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-gnu"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-musl"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-arm64-msvc"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-ia32-msvc"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-x64-msvc"
  dependency-version: 2.8.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: browserslist
  dependency-version: 4.25.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: dayjs
  dependency-version: 1.11.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: electron-to-chromium
  dependency-version: 1.5.214
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: generative-bayesian-network
  dependency-version: 2.1.72
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: get-east-asian-width
  dependency-version: 1.3.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: header-generator
  dependency-version: 2.1.72
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: listr2
  dependency-version: 9.0.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: nano-spawn
  dependency-version: 1.0.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: node-releases
  dependency-version: 2.0.20
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-06 09:27:22 +00:00
dependabot[bot] 2bd01376db ci(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [actions/setup-node](https://github.com/actions/setup-node), [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action), [crate-ci/typos](https://github.com/crate-ci/typos) and [actions/stale](https://github.com/actions/stale).


Updates `actions/setup-node` from 4.4.0 to 5.0.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...a0853c24544627f65ddf259abe73b1d18a591444)

Updates `ridedott/merge-me-action` from 2.10.126 to 2.10.128
- [Release notes](https://github.com/ridedott/merge-me-action/releases)
- [Changelog](https://github.com/ridedott/merge-me-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ridedott/merge-me-action/compare/ad649157c69da4d34e601ee360de7a74ce4e2090...60142b76c22362f5845c877672fd2822b4d07c13)

Updates `crate-ci/typos` from 1.35.7 to 1.36.2
- [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/65f69f021b736bdbe548ce72200500752d42b40e...85f62a8a84f939ae994ab3763f01a0296d61a7ee)

Updates `actions/stale` from 9.1.0 to 10.0.0
- [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/5bef64f19d7facfb25b37b414482c7164d639639...3a9db7e6a41a89f618792c92c0e97cc736e1b13f)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.128
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.36.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-06 09:05:57 +00:00
zhom ba36956158 chore: next-env autogen 2025-09-03 22:23:40 +04:00
113 changed files with 17036 additions and 9464 deletions
+6 -4
View File
@@ -31,13 +31,15 @@ jobs:
# build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -55,7 +57,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
with:
workdir: ./src-tauri
+7
View File
@@ -1,7 +1,12 @@
name: Contributors
on:
push:
branches:
- main
release:
types:
- published
permissions:
contents: write
@@ -15,6 +20,8 @@ jobs:
contents: write
pull-requests: write
steps:
- name: Checkout repository
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
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@ad649157c69da4d34e601ee360de7a74ce4e2090 #v2.10.126
with:
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_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 -5
View File
@@ -3,17 +3,14 @@ name: Greetings
on:
pull_request:
types: [opened]
issues:
types: [opened]
jobs:
greeting:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@753c925c8d1ac6fede23781875376600628d9b5d # v3.0.0
- uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
with:
issue-message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
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."
+62 -47
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.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: |
@@ -93,16 +93,36 @@ jobs:
Be constructive and helpful in your feedback. If the issue is incomplete, provide specific guidance on what's needed.
model: openai/gpt-4o
- name: Check if first-time contributor
id: check-first-time
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
run: |
# Check if user has created issues before (excluding the current one)
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues" \
--jq "map(select(.user.login == \"$ISSUE_AUTHOR\" and .number != ${{ github.event.issue.number }})) | length" \
--paginate || echo "0")
if [ "$ISSUE_COUNT" = "0" ]; then
echo "is_first_time=true" >> $GITHUB_OUTPUT
echo "✅ First-time contributor detected"
else
echo "is_first_time=false" >> $GITHUB_OUTPUT
echo "️ Returning contributor"
fi
- 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
@@ -120,41 +140,35 @@ jobs:
echo "Issue validation result: $IS_VALID"
echo "Issue type: $ISSUE_TYPE"
# Prepare greeting message for first-time contributors
IS_FIRST_TIME="${{ steps.check-first-time.outputs.is_first_time }}"
GREETING_SECTION=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING_SECTION="## 👋 Welcome!\n\nThank you for your first issue ❤️ If this is a feature request, please make sure it is 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.\n\n---\n\n"
fi
if [ "$IS_VALID" = "false" ]; then
# Create a comment asking for more information
cat > comment.md << EOF
## 🤖 Issue Validation
Thank you for submitting this issue! However, it appears that some required information might be missing to help us better understand and address your concern.
**Issue Type Detected:** \`$ISSUE_TYPE\`
**Assessment:** $ASSESSMENT
### 📋 Missing Information:
$MISSING_INFO
### 💡 Suggestions for Improvement:
$SUGGESTIONS
### 📝 How to Provide Additional Information:
Please edit your original issue description to include the missing information. Here are our issue templates for reference:
- **Bug Report Template:** [View Template](.github/ISSUE_TEMPLATE/01-bug-report.md)
- **Feature Request Template:** [View Template](.github/ISSUE_TEMPLATE/02-feature-request.md)
### 🔧 Quick Tips:
- For **bug reports**: Include step-by-step reproduction instructions, your environment details, and any error messages
- For **feature requests**: Describe the use case, expected behavior, and why this feature would be valuable
- Add **screenshots** or **logs** when applicable
Once you've updated the issue with the missing information, feel free to remove this comment or reply to let us know you've made the updates.
---
*This validation was performed automatically to ensure we have all the information needed to help you effectively.*
EOF
{
printf "%b" "$GREETING_SECTION"
printf "## 🤖 Issue Validation\n\n"
printf "Thank you for submitting this issue! However, it appears that some required information might be missing to help us better understand and address your concern.\n\n"
printf "**Issue Type Detected:** \`%s\`\n\n" "$ISSUE_TYPE"
printf "**Assessment:** %s\n\n" "$ASSESSMENT"
printf "### 📋 Missing Information:\n%s\n\n" "$MISSING_INFO"
printf "### 💡 Suggestions for Improvement:\n%s\n\n" "$SUGGESTIONS"
printf "### 📝 How to Provide Additional Information:\n\n"
printf "Please edit your original issue description to include the missing information. Here are our issue templates for reference:\n\n"
printf -- "- **Bug Report Template:** [View Template](.github/ISSUE_TEMPLATE/01-bug-report.md)\n"
printf -- "- **Feature Request Template:** [View Template](.github/ISSUE_TEMPLATE/02-feature-request.md)\n\n"
printf "### 🔧 Quick Tips:\n"
printf -- "- For **bug reports**: Include step-by-step reproduction instructions, your environment details, and any error messages\n"
printf -- "- For **feature requests**: Describe the use case, expected behavior, and why this feature would be valuable\n"
printf -- "- Add **screenshots** or **logs** when applicable\n\n"
printf "Once you have updated the issue with the missing information, feel free to remove this comment or reply to let us know you have made the updates.\n\n"
printf -- "---\n*This validation was performed automatically to ensure we have all the information needed to help you effectively.*\n"
} > comment.md
# Post the comment
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
@@ -167,18 +181,19 @@ jobs:
echo "✅ Issue contains sufficient information"
# Prepare a summary comment even when valid
cat > comment.md << EOF
## 🤖 Issue Validation
SUGGESTIONS_SECTION=""
if [ -n "$SUGGESTIONS" ]; then
SUGGESTIONS_SECTION=$(printf "### 💡 Suggestions:\n%s\n\n" "$SUGGESTIONS")
fi
**Issue Type Detected:** \`$ISSUE_TYPE\`
**Assessment:** $ASSESSMENT
$( [ -n "$SUGGESTIONS" ] && echo "### 💡 Suggestions:" && echo "$SUGGESTIONS" )
---
*This validation was performed automatically to help triage issues.*
EOF
{
printf "%b" "$GREETING_SECTION"
printf "## 🤖 Issue Validation\n\n"
printf "**Issue Type Detected:** \`%s\`\n\n" "$ISSUE_TYPE"
printf "**Assessment:** %s\n\n" "$ASSESSMENT"
printf "%b" "$SUGGESTIONS_SECTION"
printf -- "---\n*This validation was performed automatically to help triage issues.*\n"
} > comment.md
# Post the summary comment
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
+5 -3
View File
@@ -34,13 +34,15 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
+32 -5
View File
@@ -41,13 +41,15 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -102,8 +104,33 @@ jobs:
cp nodecar/nodecar-bin.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
fi
- name: Create empty 'dist' directory
run: mkdir dist
- name: Build frontend
run: pnpm next build
- name: Get host target
id: host_target
shell: bash
run: |
HOST_TARGET=$(rustc -vV | sed -n 's|host: ||p')
echo "target=${HOST_TARGET}" >> $GITHUB_OUTPUT
echo "Host target: ${HOST_TARGET}"
- name: Build donut-proxy sidecar
shell: bash
working-directory: ./src-tauri
run: cargo build --bin donut-proxy
- name: Copy donut-proxy binary to Tauri binaries
shell: bash
run: |
mkdir -p src-tauri/binaries
HOST_TARGET="${{ steps.host_target.outputs.target }}"
if [[ "$HOST_TARGET" == *"windows"* ]]; then
cp src-tauri/target/debug/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
else
cp src-tauri/target/debug/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
fi
- name: Run rustfmt check
run: cargo fmt --all -- --check
+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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.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
+43 -11
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
with:
scan-args: |-
-r
@@ -105,15 +105,18 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
@@ -128,7 +131,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
with:
workdir: ./src-tauri
@@ -159,14 +162,43 @@ jobs:
# continue-on-error: true
- name: Build frontend
run: pnpm build
run: pnpm exec next build
- name: Verify frontend dist exists
shell: bash
run: |
if [ ! -d "dist" ]; then
echo "Error: dist directory not found after build"
ls -la
exit 1
fi
echo "Frontend dist directory verified at $(pwd)/dist"
echo "Checking from src-tauri perspective:"
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
- name: Build donut-proxy sidecar
shell: bash
working-directory: ./src-tauri
run: cargo build --bin donut-proxy --target ${{ matrix.target }} --release
- name: Copy donut-proxy binary to Tauri binaries
shell: bash
run: |
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
fi
- name: Build Tauri app
uses: tauri-apps/tauri-action@e834788a94591d81e3ae0bd9ec06366f5afb8994 #v0.5.23
uses: tauri-apps/tauri-action@19b93bb55601e3e373a93cfb6eb4242e45f5af20 #v0.6.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REF_NAME: ${{ github.ref_name }}
with:
projectPath: ./src-tauri
tagName: ${{ github.ref_name }}
releaseName: "Donut Browser ${{ github.ref_name }}"
releaseBody: "See the assets to download this version and install."
+43 -11
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
with:
scan-args: |-
-r
@@ -104,15 +104,18 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
@@ -127,7 +130,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
with:
workdir: ./src-tauri
@@ -158,7 +161,35 @@ jobs:
# continue-on-error: true
- name: Build frontend
run: pnpm build
run: pnpm exec next build
- name: Verify frontend dist exists
shell: bash
run: |
if [ ! -d "dist" ]; then
echo "Error: dist directory not found after build"
ls -la
exit 1
fi
echo "Frontend dist directory verified at $(pwd)/dist"
echo "Checking from src-tauri perspective:"
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
- name: Build donut-proxy sidecar
shell: bash
working-directory: ./src-tauri
run: cargo build --bin donut-proxy --target ${{ matrix.target }} --release
- name: Copy donut-proxy binary to Tauri binaries
shell: bash
run: |
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
fi
- name: Generate nightly timestamp
id: timestamp
@@ -170,13 +201,14 @@ jobs:
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
- name: Build Tauri app
uses: tauri-apps/tauri-action@e834788a94591d81e3ae0bd9ec06366f5afb8994 #v0.5.23
uses: tauri-apps/tauri-action@19b93bb55601e3e373a93cfb6eb4242e45f5af20 #v0.6.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_SHA: ${{ github.sha }}
with:
projectPath: ./src-tauri
tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
releaseName: "Donut Browser Nightly (Build ${{ steps.timestamp.outputs.timestamp }})"
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.timestamp.outputs.timestamp }}"
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Spell Check Repo
uses: crate-ci/typos@65f69f021b736bdbe548ce72200500752d42b40e #v1.35.7
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 #v1.40.0
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.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."
+21 -3
View File
@@ -1,6 +1,7 @@
{
"cSpell.words": [
"ABORTIFHUNG",
"aboutwelcome",
"adwaita",
"ahooks",
"akhilmhdh",
@@ -16,12 +17,14 @@
"busctl",
"CAMOU",
"camoufox",
"catppuccin",
"cdylib",
"certifi",
"CFURL",
"checkin",
"chrono",
"ciphertext",
"cksum",
"CLICOLOR",
"clippy",
"cmdk",
@@ -35,6 +38,7 @@
"datas",
"DBAPI",
"dconf",
"debuginfo",
"devedition",
"distro",
"doctest",
@@ -50,6 +54,7 @@
"esac",
"esbuild",
"etree",
"firstrun",
"flate",
"frontmost",
"geoip",
@@ -62,20 +67,25 @@
"hkcu",
"hooksconfig",
"hookspath",
"Hoverable",
"icns",
"idlelib",
"idletime",
"idna",
"infobars",
"Inno",
"kdeglobals",
"keras",
"KHTML",
"killall",
"Kolkata",
"kreadconfig",
"langpack",
"launchservices",
"letterboxing",
"libatk",
"libayatana",
"libc",
"libcairo",
"libgdk",
"libglib",
@@ -87,19 +97,20 @@
"lpdw",
"lxml",
"lzma",
"macchiato",
"Matchalk",
"mmdb",
"mountpoint",
"msiexec",
"mstone",
"msvc",
"msys",
"Mullvad",
"mullvadbrowser",
"mypy",
"noarchive",
"nobrowse",
"noconfirm",
"nodecar",
"NODELAY",
"nodemon",
"norestart",
"NSIS",
@@ -121,6 +132,7 @@
"plasmohq",
"platformdirs",
"prefs",
"PRIO",
"propertylist",
"psutil",
"pycache",
@@ -130,15 +142,20 @@
"pyoxidizer",
"pytest",
"pyyaml",
"reportingpolicy",
"reqwest",
"ridedott",
"rlib",
"rustc",
"rwxr",
"SARIF",
"scipy",
"screeninfo",
"selectables",
"serde",
"sessionstore",
"setpriority",
"setsid",
"SETTINGCHANGE",
"setuptools",
"shadcn",
@@ -169,9 +186,9 @@
"timedatectl",
"titlebar",
"tkinter",
"Torbrowser",
"tqdm",
"trackingprotection",
"trailhead",
"turbopack",
"turtledemo",
"udeps",
@@ -180,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="./.next/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.
-3
View File
@@ -23,6 +23,3 @@ fi
# Copy the file with target triple suffix
cp "nodecar-bin" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
# Also copy a generic version for Tauri to find
cp "nodecar-bin" "../src-tauri/binaries/nodecar${EXT}"
+9 -9
View File
@@ -21,18 +21,18 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.3.0",
"commander": "^14.0.0",
"donutbrowser-camoufox-js": "^0.6.6",
"dotenv": "^17.2.1",
"fingerprint-generator": "^2.1.70",
"@types/node": "^25.0.3",
"commander": "^14.0.2",
"donutbrowser-camoufox-js": "^0.7.0",
"dotenv": "^17.2.3",
"fingerprint-generator": "^2.1.78",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"playwright-core": "^1.55.0",
"proxy-chain": "^2.5.9",
"nodemon": "^3.1.11",
"playwright-core": "^1.57.0",
"proxy-chain": "^2.7.0",
"tmp": "^0.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
"typescript": "^5.9.3"
},
"devDependencies": {
"@types/tmp": "^0.2.6"
+87 -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";
}
/**
@@ -431,6 +484,13 @@ 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);
+124 -1
View File
@@ -1,18 +1,40 @@
import fs from "node:fs";
import path from "node:path";
import { launchOptions } from "donutbrowser-camoufox-js";
import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js";
import { type Browser, type BrowserContext, firefox } from "playwright-core";
import tmp from "tmp";
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
import { getEnvVars, parseProxyString } from "./utils.js";
// Set up debug logging to a file
const LOG_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox-logs");
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
function debugLog(id: string, message: string, data?: any): void {
const logFile = path.join(LOG_DIR, `${id}.log`);
const timestamp = new Date().toISOString();
const logMessage = data
? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
: `[${timestamp}] ${message}\n`;
fs.appendFileSync(logFile, logMessage);
}
/**
* Run a Camoufox browser server as a worker process
* @param id The Camoufox configuration ID
*/
export async function runCamoufoxWorker(id: string): Promise<void> {
debugLog(id, "Worker starting", { pid: process.pid });
// Get the Camoufox configuration
debugLog(id, "Loading Camoufox configuration");
const config = getCamoufoxConfig(id);
if (!config) {
debugLog(id, "Configuration not found");
console.error(
JSON.stringify({
error: "Configuration not found",
@@ -22,6 +44,13 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
process.exit(1);
}
debugLog(id, "Configuration loaded successfully", {
profilePath: config.profilePath,
hasOptions: !!config.options,
hasCustomConfig: !!config.customConfig,
hasUrl: !!config.url,
});
config.processId = process.pid;
saveCamoufoxConfig(config);
@@ -37,12 +66,14 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Launch browser in background - this can take time and may fail
setImmediate(async () => {
debugLog(id, "Starting browser launch in background");
let browser: Browser | null = null;
let context: BrowserContext | null = null;
let windowCheckInterval: NodeJS.Timeout | null = null;
// Graceful shutdown handler with access to browser and server
const gracefulShutdown = async () => {
debugLog(id, "Graceful shutdown initiated");
try {
// Clear any intervals first
if (windowCheckInterval) {
@@ -76,14 +107,19 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
process.on("unhandledRejection", () => void gracefulShutdown());
try {
debugLog(id, "Preparing launch options");
// Deep clone to avoid reference sharing and ensure fresh configuration for each instance
const camoufoxOptions: LaunchOptions = JSON.parse(
JSON.stringify(config.options || {}),
);
debugLog(id, "Base options cloned", {
hasOptions: Object.keys(camoufoxOptions).length,
});
// Add profile path if provided
if (config.profilePath) {
camoufoxOptions.user_data_dir = config.profilePath;
debugLog(id, "Set user_data_dir", { profilePath: config.profilePath });
}
// Ensure block options are properly set
@@ -111,52 +147,94 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
showcursor: false,
...(camoufoxOptions.config || {}),
};
debugLog(id, "Set default options", {
i_know_what_im_doing: true,
disableTheming: true,
showcursor: false,
});
// Generate fresh options for this specific instance
debugLog(id, "Generating launch options via launchOptions function");
const generatedOptions = await launchOptions(camoufoxOptions);
debugLog(id, "Launch options generated successfully", {
hasEnv: !!generatedOptions.env,
argsLength: generatedOptions.args?.length || 0,
});
// Start with process environment to ensure proper inheritance
let finalEnv = { ...process.env };
debugLog(id, "Base environment variables set", {
envVarCount: Object.keys(finalEnv).length,
});
// Add generated options environment variables
if (generatedOptions.env) {
finalEnv = { ...finalEnv, ...generatedOptions.env };
debugLog(id, "Added generated environment variables", {
generatedEnvCount: Object.keys(generatedOptions.env).length,
totalEnvCount: Object.keys(finalEnv).length,
});
}
// If we have a custom config from Rust, use it directly as environment variables
if (config.customConfig) {
debugLog(id, "Processing custom config", {
customConfigLength: config.customConfig.length,
});
try {
// Parse the custom config JSON string
const customConfigObj = JSON.parse(config.customConfig);
debugLog(id, "Custom config parsed successfully", {
customConfigKeys: Object.keys(customConfigObj),
});
// Ensure default config values are preserved even with custom config
const mergedConfig = {
...customConfigObj,
disableTheming: true,
showcursor: false,
// allowAddonNewTab will be handled from the fingerprint config if present
};
// Convert merged config to environment variables using getEnvVars
const customEnvVars = getEnvVars(mergedConfig);
debugLog(id, "Custom config converted to environment variables", {
customEnvVarCount: Object.keys(customEnvVars).length,
});
// Merge custom config with generated config (custom takes precedence)
finalEnv = { ...finalEnv, ...customEnvVars };
debugLog(id, "Custom config merged with final environment", {
finalEnvCount: Object.keys(finalEnv).length,
});
} catch (error) {
debugLog(id, "Failed to parse custom config", {
error: error instanceof Error ? error.message : String(error),
});
console.error(
`Camoufox worker ${id}: Failed to parse custom config, using generated config:`,
error,
);
await gracefulShutdown();
return;
}
} else {
debugLog(id, "No custom config provided");
}
// Prepare profile path for persistent context
const profilePath = config.profilePath || "";
debugLog(id, "Profile path prepared", { profilePath });
// Launch persistent context with the final configuration
const finalOptions: any = {
...generatedOptions,
env: finalEnv,
};
debugLog(id, "Final launch options prepared", {
hasExecutablePath: !!finalOptions.executablePath,
hasProxy: !!camoufoxOptions.proxy,
profilePath,
});
// If a custom executable path was provided, ensure Playwright uses it
if (
@@ -165,46 +243,66 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
) {
finalOptions.executablePath = (camoufoxOptions as any)
.executable_path as string;
debugLog(id, "Custom executable path set", {
executablePath: finalOptions.executablePath,
});
}
// Only add proxy if it exists and is valid
if (camoufoxOptions.proxy) {
debugLog(id, "Processing proxy configuration", {
proxyString: camoufoxOptions.proxy,
});
try {
finalOptions.proxy = parseProxyString(camoufoxOptions.proxy);
debugLog(id, "Proxy parsed successfully");
} catch (error) {
debugLog(id, "Failed to parse proxy", {
error: error instanceof Error ? error.message : String(error),
});
console.error({
message: "Failed to parse proxy, launching without proxy",
error,
});
await gracefulShutdown();
return;
}
}
// Use launchPersistentContext instead of launchServer
debugLog(id, "Launching persistent context", { profilePath });
context = await firefox.launchPersistentContext(
profilePath,
finalOptions,
);
debugLog(id, "Persistent context launched successfully");
// Get the browser instance from context
browser = context.browser();
debugLog(id, "Browser instance obtained from context", {
browserConnected: browser?.isConnected(),
});
// Handle browser disconnection for proper cleanup
if (browser) {
browser.on("disconnected", () => void gracefulShutdown());
debugLog(id, "Browser disconnect handler registered");
}
// Handle context close for proper cleanup
context.on("close", () => void gracefulShutdown());
debugLog(id, "Context close handler registered");
saveCamoufoxConfig(config);
// Monitor for window closure
const startWindowMonitoring = () => {
debugLog(id, "Starting window monitoring");
windowCheckInterval = setInterval(async () => {
try {
// Check if context is still active
if (!context?.pages || context.pages().length === 0) {
debugLog(id, "No pages found in context, shutting down");
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
}
@@ -214,6 +312,7 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Check if browser is still connected (if available)
if (browser && !browser.isConnected()) {
debugLog(id, "Browser disconnected, shutting down");
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
}
@@ -224,12 +323,16 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Check pages in the persistent context
const pages = context.pages();
if (pages.length === 0) {
debugLog(id, "No pages in context, shutting down");
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
}
await gracefulShutdown();
}
} catch {
} catch (error) {
debugLog(id, "Error in window monitoring", {
error: error instanceof Error ? error.message : String(error),
});
// If we can't check windows, assume browser is closing
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
@@ -241,19 +344,29 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Handle URL opening if provided
if (config.url) {
debugLog(id, "Opening URL in browser", { url: config.url });
try {
const pages = await context.pages();
if (pages.length) {
const page = pages[0];
debugLog(id, "Navigating to URL");
await page.goto(config.url, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
debugLog(id, "URL opened successfully");
// Start monitoring after page is created
startWindowMonitoring();
} else {
debugLog(id, "No pages available to open URL");
startWindowMonitoring();
}
} catch (urlError) {
debugLog(id, "Failed to open URL", {
error:
urlError instanceof Error ? urlError.message : String(urlError),
});
console.error({
message: "Failed to open URL",
error: urlError,
@@ -263,15 +376,18 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
startWindowMonitoring();
}
} else {
debugLog(id, "No URL provided, starting monitoring");
// Start monitoring after page is created
startWindowMonitoring();
}
// Monitor browser/context connection
debugLog(id, "Starting keep-alive monitoring");
const keepAlive = setInterval(async () => {
try {
// Check if context is still active
if (!context?.pages) {
debugLog(id, "Context not active in keep-alive, shutting down");
clearInterval(keepAlive);
await gracefulShutdown();
return;
@@ -279,11 +395,15 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Check browser connection if available
if (browser && !browser.isConnected()) {
debugLog(id, "Browser not connected in keep-alive, shutting down");
clearInterval(keepAlive);
await gracefulShutdown();
return;
}
} catch (error) {
debugLog(id, "Error in keep-alive check", {
error: error instanceof Error ? error.message : String(error),
});
console.error({
message: "Error in keepAlive check",
error,
@@ -293,6 +413,9 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
}
}, 2000);
} catch (error) {
debugLog(id, "Failed to launch Camoufox", {
error: error instanceof Error ? error.message : String(error),
});
console.error({
message: "Failed to launch Camoufox",
error,
+8 -139
View File
@@ -8,145 +8,6 @@ import {
} from "./camoufox-launcher.js";
import { listCamoufoxConfigs } from "./camoufox-storage.js";
import { runCamoufoxWorker } from "./camoufox-worker.js";
import {
startProxyProcess,
stopAllProxyProcesses,
stopProxyProcess,
} from "./proxy-runner";
import { listProxyConfigs } from "./proxy-storage";
import { runProxyWorker } from "./proxy-worker";
// Command for proxy management
program
.command("proxy")
.argument("<action>", "start, stop, or list proxies")
.option("--host <host>", "upstream proxy host")
.option("--proxy-port <port>", "upstream proxy port", Number.parseInt)
.option("--type <type>", "proxy type (http, https, socks4, socks5)")
.option("--username <username>", "proxy username")
.option("--password <password>", "proxy password")
.option(
"-p, --port <number>",
"local port to use (random if not specified)",
Number.parseInt,
)
.option("--ignore-certificate", "ignore certificate errors for HTTPS proxies")
.option("--id <id>", "proxy ID for stop command")
.option(
"-u, --upstream <url>",
"upstream proxy URL (protocol://[username:password@]host:port)",
)
.description("manage proxy servers")
.action(
async (
action: string,
options: {
host?: string;
proxyPort?: number;
type?: string;
username?: string;
password?: string;
port?: number;
ignoreCertificate?: boolean;
id?: string;
upstream?: string;
},
) => {
if (action === "start") {
let upstreamUrl: string | undefined;
// Build upstream URL from individual components if provided
if (options.host && options.proxyPort && options.type) {
// Preserve provided scheme (http, https, socks4, socks5)
const protocol = String(options.type).toLowerCase();
const auth =
options.username && options.password
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
options.password,
)}@`
: "";
upstreamUrl = `${protocol}://${auth}${options.host}:${options.proxyPort}`;
} else if (options.upstream) {
upstreamUrl = options.upstream;
}
// If no upstream is provided, create a direct proxy
try {
const config = await startProxyProcess(upstreamUrl, {
port: options.port,
ignoreProxyCertificate: options.ignoreCertificate,
});
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: config.id,
localPort: config.localPort,
localUrl: config.localUrl,
upstreamUrl: config.upstreamUrl,
}),
);
// Exit successfully to allow the process to detach
process.exit(0);
} catch (error: unknown) {
console.error(
`Failed to start proxy: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`,
);
process.exit(1);
}
} else if (action === "stop") {
if (options.id) {
const stopped = await stopProxyProcess(options.id);
console.log(JSON.stringify({ success: stopped }));
} else if (options.upstream) {
// Find proxies with this upstream URL
const configs = listProxyConfigs().filter(
(config) => config.upstreamUrl === options.upstream,
);
if (configs.length === 0) {
console.error(`No proxies found for ${options.upstream}`);
process.exit(1);
return;
}
for (const config of configs) {
const stopped = await stopProxyProcess(config.id);
console.log(JSON.stringify({ success: stopped }));
}
} else {
await stopAllProxyProcesses();
console.log(JSON.stringify({ success: true }));
}
process.exit(0);
} else if (action === "list") {
const configs = listProxyConfigs();
console.log(JSON.stringify(configs));
process.exit(0);
} else {
console.error("Invalid action. Use 'start', 'stop', or 'list'");
process.exit(1);
}
},
);
// Command for proxy worker (internal use)
program
.command("proxy-worker")
.argument("<action>", "start a proxy worker")
.requiredOption("--id <id>", "proxy configuration ID")
.description("run a proxy worker process")
.action(async (action: string, options: { id: string }) => {
if (action === "start") {
await runProxyWorker(options.id);
} else {
console.error("Invalid action for proxy-worker. Use 'start'");
process.exit(1);
}
});
// Command for Camoufox management
program
@@ -173,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(
@@ -423,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);
-124
View File
@@ -1,124 +0,0 @@
import { spawn } from "node:child_process";
import path from "node:path";
import getPort from "get-port";
import {
deleteProxyConfig,
generateProxyId,
getProxyConfig,
isProcessRunning,
listProxyConfigs,
type ProxyConfig,
saveProxyConfig,
} from "./proxy-storage";
/**
* Start a proxy in a separate process
* @param upstreamUrl The upstream proxy URL (optional for direct proxy)
* @param options Optional configuration
* @returns Promise resolving to the proxy configuration
*/
export async function startProxyProcess(
upstreamUrl?: string,
options: { port?: number; ignoreProxyCertificate?: boolean } = {},
): Promise<ProxyConfig> {
// Generate a unique ID for this proxy
const id = generateProxyId();
// Get a random available port if not specified
const port = options.port ?? (await getPort());
// Create the proxy configuration
const config: ProxyConfig = {
id,
upstreamUrl: upstreamUrl || "DIRECT",
localPort: port,
ignoreProxyCertificate: options.ignoreProxyCertificate ?? false,
};
// Save the configuration before starting the process
saveProxyConfig(config);
// Build the command arguments
const args = [
path.join(__dirname, "index.js"),
"proxy-worker",
"start",
"--id",
id,
];
// Spawn the process with proper detachment
const child = spawn(process.execPath, args, {
detached: true,
stdio: ["ignore", "ignore", "ignore"], // Completely ignore all stdio
cwd: process.cwd(),
});
// Unref the child to allow the parent to exit independently
child.unref();
// Store the process ID and local URL
config.pid = child.pid;
config.localUrl = `http://127.0.0.1:${port}`;
// Update the configuration with the process ID
saveProxyConfig(config);
// Give the worker a moment to start before returning
await new Promise((resolve) => setTimeout(resolve, 100));
return config;
}
/**
* Stop a proxy process
* @param id The proxy ID to stop
* @returns Promise resolving to true if stopped, false if not found
*/
export async function stopProxyProcess(id: string): Promise<boolean> {
const config = getProxyConfig(id);
if (!config?.pid) {
// Try to delete the config anyway in case it exists without a PID
deleteProxyConfig(id);
return false;
}
try {
// Check if the process is running
if (isProcessRunning(config.pid)) {
// Send SIGTERM to the process
process.kill(config.pid, "SIGTERM");
// Wait a bit to ensure the process has terminated
await new Promise((resolve) => setTimeout(resolve, 500));
// If still running, send SIGKILL
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGKILL");
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
// Delete the configuration
deleteProxyConfig(id);
return true;
} catch (error) {
console.error(`Error stopping proxy ${id}:`, error);
// Delete the configuration even if stopping failed
deleteProxyConfig(id);
return false;
}
}
/**
* Stop all proxy processes
* @returns Promise resolving when all proxies are stopped
*/
export async function stopAllProxyProcesses(): Promise<void> {
const configs = listProxyConfigs();
const stopPromises = configs.map((config) => stopProxyProcess(config.id));
await Promise.all(stopPromises);
}
-150
View File
@@ -1,150 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import tmp from "tmp";
export interface ProxyConfig {
id: string;
upstreamUrl: string; // Can be "DIRECT" for direct proxy
localPort?: number;
ignoreProxyCertificate?: boolean;
localUrl?: string;
pid?: number;
}
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "proxies");
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
/**
* Save a proxy configuration to disk
* @param config The proxy configuration to save
*/
export function saveProxyConfig(config: ProxyConfig): void {
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
}
/**
* Get a proxy configuration by ID
* @param id The proxy ID
* @returns The proxy configuration or null if not found
*/
export function getProxyConfig(id: string): ProxyConfig | null {
const filePath = path.join(STORAGE_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, "utf-8");
return JSON.parse(content) as ProxyConfig;
} catch (error) {
console.error(`Error reading proxy config ${id}:`, error);
return null;
}
}
/**
* Delete a proxy configuration
* @param id The proxy ID to delete
* @returns True if deleted, false if not found
*/
export function deleteProxyConfig(id: string): boolean {
const filePath = path.join(STORAGE_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return false;
}
try {
fs.unlinkSync(filePath);
return true;
} catch (error) {
console.error(`Error deleting proxy config ${id}:`, error);
return false;
}
}
/**
* List all saved proxy configurations
* @returns Array of proxy configurations
*/
export function listProxyConfigs(): ProxyConfig[] {
if (!fs.existsSync(STORAGE_DIR)) {
return [];
}
try {
return fs
.readdirSync(STORAGE_DIR)
.filter((file) => file.endsWith(".json"))
.map((file) => {
try {
const content = fs.readFileSync(
path.join(STORAGE_DIR, file),
"utf-8",
);
return JSON.parse(content) as ProxyConfig;
} catch (error) {
console.error(`Error reading proxy config ${file}:`, error);
return null;
}
})
.filter((config): config is ProxyConfig => config !== null);
} catch (error) {
console.error("Error listing proxy configs:", error);
return [];
}
}
/**
* Update a proxy configuration
* @param config The proxy configuration to update
* @returns True if updated, false if not found
*/
export function updateProxyConfig(config: ProxyConfig): boolean {
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
try {
fs.readFileSync(filePath, "utf-8");
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
console.error(
`Config ${config.id} was deleted while the app was running`,
);
return false;
}
console.error(`Error updating proxy config ${config.id}:`, error);
return false;
}
}
/**
* Check if a proxy process is running
* @param pid The process ID to check
* @returns True if running, false otherwise
*/
export function isProcessRunning(pid: number): boolean {
try {
// The kill method with signal 0 doesn't actually kill the process
// but checks if it exists
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Generate a unique ID for a proxy
* @returns A unique ID string
*/
export function generateProxyId(): string {
return `proxy_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
}
-70
View File
@@ -1,70 +0,0 @@
import { Server } from "proxy-chain";
import { getProxyConfig, updateProxyConfig } from "./proxy-storage";
/**
* Run a proxy server as a worker process
* @param id The proxy configuration ID
*/
export async function runProxyWorker(id: string): Promise<void> {
// Get the proxy configuration
const config = getProxyConfig(id);
if (!config) {
console.error(`Proxy configuration ${id} not found`);
process.exit(1);
}
// Create a new proxy server
const server = new Server({
port: config.localPort,
host: "127.0.0.1",
prepareRequestFunction: () => {
// If upstreamUrl is "DIRECT", don't use upstream proxy
if (config.upstreamUrl === "DIRECT") {
return {};
}
return {
upstreamProxyUrl: config.upstreamUrl,
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false,
};
},
});
// Handle process termination gracefully
const gracefulShutdown = async () => {
try {
await server.close(true);
} catch {}
process.exit(0);
};
process.on("SIGTERM", () => void gracefulShutdown());
process.on("SIGINT", () => void gracefulShutdown());
// Handle uncaught exceptions
process.on("uncaughtException", () => {
process.exit(1);
});
process.on("unhandledRejection", () => {
process.exit(1);
});
// Start the server
try {
await server.listen();
// Update the config with the actual port (in case it was auto-assigned)
config.localPort = server.port;
config.localUrl = `http://127.0.0.1:${server.port}`;
updateProxyConfig(config);
// Keep the process alive
setInterval(() => {
// Do nothing, just keep the process alive
}, 60000);
} catch (error) {
console.error(`Failed to start proxy worker ${id}:`, error);
process.exit(1);
}
}
+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) {
+36 -29
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.12.2",
"version": "0.13.8",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -21,59 +21,66 @@
"format": "pnpm format:js && pnpm format:rust",
"cargo": "cd src-tauri && cargo",
"unused-exports:js": "ts-unused-exports tsconfig.json",
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands"
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands",
"copy-proxy-binary": "cd src-tauri && bash copy-proxy-binary.sh",
"prebuild": "pnpm copy-proxy-binary",
"pretauri:dev": "pnpm copy-proxy-binary",
"precargo": "pnpm copy-proxy-binary"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-deep-link": "^2.4.2",
"@tauri-apps/plugin-dialog": "^2.3.3",
"@tauri-apps/plugin-fs": "~2.4.2",
"@tauri-apps/plugin-opener": "^2.5.0",
"ahooks": "^3.9.4",
"@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",
"@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"ahooks": "^3.9.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.0",
"lucide-react": "^0.542.0",
"motion": "^12.23.12",
"next": "^15.5.2",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"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.1.1",
"react-dom": "^19.1.1",
"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.3.1",
"tailwind-merge": "^3.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.2.2",
"@tailwindcss/postcss": "^4.1.12",
"@tauri-apps/cli": "^2.8.3",
"@biomejs/biome": "2.3.10",
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/cli": "^2.9.6",
"@types/color": "^4.2.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"@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.1.5",
"tailwindcss": "^4.1.12",
"lint-staged": "^16.2.7",
"tailwindcss": "^4.1.18",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.3.7",
"typescript": "~5.9.2"
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
},
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"lint-staged": {
+2150 -2697
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,8 +1,10 @@
packages:
- nodecar
onlyBuiltDependencies:
- '@biomejs/biome'
- '@tailwindcss/oxide'
- better-sqlite3
- esbuild
- sharp
- sqlite3
+4
View File
@@ -0,0 +1,4 @@
[build]
# Omit jobs setting to use all cores
incremental = true
+1448 -1067
View File
File diff suppressed because it is too large Load Diff
+51 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.12.2"
version = "0.13.8"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -12,10 +12,18 @@ default-run = "donutbrowser"
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "donutbrowser"
name = "donutbrowser_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
doctest = false
[[bin]]
name = "donutbrowser"
path = "src/main.rs"
[[bin]]
name = "donut-proxy"
path = "src/bin/proxy_server.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -29,32 +37,43 @@ tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
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"] }
reqwest = { version = "0.12", features = ["json", "stream", "socks"] }
tokio = { version = "1", features = ["full", "sync"] }
sysinfo = "0.37"
lazy_static = "1.4"
base64 = "0.22"
libc = "0.2"
async-trait = "0.1"
futures-util = "0.3"
zip = "4"
zip = "7"
tar = "0"
bzip2 = "0"
flate2 = "1"
lzma-rs = "0"
msi-extract = "0"
uuid = { version = "1.0", 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.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
clap = { version = "4", features = ["derive"] }
async-socks5 = "0.6"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
@@ -66,12 +85,13 @@ objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
windows = { version = "0.61", features = [
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
"Win32_System_Diagnostics_Debug",
"Win32_System_SystemInformation",
"Win32_System_IO",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Registry",
@@ -81,17 +101,37 @@ windows = { version = "0.61", 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"
tower-http = { version = "0.6", features = ["fs", "trace"] }
futures-util = "0.3"
serial_test = "3"
# Integration test configuration
[[test]]
name = "nodecar_integration"
path = "tests/nodecar_integration.rs"
name = "donut_proxy_integration"
path = "tests/donut_proxy_integration.rs"
[profile.dev]
codegen-units = 256
incremental = true
opt-level = 0
# Split debuginfo on macOS for faster linking (ignored on other platforms)
split-debuginfo = "unpacked"
[profile.release]
codegen-units = 1
opt-level = 3
lto = "thin"
# Split debuginfo on macOS for faster linking (ignored on other platforms)
split-debuginfo = "unpacked"
[profile.test]
# Optimize test builds for faster compilation
codegen-units = 256
incremental = true
[features]
# by default Tauri runs in production mode
+6 -6
View File
@@ -3,15 +3,15 @@
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Donut Browser needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually.</string>
<string>Donut needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Donut Browser needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
<string>Donut needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Donut Browser has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
<string>Donut has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
<key>CFBundleDisplayName</key>
<string>Donut Browser</string>
<string>Donut</string>
<key>CFBundleName</key>
<string>Donut Browser</string>
<string>Donut</string>
<key>CFBundleIdentifier</key>
<string>com.donutbrowser</string>
<key>CFBundleURLName</key>
@@ -25,7 +25,7 @@
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Donut Browser</string>
<string>Copyright © 2025 Donut</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
+54 -1
View File
@@ -1,4 +1,6 @@
fn main() {
println!("cargo::rustc-check-cfg=cfg(mobile)");
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-lib=framework=CoreFoundation");
@@ -34,5 +36,56 @@ fn main() {
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
}
tauri_build::build()
// Tell Cargo to rebuild if the proxy binary source changes
println!("cargo:rerun-if-changed=src/bin/proxy_server.rs");
println!("cargo:rerun-if-changed=src/proxy_server.rs");
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() {
tauri_build::build()
} else {
println!("cargo:warning=Skipping tauri_build: external binaries not found. This is expected when building sidecar binaries.");
}
}
fn external_binaries_exist() -> bool {
use std::env;
use std::path::PathBuf;
let manifest_dir = match env::var("CARGO_MANIFEST_DIR") {
Ok(dir) => dir,
Err(_) => return false,
};
let target = match env::var("TARGET") {
Ok(t) => t,
Err(_) => return false,
};
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
// Check for both required external binaries
let nodecar_name = if target.contains("windows") {
format!("nodecar-{}.exe", target)
} else {
format!("nodecar-{}", target)
};
let donut_proxy_name = if target.contains("windows") {
format!("donut-proxy-{}.exe", target)
} else {
format!("donut-proxy-{}", target)
};
let nodecar_exists = binaries_dir.join(&nodecar_name).exists();
let donut_proxy_exists = binaries_dir.join(&donut_proxy_name).exists();
nodecar_exists && donut_proxy_exists
}
+12 -2
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",
@@ -29,6 +38,7 @@
"macos-permissions:allow-request-microphone-permission",
"macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission"
"macos-permissions:allow-check-camera-permission",
"log:default"
]
}
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
set -e
# Get the target triple from environment or use default
TARGET="${TARGET:-$(rustc -vV 2>/dev/null | sed -n 's|host: ||p' || echo "unknown")}"
MANIFEST_DIR="$(dirname "$0")"
# Determine binary name based on target
if [[ "$TARGET" == *"windows"* ]]; then
BIN_NAME="donut-proxy.exe"
else
BIN_NAME="donut-proxy"
fi
# Determine source path
HOST_TARGET=$(rustc -vV 2>/dev/null | sed -n 's|host: ||p' || echo "$TARGET")
if [[ "$TARGET" == "$HOST_TARGET" ]] || [[ "$TARGET" == "unknown" ]]; then
# Native target - use debug or release based on profile
if [[ "${PROFILE:-debug}" == "release" ]]; then
SRC_DIR="$MANIFEST_DIR/target/release"
else
SRC_DIR="$MANIFEST_DIR/target/debug"
fi
else
# Cross-compilation target
if [[ "${PROFILE:-debug}" == "release" ]]; then
SRC_DIR="$MANIFEST_DIR/target/$TARGET/release"
else
SRC_DIR="$MANIFEST_DIR/target/$TARGET/debug"
fi
fi
SOURCE="$SRC_DIR/$BIN_NAME"
DEST_DIR="$MANIFEST_DIR/binaries"
# Tauri expects the format: donut-proxy-{target} with hyphens (same as nodecar)
DEST_NAME="donut-proxy-$TARGET"
if [[ "$TARGET" == *"windows"* ]]; then
DEST_NAME="$DEST_NAME.exe"
fi
DEST="$DEST_DIR/$DEST_NAME"
# Create binaries directory if it doesn't exist
mkdir -p "$DEST_DIR"
# Copy the binary if it exists
if [[ -f "$SOURCE" ]]; then
cp "$SOURCE" "$DEST"
echo "Copied $BIN_NAME to $DEST"
else
echo "Warning: Binary not found at $SOURCE"
echo "Building donut-proxy binary..."
cd "$MANIFEST_DIR"
BUILD_ARGS=("build" "--bin" "donut-proxy")
if [[ -n "$PROFILE" ]] && [[ "$PROFILE" == "release" ]]; then
BUILD_ARGS+=("--release")
fi
if [[ -n "$TARGET" ]] && [[ "$TARGET" != "unknown" ]] && [[ "$TARGET" != "$HOST_TARGET" ]]; then
BUILD_ARGS+=("--target" "$TARGET")
fi
cargo "${BUILD_ARGS[@]}"
if [[ -f "$SOURCE" ]]; then
cp "$SOURCE" "$DEST"
echo "Built and copied $BIN_NAME to $DEST"
else
echo "Error: Failed to build donut-proxy binary"
exit 1
fi
fi
+4 -1
View File
@@ -1,7 +1,10 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Donut Browser
Name=Donut
Name[en]=Donut
GenericName=Web Browser
X-GNOME-FullName=Donut
Comment=Simple Yet Powerful Anti-Detect Browser
Exec=donutbrowser %u
Icon=donutbrowser
+83 -448
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(),
}
}
@@ -407,8 +404,8 @@ impl ApiClient {
let text = response.text().await?;
let mut page_releases: Vec<GithubRelease> = serde_json::from_str(&text).map_err(|e| {
eprintln!("Failed to parse GitHub API response (page {page}): {e}");
eprintln!(
log::error!("Failed to parse GitHub API response (page {page}): {e}");
log::error!(
"Response text (first 500 chars): {}",
if text.len() > 500 {
&text[..500]
@@ -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,
}
}
@@ -487,13 +482,13 @@ impl ApiClient {
let content = fs::read_to_string(&cache_file).ok()?;
if let Ok(cached) = serde_json::from_str::<CachedVersionData>(&content) {
// Always return cached releases regardless of age - they're always valid
println!("Using cached versions for {browser}");
log::info!("Using cached versions for {browser}");
return Some(cached.releases);
}
// Backward compatibility: legacy caches stored just an array of version strings
if let Ok(legacy_versions) = serde_json::from_str::<Vec<String>>(&content) {
println!("Using legacy cached versions for {browser}; upgrading in-memory");
log::info!("Using legacy cached versions for {browser}; upgrading in-memory");
let releases: Vec<BrowserRelease> = legacy_versions
.into_iter()
.map(|version| BrowserRelease {
@@ -548,7 +543,7 @@ impl ApiClient {
let content = serde_json::to_string_pretty(&cached_data)?;
fs::write(&cache_file, content)?;
println!("Cached {} versions for {}", releases.len(), browser);
log::info!("Cached {} versions for {}", releases.len(), browser);
Ok(())
}
@@ -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
println!("Using cached GitHub releases for {browser}");
Some(cached_data.releases)
}
@@ -588,7 +582,7 @@ impl ApiClient {
let content = serde_json::to_string_pretty(&cached_data)?;
fs::write(&cache_file, content)?;
println!("Cached {} GitHub releases for {}", releases.len(), browser);
log::info!("Cached {} GitHub releases for {}", releases.len(), browser);
Ok(())
}
@@ -603,7 +597,7 @@ impl ApiClient {
}
}
println!("Fetching Firefox releases from Mozilla API...");
log::info!("Fetching Firefox releases from Mozilla API...");
let url = format!("{}/firefox.json", self.firefox_api_base);
let response = self
@@ -648,7 +642,7 @@ impl ApiClient {
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_versions("firefox", &releases) {
eprintln!("Failed to cache Firefox versions: {e}");
log::error!("Failed to cache Firefox versions: {e}");
}
}
@@ -666,7 +660,7 @@ impl ApiClient {
}
}
println!("Fetching Firefox Developer Edition releases from Mozilla API...");
log::info!("Fetching Firefox Developer Edition releases from Mozilla API...");
let url = format!("{}/devedition.json", self.firefox_dev_api_base);
let response = self
@@ -682,7 +676,7 @@ impl ApiClient {
response.status(),
url
);
eprintln!("{error_msg}");
log::error!("{error_msg}");
return Err(error_msg.into());
}
@@ -717,46 +711,7 @@ impl ApiClient {
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_versions("firefox-developer", &releases) {
eprintln!("Failed to cache Firefox Developer versions: {e}");
}
}
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);
}
}
println!("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) {
eprintln!("Failed to cache Mullvad releases: {e}");
log::error!("Failed to cache Firefox Developer versions: {e}");
}
}
@@ -774,7 +729,7 @@ impl ApiClient {
}
}
println!("Fetching Zen releases from GitHub API");
log::info!("Fetching Zen releases from GitHub API");
let base_url = format!(
"{}/repos/zen-browser/desktop/releases",
self.github_api_base
@@ -792,7 +747,7 @@ impl ApiClient {
if release.tag_name.to_lowercase() == "twilight" {
if let Ok(has_update) = self.check_twilight_update(release).await {
if has_update {
println!(
log::info!(
"Detected update for Zen twilight release: {}",
release.tag_name
);
@@ -807,7 +762,7 @@ impl ApiClient {
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_github_releases("zen", &releases) {
eprintln!("Failed to cache Zen releases: {e}");
log::error!("Failed to cache Zen releases: {e}");
}
}
@@ -825,7 +780,7 @@ impl ApiClient {
}
}
println!("Fetching Brave releases from GitHub API");
log::info!("Fetching Brave releases from GitHub API");
let base_url = format!(
"{}/repos/brave/brave-browser/releases",
self.github_api_base
@@ -843,7 +798,6 @@ impl ApiClient {
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os);
if has_compatible_asset {
println!("release.name: {:?}", release.name);
// Use the centralized nightly detection function
release.is_nightly =
is_browser_version_nightly("brave", &release.tag_name, Some(&release.name));
@@ -858,7 +812,7 @@ impl ApiClient {
sort_github_releases(&mut filtered_releases);
if let Err(e) = self.save_cached_github_releases("brave", &filtered_releases) {
eprintln!("Failed to cache Brave releases: {e}");
log::error!("Failed to cache Brave releases: {e}");
}
Ok(filtered_releases)
@@ -882,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)
})
}
@@ -946,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)
@@ -978,7 +939,7 @@ impl ApiClient {
}
}
println!("Fetching Chromium releases...");
log::info!("Fetching Chromium releases...");
// Get the latest version first
let latest_version = self.fetch_chromium_latest_version().await?;
@@ -1006,7 +967,7 @@ impl ApiClient {
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_versions("chromium", &releases) {
eprintln!("Failed to cache Chromium versions: {e}");
log::error!("Failed to cache Chromium versions: {e}");
}
}
@@ -1020,7 +981,7 @@ impl ApiClient {
// Check cache first (unless bypassing)
if !no_caching {
if let Some(cached_releases) = self.load_cached_github_releases("camoufox") {
println!(
log::info!(
"Using cached Camoufox releases, count: {}",
cached_releases.len()
);
@@ -1028,18 +989,18 @@ impl ApiClient {
}
}
println!("Fetching Camoufox releases from GitHub API");
log::info!("Fetching Camoufox releases from GitHub API");
let base_url = format!("{}/repos/daijro/camoufox/releases", self.github_api_base);
let releases: Vec<GithubRelease> = self.fetch_github_releases_multiple_pages(&base_url).await?;
println!(
log::info!(
"Fetched {} total Camoufox releases from GitHub",
releases.len()
);
// Get platform info to filter appropriate releases
let (os, arch) = Self::get_platform_info();
println!("Filtering for platform: {os}/{arch}");
log::info!("Filtering for platform: {os}/{arch}");
// Filter releases that have assets compatible with the current platform
let mut compatible_releases: Vec<GithubRelease> = releases
@@ -1048,11 +1009,14 @@ impl ApiClient {
.filter_map(|(i, release)| {
let has_compatible = self.has_compatible_camoufox_asset(&release.assets, &os, &arch);
if !has_compatible {
println!(
log::info!(
"Release {} ({}) has no compatible assets for {}/{}",
i, release.tag_name, os, arch
i,
release.tag_name,
os,
arch
);
println!(
log::info!(
" Available assets: {:?}",
release.assets.iter().map(|a| &a.name).collect::<Vec<_>>()
);
@@ -1065,13 +1029,13 @@ impl ApiClient {
})
.collect();
println!(
log::info!(
"After platform filtering: {} compatible releases",
compatible_releases.len()
);
// Sort by version (latest first) with debugging
println!(
log::info!(
"Before sorting: {:?}",
compatible_releases
.iter()
@@ -1080,7 +1044,7 @@ impl ApiClient {
.collect::<Vec<_>>()
);
sort_github_releases(&mut compatible_releases);
println!(
log::info!(
"After sorting: {:?}",
compatible_releases
.iter()
@@ -1092,116 +1056,15 @@ impl ApiClient {
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_github_releases("camoufox", &compatible_releases) {
eprintln!("Failed to cache Camoufox releases: {e}");
log::error!("Failed to cache Camoufox releases: {e}");
} else {
println!("Cached {} Camoufox releases", compatible_releases.len());
log::info!("Cached {} Camoufox releases", compatible_releases.len());
}
}
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);
}
}
println!("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) {
eprintln!("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,
@@ -1248,9 +1111,10 @@ impl ApiClient {
// File size changed, update cache and return true
let content = serde_json::to_string_pretty(&current_info)?;
fs::write(&twilight_cache_file, content)?;
println!(
log::info!(
"Zen twilight release updated: file size changed from {} to {}",
cached_info.file_size, current_info.file_size
cached_info.file_size,
current_info.file_size
);
return Ok(true);
}
@@ -1268,10 +1132,10 @@ impl ApiClient {
let path = entry.path();
if path.is_file() {
fs::remove_file(&path)?;
println!("Removed cache file: {path:?}");
log::info!("Removed cache file: {path:?}");
}
}
println!("All version cache cleared successfully");
log::info!("All version cache cleared successfully");
}
Ok(())
@@ -1300,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
)
}
@@ -1474,7 +1337,7 @@ mod tests {
let result = client.fetch_firefox_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Firefox API test error: {e}");
log::info!("Firefox API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
@@ -1516,7 +1379,7 @@ mod tests {
.await;
if let Err(e) = &result {
println!("Firefox Developer API test error: {e}");
log::info!("Firefox Developer API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
@@ -1524,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;
@@ -1651,7 +1473,7 @@ mod tests {
let result = client.fetch_brave_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Brave API test error: {e}");
log::info!("Brave API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
@@ -1665,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")
@@ -1693,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")
@@ -1718,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"));
@@ -1908,92 +1621,14 @@ 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
let v22 = VersionComponent::parse("135.0.5beta22");
let v24 = VersionComponent::parse("135.0.5beta24");
println!("v22: {v22:?}");
println!("v24: {v24:?}");
log::info!("v22: {v22:?}");
log::info!("v24: {v24:?}");
// v24 should be greater than v22
assert!(
@@ -2016,7 +1651,7 @@ mod tests {
sort_versions(&mut versions);
println!("Sorted versions: {versions:?}");
log::info!("Sorted versions: {versions:?}");
// Should be sorted from newest to oldest
assert_eq!(versions[0], "135.0.5beta24");
@@ -2031,8 +1666,8 @@ mod tests {
let v22 = VersionComponent::parse("135.0beta22");
let v24 = VersionComponent::parse("135.0.1beta24");
println!("User reported v22: {v22:?}");
println!("User reported v24: {v24:?}");
log::info!("User reported v22: {v22:?}");
log::info!("User reported v24: {v24:?}");
// 135.0.1beta24 should be greater than 135.0beta22 (newer patch version)
assert!(
@@ -2045,7 +1680,7 @@ mod tests {
sort_versions(&mut versions);
println!("User reported sorted versions: {versions:?}");
log::info!("User reported sorted versions: {versions:?}");
// Should be sorted from newest to oldest
assert_eq!(
+565 -91
View File
@@ -1,27 +1,29 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{Path, Query, State},
extract::{Path, State},
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{Json, Response},
routing::{delete, get, post, put},
routing::get,
Router,
};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::Emitter;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tower_http::cors::CorsLayer;
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,
@@ -31,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>>,
@@ -77,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,
@@ -134,13 +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, ToSchema)]
struct RunProfileRequest {
url: Option<String>,
headless: Option<bool>,
}
#[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<()>>,
@@ -197,38 +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("/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);
@@ -334,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() {
@@ -368,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>,
@@ -403,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>,
@@ -473,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>,
@@ -554,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>,
@@ -566,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> {
@@ -590,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>,
@@ -613,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>,
@@ -630,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>,
@@ -648,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>,
@@ -662,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() {
@@ -673,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> {
@@ -683,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>,
@@ -698,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>,
@@ -745,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,
@@ -763,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),
}
@@ -772,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>,
@@ -783,15 +1119,31 @@ 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>,
Query(params): Query<HashMap<String, String>>,
State(state): State<ApiServerState>,
Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
let headless = params
.get("headless")
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
let headless = request.headless.unwrap_or(false);
let url = request.url;
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
@@ -810,7 +1162,7 @@ async fn run_profile(
match crate::browser_runner::launch_browser_profile_with_debugging(
state.app_handle.clone(),
profile.clone(),
None,
url,
Some(remote_debugging_port),
headless,
)
@@ -825,7 +1177,96 @@ 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>,
Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> {
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
.open_url_with_profile(state.app_handle.clone(), id, request.url)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::OK)
}
// 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>,
) -> Result<StatusCode, StatusCode> {
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
.kill_browser_process(state.app_handle.clone(), profile)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
// 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>,
@@ -847,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>,
@@ -863,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>,
+153 -86
View File
@@ -107,6 +107,8 @@ pub struct AppUpdateInfo {
pub download_url: String,
pub is_nightly: bool,
pub published_at: String,
pub manual_update_required: bool,
pub release_page_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -166,13 +168,13 @@ impl AppAutoUpdater {
let current_version = Self::get_current_version();
let is_nightly = Self::is_nightly_build();
println!("=== App Update Check ===");
println!("Current version: {current_version}");
println!("Is nightly build: {is_nightly}");
println!("STABLE_RELEASE env: {:?}", option_env!("STABLE_RELEASE"));
log::info!("=== App Update Check ===");
log::info!("Current version: {current_version}");
log::info!("Is nightly build: {is_nightly}");
log::info!("STABLE_RELEASE env: {:?}", option_env!("STABLE_RELEASE"));
let releases = self.fetch_app_releases().await?;
println!("Fetched {} releases from GitHub", releases.len());
log::info!("Fetched {} releases from GitHub", releases.len());
// Filter releases based on build type
let filtered_releases: Vec<&AppRelease> = if is_nightly {
@@ -181,7 +183,7 @@ impl AppAutoUpdater {
.iter()
.filter(|release| release.tag_name.starts_with("nightly-"))
.collect();
println!("Found {} nightly releases", nightly_releases.len());
log::info!("Found {} nightly releases", nightly_releases.len());
nightly_releases
} else {
// For stable builds, look for stable releases (semver format)
@@ -189,47 +191,87 @@ impl AppAutoUpdater {
.iter()
.filter(|release| release.tag_name.starts_with('v'))
.collect();
println!("Found {} stable releases", stable_releases.len());
log::info!("Found {} stable releases", stable_releases.len());
stable_releases
};
if filtered_releases.is_empty() {
println!("No releases found for build type (nightly: {is_nightly})");
log::info!("No releases found for build type (nightly: {is_nightly})");
return Ok(None);
}
// Get the latest release
let latest_release = filtered_releases[0];
println!(
log::info!(
"Latest release: {} ({})",
latest_release.tag_name, latest_release.name
latest_release.tag_name,
latest_release.name
);
// Check if we need to update
if self.should_update(&current_version, &latest_release.tag_name, is_nightly) {
println!("Update available!");
log::info!("Update available!");
// Build the release page URL
let release_page_url = format!(
"https://github.com/zhom/donutbrowser/releases/tag/{}",
latest_release.tag_name
);
// Find the appropriate asset for current platform
if let Some(download_url) = self.get_download_url_for_platform(&latest_release.assets) {
let download_url = self.get_download_url_for_platform(&latest_release.assets);
// On Linux, we show the update notification even if auto-update is disabled
// Users can manually download from the release page
#[cfg(target_os = "linux")]
{
let manual_update_required = download_url.is_none();
let update_info = AppUpdateInfo {
current_version,
new_version: latest_release.tag_name.clone(),
release_notes: latest_release.body.clone(),
download_url,
download_url: download_url.unwrap_or_else(|| release_page_url.clone()),
is_nightly,
published_at: latest_release.published_at.clone(),
manual_update_required,
release_page_url: Some(release_page_url),
};
println!(
"Update info prepared: {} -> {}",
update_info.current_version, update_info.new_version
log::info!(
"Update info prepared: {} -> {} (manual_update_required: {})",
update_info.current_version,
update_info.new_version,
update_info.manual_update_required
);
return Ok(Some(update_info));
} else {
println!("No suitable download asset found for current platform");
}
#[cfg(not(target_os = "linux"))]
{
if let Some(url) = download_url {
let update_info = AppUpdateInfo {
current_version,
new_version: latest_release.tag_name.clone(),
release_notes: latest_release.body.clone(),
download_url: url,
is_nightly,
published_at: latest_release.published_at.clone(),
manual_update_required: false,
release_page_url: Some(release_page_url),
};
log::info!(
"Update info prepared: {} -> {}",
update_info.current_version,
update_info.new_version
);
return Ok(Some(update_info));
} else {
log::info!("No suitable download asset found for current platform");
}
}
} else {
println!("No update needed");
log::info!("No update needed");
}
Ok(None)
@@ -261,7 +303,7 @@ impl AppAutoUpdater {
return false;
}
println!(
log::info!(
"Comparing versions: current={current_version}, new={new_version}, is_nightly={is_nightly}"
);
@@ -273,20 +315,20 @@ impl AppAutoUpdater {
) {
// Different commit hashes mean we should update
let should_update = new_hash != current_hash;
println!("Nightly comparison: current_hash={current_hash}, new_hash={new_hash}, should_update={should_update}");
log::info!("Nightly comparison: current_hash={current_hash}, new_hash={new_hash}, should_update={should_update}");
return should_update;
}
// If current version doesn't have nightly prefix but we're in nightly mode,
// this could be a dev build or stable build upgrading to nightly
if !current_version.starts_with("nightly-") {
println!("Upgrading from non-nightly to nightly: {new_version}");
log::info!("Upgrading from non-nightly to nightly: {new_version}");
return true;
}
} else {
// For stable builds, use semantic versioning comparison
let should_update = self.is_version_newer(new_version, current_version);
println!("Stable comparison: {new_version} > {current_version} = {should_update}");
log::info!("Stable comparison: {new_version} > {current_version} = {should_update}");
return should_update;
}
@@ -354,7 +396,7 @@ impl AppAutoUpdater {
};
let exe_path_str = exe_path.to_string_lossy();
println!("Detecting installation method for: {exe_path_str}");
log::info!("Detecting installation method for: {exe_path_str}");
// Check if installed via package manager by querying package databases
if let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) {
@@ -365,7 +407,7 @@ impl AppAutoUpdater {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() && !stdout.contains("no path found") {
println!("Found DEB package owning the executable");
log::info!("Found DEB package owning the executable");
return LinuxInstallationMethod::Deb;
}
}
@@ -376,7 +418,7 @@ impl AppAutoUpdater {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() && !stdout.contains("not owned") {
println!("Found RPM package owning the executable");
log::info!("Found RPM package owning the executable");
return LinuxInstallationMethod::Rpm;
}
}
@@ -391,7 +433,7 @@ impl AppAutoUpdater {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() && stdout.contains(exe_name) {
println!("Found RPM package via {rpm_cmd}");
log::info!("Found RPM package via {rpm_cmd}");
return LinuxInstallationMethod::Rpm;
}
}
@@ -402,7 +444,7 @@ impl AppAutoUpdater {
// Check installation location to infer method
if exe_path_str.starts_with("/usr/bin/") || exe_path_str.starts_with("/usr/local/bin/") {
// Likely installed via package manager or system-wide installation
println!("Executable in system directory, assuming package installation");
log::info!("Executable in system directory, assuming package installation");
// Try to determine which package system is available
if Command::new("dpkg").arg("--version").output().is_ok() {
@@ -414,11 +456,11 @@ impl AppAutoUpdater {
return LinuxInstallationMethod::Manual;
} else if exe_path_str.contains("/.local/") || exe_path_str.starts_with("/home/") {
// User-local installation
println!("Executable in user directory, assuming manual installation");
log::info!("Executable in user directory, assuming manual installation");
return LinuxInstallationMethod::Manual;
}
println!("Could not determine installation method");
log::info!("Could not determine installation method");
LinuxInstallationMethod::Unknown
}
@@ -432,13 +474,13 @@ impl AppAutoUpdater {
"unknown"
};
println!("Looking for platform-specific asset for arch: {arch}");
log::info!("Looking for platform-specific asset for arch: {arch}");
#[cfg(target_os = "linux")]
{
// If we're running from an AppImage, disable auto-updates for safety
if self.is_running_from_appimage() {
println!("Running from AppImage - auto-updates disabled for safety");
log::info!("Running from AppImage - auto-updates disabled for safety");
return None;
}
}
@@ -460,7 +502,7 @@ impl AppAutoUpdater {
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
println!("Unsupported platform for auto-update");
log::info!("Unsupported platform for auto-update");
None
}
}
@@ -475,7 +517,7 @@ impl AppAutoUpdater {
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
{
println!("Found exact architecture match: {}", asset.name);
log::info!("Found exact architecture match: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -486,7 +528,7 @@ impl AppAutoUpdater {
if asset.name.contains(".dmg")
&& (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
{
println!("Found x86_64 variant: {}", asset.name);
log::info!("Found x86_64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -498,7 +540,7 @@ impl AppAutoUpdater {
if asset.name.contains(".dmg")
&& (asset.name.contains("arm64") || asset.name.contains("aarch64"))
{
println!("Found arm64 variant: {}", asset.name);
log::info!("Found arm64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -511,7 +553,7 @@ impl AppAutoUpdater {
|| asset.name.to_lowercase().contains("darwin")
|| !asset.name.contains(".app.tar.gz"))
{
println!("Found fallback DMG: {}", asset.name);
log::info!("Found fallback DMG: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -533,7 +575,7 @@ impl AppAutoUpdater {
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
{
println!("Found Windows {ext} with exact arch match: {}", asset.name);
log::info!("Found Windows {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -544,7 +586,7 @@ impl AppAutoUpdater {
if asset.name.to_lowercase().ends_with(&format!(".{ext}"))
&& (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
{
println!("Found Windows {ext} with x86_64 variant: {}", asset.name);
log::info!("Found Windows {ext} with x86_64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -557,7 +599,7 @@ impl AppAutoUpdater {
|| asset.name.to_lowercase().contains("win32")
|| asset.name.to_lowercase().contains("win64"))
{
println!("Found Windows {ext} fallback: {}", asset.name);
log::info!("Found Windows {ext} fallback: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -570,7 +612,7 @@ impl AppAutoUpdater {
fn get_linux_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option<String> {
// Detect installation method to prioritize appropriate formats
let installation_method = self.detect_linux_installation_method();
println!("Detected Linux installation method: {installation_method:?}");
log::info!("Detected Linux installation method: {installation_method:?}");
// Priority order based on installation method
let extensions = match installation_method {
@@ -578,7 +620,7 @@ impl AppAutoUpdater {
LinuxInstallationMethod::Rpm => vec!["rpm", "tar.gz"],
LinuxInstallationMethod::AppImage => {
// AppImages should not auto-update for safety
println!("AppImage installation detected - auto-updates disabled");
log::info!("AppImage installation detected - auto-updates disabled");
return None;
}
LinuxInstallationMethod::Manual | LinuxInstallationMethod::Unknown => {
@@ -596,7 +638,7 @@ impl AppAutoUpdater {
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
{
println!("Found Linux {ext} with exact arch match: {}", asset.name);
log::info!("Found Linux {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -610,7 +652,7 @@ impl AppAutoUpdater {
|| asset.name.contains("x86-64")
|| asset.name.contains("amd64"))
{
println!("Found Linux {ext} with x86_64 variant: {}", asset.name);
log::info!("Found Linux {ext} with x86_64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -623,7 +665,7 @@ impl AppAutoUpdater {
if asset_name_lower.ends_with(&format!(".{ext}"))
&& (asset.name.contains("arm64") || asset.name.contains("aarch64"))
{
println!("Found Linux {ext} with arm64 variant: {}", asset.name);
log::info!("Found Linux {ext} with arm64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -637,7 +679,7 @@ impl AppAutoUpdater {
|| asset_name_lower.contains("ubuntu")
|| asset_name_lower.contains("debian"))
{
println!("Found Linux {ext} fallback: {}", asset.name);
log::info!("Found Linux {ext} fallback: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
@@ -742,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)
@@ -753,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;
@@ -957,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(())
}
@@ -967,12 +1041,12 @@ impl AppAutoUpdater {
.and_then(|ext| ext.to_str())
.unwrap_or("");
println!("Installing Windows update with extension: {extension}");
log::info!("Installing Windows update with extension: {extension}");
match extension {
"msi" => {
// Install MSI silently with enhanced error handling
println!("Running MSI installer: {}", installer_path.display());
log::info!("Running MSI installer: {}", installer_path.display());
let mut cmd = Command::new("msiexec");
cmd.args([
@@ -995,10 +1069,10 @@ impl AppAutoUpdater {
let log_path = format!("{}.log", installer_path.to_str().unwrap());
let log_content = fs::read_to_string(&log_path).unwrap_or_default();
println!("MSI installation failed with exit code: {exit_code}");
println!("Error output: {error_msg}");
log::info!("MSI installation failed with exit code: {exit_code}");
log::info!("Error output: {error_msg}");
if !log_content.is_empty() {
println!(
log::info!(
"Log file content (last 500 chars): {}",
&log_content
.chars()
@@ -1016,11 +1090,11 @@ impl AppAutoUpdater {
);
}
println!("MSI installation completed successfully");
log::info!("MSI installation completed successfully");
}
"exe" => {
// Run exe installer silently with multiple fallback options
println!("Running EXE installer: {}", installer_path.display());
log::info!("Running EXE installer: {}", installer_path.display());
// Try NSIS silent flag first (most common for Tauri)
let mut success = false;
@@ -1035,12 +1109,12 @@ impl AppAutoUpdater {
];
for args in nsis_args {
println!("Trying installer with args: {:?}", args);
log::info!("Trying installer with args: {:?}", args);
let output = Command::new(installer_path).args(&args).output();
match output {
Ok(output) if output.status.success() => {
println!(
log::info!(
"EXE installation completed successfully with args: {:?}",
args
);
@@ -1054,13 +1128,14 @@ impl AppAutoUpdater {
output.status.code().unwrap_or(-1),
error_msg
);
println!("Installer failed with args {:?}: {}", args, last_error);
log::info!("Installer failed with args {:?}: {}", args, last_error);
}
Err(e) => {
last_error = format!("Failed to execute installer: {e}");
println!(
log::info!(
"Failed to execute installer with args {:?}: {}",
args, last_error
args,
last_error
);
}
}
@@ -1077,7 +1152,7 @@ impl AppAutoUpdater {
}
"zip" => {
// Handle ZIP files by extracting and replacing the current executable
println!("Handling ZIP update: {}", installer_path.display());
log::info!("Handling ZIP update: {}", installer_path.display());
let temp_extract_dir = installer_path.parent().unwrap().join("extracted");
fs::create_dir_all(&temp_extract_dir)?;
@@ -1124,7 +1199,7 @@ impl AppAutoUpdater {
// Clean up
let _ = fs::remove_dir_all(&temp_extract_dir);
println!("ZIP update completed successfully");
log::info!("ZIP update completed successfully");
}
_ => {
return Err(format!("Unsupported installer format: {extension}").into());
@@ -1141,7 +1216,7 @@ impl AppAutoUpdater {
.and_then(|name| name.to_str())
.unwrap_or("");
println!("Installing Linux update: {}", installer_path.display());
log::info!("Installing Linux update: {}", installer_path.display());
// Handle compound extensions like .tar.gz
if file_name.ends_with(".tar.gz") {
@@ -1173,7 +1248,7 @@ impl AppAutoUpdater {
&self,
deb_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing DEB package: {}", deb_path.display());
log::info!("Installing DEB package: {}", deb_path.display());
// Try different package managers in order of preference
let package_managers = [
@@ -1186,23 +1261,23 @@ impl AppAutoUpdater {
for (manager, args) in &package_managers {
// Check if package manager exists
if Command::new("which").arg(manager).output().is_ok() {
println!("Trying to install with {manager}");
log::info!("Trying to install with {manager}");
let output = Command::new("pkexec").arg(manager).args(args).output();
match output {
Ok(output) if output.status.success() => {
println!("DEB installation completed successfully with {manager}");
log::info!("DEB installation completed successfully with {manager}");
return Ok(());
}
Ok(output) => {
let error_msg = String::from_utf8_lossy(&output.stderr);
last_error = format!("{manager} failed: {error_msg}");
println!("Installation failed with {manager}: {error_msg}");
log::info!("Installation failed with {manager}: {error_msg}");
}
Err(e) => {
last_error = format!("Failed to execute {manager}: {e}");
println!("Failed to execute {manager}: {e}");
log::info!("Failed to execute {manager}: {e}");
}
}
}
@@ -1217,7 +1292,7 @@ impl AppAutoUpdater {
&self,
rpm_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing RPM package: {}", rpm_path.display());
log::info!("Installing RPM package: {}", rpm_path.display());
// Try different package managers in order of preference
let package_managers = [
@@ -1232,23 +1307,23 @@ impl AppAutoUpdater {
for (manager, args) in &package_managers {
// Check if package manager exists
if Command::new("which").arg(manager).output().is_ok() {
println!("Trying to install with {manager}");
log::info!("Trying to install with {manager}");
let output = Command::new("pkexec").arg(manager).args(args).output();
match output {
Ok(output) if output.status.success() => {
println!("RPM installation completed successfully with {manager}");
log::info!("RPM installation completed successfully with {manager}");
return Ok(());
}
Ok(output) => {
let error_msg = String::from_utf8_lossy(&output.stderr);
last_error = format!("{manager} failed: {error_msg}");
println!("Installation failed with {manager}: {error_msg}");
log::info!("Installation failed with {manager}: {error_msg}");
}
Err(e) => {
last_error = format!("Failed to execute {manager}: {e}");
println!("Failed to execute {manager}: {e}");
log::info!("Failed to execute {manager}: {e}");
}
}
}
@@ -1263,7 +1338,7 @@ impl AppAutoUpdater {
&self,
appimage_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing AppImage: {}", appimage_path.display());
log::info!("Installing AppImage: {}", appimage_path.display());
// This function should not be called for AppImages since we disable auto-updates for them
// But if it somehow gets called, we'll handle it safely
@@ -1297,7 +1372,7 @@ impl AppAutoUpdater {
// Replace the AppImage
fs::copy(appimage_path, &current_appimage)?;
println!("AppImage replacement completed successfully");
log::info!("AppImage replacement completed successfully");
Ok(())
}
@@ -1307,7 +1382,7 @@ impl AppAutoUpdater {
&self,
tarball_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing tarball: {}", tarball_path.display());
log::info!("Installing tarball: {}", tarball_path.display());
let current_exe = self.get_current_app_path()?;
let temp_extract_dir = tarball_path.parent().unwrap().join("extracted");
@@ -1369,7 +1444,7 @@ impl AppAutoUpdater {
// Clean up
let _ = fs::remove_dir_all(&temp_extract_dir);
println!("Tarball installation completed successfully");
log::info!("Tarball installation completed successfully");
Ok(())
}
@@ -1606,7 +1681,7 @@ pub async fn download_and_install_app_update(
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
println!("Manual app update check triggered");
log::info!("Manual app update check triggered");
let updater = AppAutoUpdater::instance();
updater
.check_for_updates()
@@ -1614,14 +1689,6 @@ pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, Str
.map_err(|e| format!("Failed to check for app updates: {e}"))
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub installation_method: String,
pub supported_formats: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1630,7 +1697,7 @@ mod tests {
fn test_is_nightly_build() {
// This will depend on whether STABLE_RELEASE is set during test compilation
let is_nightly = AppAutoUpdater::is_nightly_build();
println!("Is nightly build: {is_nightly}");
log::info!("Is nightly build: {is_nightly}");
// The result should be true for test builds since STABLE_RELEASE is not set
// unless the test is run in a stable release environment
+17 -15
View File
@@ -109,13 +109,13 @@ impl AutoUpdater {
let new_version = &update.new_version.parse::<u32>().unwrap();
let result = new_version - current_version;
println!(
log::info!(
"Current version: {current_version}, New version: {new_version}, Result: {result}"
);
if result > 400 {
notifications.push(update);
} else {
println!(
log::info!(
"Skipping chromium update notification: only {result} new versions (need 400+)"
);
}
@@ -130,22 +130,23 @@ impl AutoUpdater {
}
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
println!("Starting auto-update check with progress...");
log::info!("Starting auto-update check with progress...");
// Check for browser updates and trigger auto-downloads
match self.check_for_updates().await {
Ok(update_notifications) => {
if !update_notifications.is_empty() {
println!(
log::info!(
"Found {} browser updates to auto-download",
update_notifications.len()
);
// Trigger automatic downloads for each update
for notification in update_notifications {
println!(
log::info!(
"Auto-downloading {} version {}",
notification.browser, notification.new_version
notification.browser,
notification.new_version
);
// Clone app_handle for the async task
@@ -164,7 +165,7 @@ impl AutoUpdater {
new_version.clone(),
) {
true => {
println!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
// Browser already exists, go straight to profile update
match AutoUpdater::instance()
@@ -176,19 +177,19 @@ impl AutoUpdater {
.await
{
Ok(updated_profiles) => {
println!(
log::info!(
"Auto-update completed for {} profiles: {:?}",
updated_profiles.len(),
updated_profiles
);
}
Err(e) => {
eprintln!("Failed to complete auto-update for {browser}: {e}");
log::error!("Failed to complete auto-update for {browser}: {e}");
}
}
}
false => {
println!("Downloading browser {browser} version {new_version}...");
log::info!("Downloading browser {browser} version {new_version}...");
// Emit the auto-update event to trigger frontend handling
let auto_update_event = serde_json::json!({
@@ -201,20 +202,20 @@ impl AutoUpdater {
if let Err(e) =
app_handle_clone.emit("browser-auto-update-available", &auto_update_event)
{
eprintln!("Failed to emit auto-update event for {browser}: {e}");
log::error!("Failed to emit auto-update event for {browser}: {e}");
} else {
println!("Emitted auto-update event for {browser}");
log::info!("Emitted auto-update event for {browser}");
}
}
}
});
}
} else {
println!("No browser updates needed");
log::info!("No browser updates needed");
}
}
Err(e) => {
eprintln!("Failed to check for browser updates: {e}");
log::error!("Failed to check for browser updates: {e}");
}
}
}
@@ -330,7 +331,7 @@ impl AutoUpdater {
updated_profiles.push(profile.name);
}
Err(e) => {
eprintln!("Failed to update profile {}: {}", profile.name, e);
log::error!("Failed to update profile {}: {}", profile.name, e);
}
}
}
@@ -516,6 +517,7 @@ mod tests {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
note: None,
}
}
+328
View File
@@ -0,0 +1,328 @@
use clap::{Arg, Command};
use donutbrowser_lib::proxy_runner::{
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,
port: u16,
username: Option<&str>,
password: Option<&str>,
) -> String {
let mut url = format!("{}://", proxy_type.to_lowercase());
if let (Some(user), Some(pass)) = (username, password) {
let encoded_user = urlencoding::encode(user);
let encoded_pass = urlencoding::encode(pass);
url.push_str(&format!("{}:{}@", encoded_user, encoded_pass));
} else if let Some(user) = username {
let encoded_user = urlencoding::encode(user);
url.push_str(&format!("{}@", encoded_user));
}
url.push_str(host);
url.push(':');
url.push_str(&port.to_string());
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);
if let Some(location) = panic_info.location() {
log::error!(
"Location: {}:{}:{}",
location.file(),
location.line(),
location.column()
);
}
if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
log::error!("Message: {}", s);
}
}));
let matches = Command::new("donut-proxy")
.subcommand(
Command::new("proxy")
.about("Manage proxy servers")
.subcommand(
Command::new("start")
.about("Start a proxy server")
.arg(Arg::new("host").long("host").help("Upstream proxy host"))
.arg(
Arg::new("proxy-port")
.long("proxy-port")
.value_parser(clap::value_parser!(u16))
.help("Upstream proxy port"),
)
.arg(
Arg::new("type")
.long("type")
.help("Proxy type (http, https, socks4, socks5)"),
)
.arg(Arg::new("username").long("username").help("Proxy username"))
.arg(Arg::new("password").long("password").help("Proxy password"))
.arg(
Arg::new("port")
.short('p')
.long("port")
.value_parser(clap::value_parser!(u16))
.help("Local port to use (random if not specified)"),
)
.arg(
Arg::new("ignore-certificate")
.long("ignore-certificate")
.help("Ignore certificate errors for HTTPS proxies"),
)
.arg(
Arg::new("upstream")
.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(
Command::new("stop")
.about("Stop a proxy server")
.arg(Arg::new("id").long("id").help("Proxy ID to stop"))
.arg(
Arg::new("upstream")
.long("upstream")
.help("Stop proxies with this upstream URL"),
),
)
.subcommand(Command::new("list").about("List all proxy servers")),
)
.subcommand(
Command::new("proxy-worker")
.about("Run a proxy worker process (internal use)")
.arg(
Arg::new("id")
.long("id")
.required(true)
.help("Proxy configuration ID"),
)
.arg(Arg::new("action").required(true).help("Action (start)")),
)
.get_matches();
if let Some(proxy_matches) = matches.subcommand_matches("proxy") {
if let Some(start_matches) = proxy_matches.subcommand_matches("start") {
let mut upstream_url: Option<String> = None;
// Build upstream URL from individual components if provided
if let (Some(host), Some(port), Some(proxy_type)) = (
start_matches.get_one::<String>("host"),
start_matches.get_one::<u16>("proxy-port"),
start_matches.get_one::<String>("type"),
) {
let username = start_matches.get_one::<String>("username");
let password = start_matches.get_one::<String>("password");
upstream_url = Some(build_proxy_url(
proxy_type,
host,
*port,
username.map(|s| s.as_str()),
password.map(|s| s.as_str()),
));
} else if let Some(upstream) = start_matches.get_one::<String>("upstream") {
upstream_url = Some(upstream.clone());
}
let port = start_matches.get_one::<u16>("port").copied();
let profile_id = start_matches.get_one::<String>("profile-id").cloned();
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
println!(
"{}",
serde_json::json!({
"id": config.id,
"localPort": config.local_port,
"localUrl": config.local_url,
"upstreamUrl": config.upstream_url,
})
);
process::exit(0);
}
Err(e) => {
eprintln!("Failed to start proxy: {}", e);
process::exit(1);
}
}
} else if let Some(stop_matches) = proxy_matches.subcommand_matches("stop") {
if let Some(id) = stop_matches.get_one::<String>("id") {
match stop_proxy_process(id).await {
Ok(success) => {
// Use println! here because this needs to go to stdout for parsing
println!("{}", serde_json::json!({ "success": success }));
process::exit(0);
}
Err(e) => {
eprintln!("Failed to stop proxy: {}", e);
process::exit(1);
}
}
} else if let Some(upstream) = stop_matches.get_one::<String>("upstream") {
// Find proxies with this upstream URL
let configs = donutbrowser_lib::proxy_storage::list_proxy_configs();
let matching_configs: Vec<_> = configs
.iter()
.filter(|config| config.upstream_url == *upstream)
.collect();
if matching_configs.is_empty() {
eprintln!("No proxies found for {}", upstream);
process::exit(1);
}
for config in matching_configs {
let _ = stop_proxy_process(&config.id).await;
}
// Use println! here because this needs to go to stdout for parsing
println!("{}", serde_json::json!({ "success": true }));
process::exit(0);
} else {
// Stop all proxies
match stop_all_proxy_processes().await {
Ok(_) => {
// Use println! here because this needs to go to stdout for parsing
println!("{}", serde_json::json!({ "success": true }));
process::exit(0);
}
Err(e) => {
eprintln!("Failed to stop all proxies: {}", e);
process::exit(1);
}
}
}
} else if proxy_matches.subcommand_matches("list").is_some() {
let configs = donutbrowser_lib::proxy_storage::list_proxy_configs();
// Use println! here because this needs to go to stdout for parsing
println!("{}", serde_json::to_string(&configs).unwrap());
process::exit(0);
} else {
log::error!("Invalid action. Use 'start', 'stop', or 'list'");
process::exit(1);
}
} else if let Some(worker_matches) = matches.subcommand_matches("proxy-worker") {
let id = worker_matches
.get_one::<String>("id")
.expect("id is required");
let action = worker_matches
.get_one::<String>("action")
.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());
let config = match get_proxy_config(id) {
Some(config) => {
log::error!(
"Found config: id={}, port={:?}, upstream={}",
config.id,
config.local_port,
config.upstream_url
);
config
}
None => {
log::error!("Proxy configuration {} not found", id);
process::exit(1);
}
};
// Run the proxy server - this should never return (infinite loop)
log::error!("Starting proxy server for config id: {}", id);
if let Err(e) = run_proxy_server(config).await {
log::error!("Failed to run proxy server: {}", e);
log::error!("Error details: {:?}", e);
process::exit(1);
}
// This should never be reached - run_proxy_server has an infinite loop
log::error!("ERROR: Proxy server returned unexpectedly (this should never happen)");
process::exit(1);
} else {
log::error!("Invalid action for proxy-worker. Use 'start'");
process::exit(1);
}
} else {
log::error!("No command specified");
process::exit(1);
}
}
+130 -110
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"),
@@ -377,7 +438,7 @@ mod linux {
pub fn prepare_executable(executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Linux, ensure the executable has proper permissions
println!("Setting execute permissions for: {:?}", executable_path);
log::info!("Setting execute permissions for: {:?}", executable_path);
let metadata = std::fs::metadata(executable_path)?;
let mut permissions = metadata.permissions();
@@ -388,7 +449,7 @@ mod linux {
std::fs::set_permissions(executable_path, permissions)?;
println!(
log::info!(
"Execute permissions set successfully for: {:?}",
executable_path
);
@@ -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
@@ -653,14 +697,14 @@ impl Browser for FirefoxBrowser {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Firefox browser checking version {version} in directory: {browser_dir:?}");
log::info!("Firefox browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
println!("Directory exists, checking for browser files...");
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&browser_dir);
@@ -673,7 +717,7 @@ impl Browser for FirefoxBrowser {
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
log::info!("Unsupported platform for browser verification");
false
}
}
@@ -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
@@ -768,14 +818,14 @@ impl Browser for ChromiumBrowser {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Chromium browser checking version {version} in directory: {browser_dir:?}");
log::info!("Chromium browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
println!("Directory exists, checking for browser files...");
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_chromium_version_downloaded(&browser_dir);
@@ -788,7 +838,7 @@ impl Browser for ChromiumBrowser {
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
log::info!("Unsupported platform for browser verification");
false
}
}
@@ -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
File diff suppressed because it is too large Load Diff
+7 -257
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()),
};
@@ -277,7 +257,7 @@ impl BrowserVersionManager {
.api_client
.save_cached_versions(browser, &merged_releases)
{
eprintln!("Failed to save merged cache for {browser}: {e}");
log::error!("Failed to save merged cache for {browser}: {e}");
}
}
@@ -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
@@ -534,7 +468,7 @@ impl BrowserVersionManager {
})
.collect();
if let Err(e) = self.api_client.save_cached_versions(browser, &releases) {
eprintln!("Failed to save updated cache for {browser}: {e}");
log::error!("Failed to save updated cache for {browser}: {e}");
}
Ok(new_versions_count)
@@ -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,
@@ -922,7 +754,7 @@ impl BrowserVersionManager {
.collect();
// Always save so that other callers without release_name can classify correctly
if let Err(e) = self.api_client.save_cached_versions("brave", &converted) {
eprintln!("Failed to persist Brave versions cache: {e}");
log::error!("Failed to persist Brave versions cache: {e}");
}
Ok(releases.into_iter().map(|r| r.tag_name).collect())
@@ -947,7 +779,7 @@ impl BrowserVersionManager {
})
.collect();
if let Err(e) = self.api_client.save_cached_versions("brave", &converted) {
eprintln!("Failed to persist Brave versions cache: {e}");
log::error!("Failed to persist Brave versions cache: {e}");
}
Ok(releases)
@@ -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();
@@ -1271,7 +1021,7 @@ mod tests {
let unsupported_result = service.get_download_info("unsupported", "1.0.0");
assert!(unsupported_result.is_err());
println!("Download info test passed for all browsers");
log::info!("Download info test passed for all browsers");
}
}
@@ -1307,7 +1057,7 @@ pub async fn fetch_browser_versions_cached_first(
.fetch_browser_versions_detailed(&browser_str_clone, false)
.await
{
eprintln!("Background version update failed for {browser_str_clone}: {e}");
log::error!("Background version update failed for {browser_str_clone}: {e}");
}
});
}
@@ -1339,7 +1089,7 @@ pub async fn fetch_browser_versions_with_count_cached_first(
.fetch_browser_versions_with_count(&browser_str_clone, false)
.await
{
eprintln!("Background version update failed for {browser_str_clone}: {e}");
log::error!("Background version update failed for {browser_str_clone}: {e}");
}
});
}
+123 -24
View File
@@ -22,6 +22,8 @@ pub struct CamoufoxConfig {
pub block_webgl: Option<bool>,
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 {
@@ -38,6 +40,8 @@ impl Default for CamoufoxConfig {
block_webgl: None,
executable_path: None,
fingerprint: None,
randomize_fingerprint_on_launch: None,
os: None,
}
}
}
@@ -169,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 {
@@ -206,7 +215,7 @@ impl CamoufoxManager {
url: Option<&str>,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
println!("Using existing fingerprint from profile metadata");
log::info!("Using existing fingerprint from profile metadata");
existing_fingerprint.clone()
} else {
return Err("No fingerprint provided".into());
@@ -266,18 +275,18 @@ impl CamoufoxManager {
}
// Execute nodecar sidecar command
println!("Executing nodecar command with args: {args:?}");
log::info!("Executing nodecar command with args: {args:?}");
let output = sidecar_command.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
println!("nodecar camoufox failed - stdout: {stdout}, stderr: {stderr}");
log::info!("nodecar camoufox failed - stdout: {stdout}, stderr: {stderr}");
return Err(format!("nodecar camoufox failed: {stderr}").into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
println!("nodecar camoufox output: {stdout}");
log::info!("nodecar camoufox output: {stdout}");
// Parse the JSON output
let launch_result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
@@ -340,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,
@@ -347,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,
@@ -493,6 +590,8 @@ mod tests {
assert_eq!(default_config.geoip, Some(serde_json::Value::Bool(true)));
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);
}
}
+60 -55
View File
@@ -122,7 +122,7 @@ impl DownloadedBrowsersRegistry {
let browser_type = match BrowserType::from_str(browser) {
Ok(bt) => bt,
Err(_) => {
println!("Invalid browser type: {browser}");
log::info!("Invalid browser type: {browser}");
return false;
}
};
@@ -146,7 +146,7 @@ impl DownloadedBrowsersRegistry {
// If files don't exist but registry thinks they do, clean up the registry
if !files_exist {
println!("Cleaning up stale registry entry for {browser} {version}");
log::info!("Cleaning up stale registry entry for {browser} {version}");
self.remove_browser(browser, version);
let _ = self.save(); // Don't fail if save fails, just log
}
@@ -166,7 +166,7 @@ impl DownloadedBrowsersRegistry {
pub fn mark_download_started(&self, browser: &str, version: &str, file_path: PathBuf) {
// Only mark download started, don't add to registry yet
// The browser will be added to registry only after verification succeeds
println!(
log::info!(
"Marking download started for {}:{} at {}",
browser,
version,
@@ -187,7 +187,7 @@ impl DownloadedBrowsersRegistry {
file_path,
};
self.add_browser(info);
println!("Browser {browser}:{version} successfully added to registry after verification");
log::info!("Browser {browser}:{version} successfully added to registry after verification");
Ok(())
}
@@ -268,7 +268,7 @@ impl DownloadedBrowsersRegistry {
let pending_updates = match self.auto_updater.get_pending_update_versions() {
Ok(updates) => updates,
Err(e) => {
eprintln!("Warning: Failed to get pending updates for cleanup: {e}");
log::warn!("Warning: Failed to get pending updates for cleanup: {e}");
std::collections::HashSet::new()
}
};
@@ -283,13 +283,13 @@ impl DownloadedBrowsersRegistry {
// Don't remove if it's used by any active profile
if active_set.contains(&browser_version) {
println!("Keeping: {browser} {version} (in use by profile)");
log::info!("Keeping: {browser} {version} (in use by profile)");
continue;
}
// Don't remove if it's currently running (even if not in active profiles)
if running_set.contains(&browser_version) {
println!("Keeping: {browser} {version} (currently running)");
log::info!("Keeping: {browser} {version} (currently running)");
continue;
}
@@ -300,14 +300,14 @@ impl DownloadedBrowsersRegistry {
let has_running_profile_for_browser =
running_profiles.iter().any(|(b, _)| b == browser);
if has_running_profile_for_browser {
println!("Keeping: {browser} {version} (pending update for running profile)");
log::info!("Keeping: {browser} {version} (pending update for running profile)");
continue;
}
}
// Mark for removal
to_remove.push(browser_version);
println!("Marking for removal: {browser} {version} (not used by any profile)");
log::info!("Marking for removal: {browser} {version} (not used by any profile)");
}
}
}
@@ -315,21 +315,21 @@ impl DownloadedBrowsersRegistry {
// Remove unused binaries and their version folders
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
eprintln!("Failed to cleanup unused binary {browser}:{version}: {e}");
log::error!("Failed to cleanup unused binary {browser}:{version}: {e}");
} else {
// After removing the binary, also remove the empty version folder
if let Err(e) = self.remove_empty_version_folder(&browser, &version) {
eprintln!("Failed to remove empty version folder for {browser}:{version}: {e}");
log::error!("Failed to remove empty version folder for {browser}:{version}: {e}");
}
cleaned_up.push(format!("{browser} {version}"));
println!("Successfully removed unused binary: {browser} {version}");
log::info!("Successfully removed unused binary: {browser} {version}");
}
}
if cleaned_up.is_empty() {
println!("No unused binaries found to clean up");
log::info!("No unused binaries found to clean up");
} else {
println!("Cleaned up {} unused binaries", cleaned_up.len());
log::info!("Cleaned up {} unused binaries", cleaned_up.len());
}
Ok(cleaned_up)
@@ -374,7 +374,7 @@ impl DownloadedBrowsersRegistry {
// Files don't exist, remove from registry
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
cleaned_up.push(format!("{browser_str} {version}"));
println!("Removed stale registry entry for {browser_str} {version}");
log::info!("Removed stale registry entry for {browser_str} {version}");
}
}
}
@@ -529,7 +529,7 @@ impl DownloadedBrowsersRegistry {
if entries.next().is_none() {
// Directory is empty, remove it
fs::remove_dir(&version_dir)?;
println!("Removed empty version folder: {}", version_dir.display());
log::info!("Removed empty version folder: {}", version_dir.display());
// Also check if the browser folder is now empty and remove it too
let browser_dir = binaries_dir.join(browser);
@@ -537,7 +537,7 @@ impl DownloadedBrowsersRegistry {
if let Ok(mut browser_entries) = fs::read_dir(&browser_dir) {
if browser_entries.next().is_none() {
fs::remove_dir(&browser_dir)?;
println!("Removed empty browser folder: {}", browser_dir.display());
log::info!("Removed empty browser folder: {}", browser_dir.display());
}
}
}
@@ -618,7 +618,7 @@ impl DownloadedBrowsersRegistry {
// Remove empty version directories
for (version_path, version_name) in empty_version_dirs {
if let Err(e) = fs::remove_dir(&version_path) {
eprintln!(
log::error!(
"Failed to remove empty version folder {}: {e}",
version_path.display()
);
@@ -626,7 +626,7 @@ impl DownloadedBrowsersRegistry {
cleaned_up.push(format!(
"Removed empty version folder: {browser_name}/{version_name}"
));
println!("Removed empty version folder: {}", version_path.display());
log::info!("Removed empty version folder: {}", version_path.display());
}
}
@@ -635,13 +635,13 @@ impl DownloadedBrowsersRegistry {
if let Ok(mut entries) = fs::read_dir(&browser_path) {
if entries.next().is_none() {
if let Err(e) = fs::remove_dir(&browser_path) {
eprintln!(
log::error!(
"Failed to remove empty browser folder {}: {e}",
browser_path.display()
);
} else {
cleaned_up.push(format!("Removed empty browser folder: {browser_name}"));
println!("Removed empty browser folder: {}", browser_path.display());
log::info!("Removed empty browser folder: {}", browser_path.display());
}
}
}
@@ -656,7 +656,7 @@ impl DownloadedBrowsersRegistry {
&self,
app_handle: &tauri::AppHandle,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
println!("Starting browser version consolidation...");
log::info!("Starting browser version consolidation...");
let profiles = self
.profile_manager
@@ -691,15 +691,16 @@ impl DownloadedBrowsersRegistry {
if browser.is_version_downloaded(&profile.version, &binaries_dir) {
available_versions.push(profile.version.clone());
} else {
println!(
log::info!(
"Profile '{}' references version {} that doesn't exist on disk",
profile.name, profile.version
profile.name,
profile.version
);
}
}
if available_versions.is_empty() {
println!("No available versions found for {browser_name}, skipping consolidation");
log::info!("No available versions found for {browser_name}, skipping consolidation");
continue;
}
@@ -710,7 +711,7 @@ impl DownloadedBrowsersRegistry {
});
let latest_version = &available_versions[0];
println!("Latest available version for {browser_name}: {latest_version}");
log::info!("Latest available version for {browser_name}: {latest_version}");
// Check which profiles need to be updated to the latest version
let mut profiles_to_update = Vec::new();
@@ -723,9 +724,10 @@ impl DownloadedBrowsersRegistry {
profiles_to_update.push(profile);
older_versions_to_remove.insert(profile.version.clone());
} else {
println!(
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name, profile.version
profile.name,
profile.version
);
}
}
@@ -744,21 +746,21 @@ impl DownloadedBrowsersRegistry {
));
}
Err(e) => {
eprintln!("Failed to update profile '{}': {}", profile.name, e);
log::error!("Failed to update profile '{}': {}", profile.name, e);
}
}
}
// Remove older version binaries that are no longer needed
for old_version in &older_versions_to_remove {
println!("Consolidating: removing old version {browser_name} {old_version}");
log::info!("Consolidating: removing old version {browser_name} {old_version}");
match self.cleanup_failed_download(browser_name, old_version) {
Ok(_) => {
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
println!("Successfully removed old version: {browser_name} {old_version}");
log::info!("Successfully removed old version: {browser_name} {old_version}");
}
Err(e) => {
eprintln!("Failed to cleanup old version {browser_name} {old_version}: {e}");
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
}
}
}
@@ -770,7 +772,7 @@ impl DownloadedBrowsersRegistry {
.save()
.map_err(|e| format!("Failed to save registry after consolidation: {e}"))?;
println!(
log::info!(
"Browser version consolidation completed: {} actions taken",
consolidated.len()
);
@@ -793,9 +795,10 @@ impl DownloadedBrowsersRegistry {
let browser_type = match BrowserType::from_str(&profile.browser) {
Ok(bt) => bt,
Err(_) => {
println!(
log::info!(
"Warning: Invalid browser type '{}' for profile '{}'",
profile.browser, profile.name
profile.browser,
profile.name
);
continue;
}
@@ -817,7 +820,7 @@ impl DownloadedBrowsersRegistry {
return Err("Failed to get base directories".into());
};
println!(
log::info!(
"binaries_dir: {binaries_dir:?} for profile: {}",
profile.name
);
@@ -839,7 +842,7 @@ impl DownloadedBrowsersRegistry {
// First, clean up any stale registry entries
if let Ok(cleaned_up) = self.verify_and_cleanup_stale_entries() {
if !cleaned_up.is_empty() {
println!(
log::info!(
"Cleaned up {} stale registry entries: {}",
cleaned_up.len(),
cleaned_up.join(", ")
@@ -850,9 +853,9 @@ impl DownloadedBrowsersRegistry {
// Consolidate browser versions - keep only latest version per browser
if let Ok(consolidated) = self.consolidate_browser_versions(app_handle) {
if !consolidated.is_empty() {
println!("Version consolidation results:");
log::info!("Version consolidation results:");
for action in &consolidated {
println!(" {action}");
log::info!(" {action}");
}
}
}
@@ -861,7 +864,7 @@ impl DownloadedBrowsersRegistry {
let mut downloaded = Vec::new();
for (profile_name, browser, version) in missing_binaries {
println!("Downloading missing binary for profile '{profile_name}': {browser} {version}");
log::info!("Downloading missing binary for profile '{profile_name}': {browser} {version}");
match crate::downloader::download_browser(
app_handle.clone(),
@@ -882,31 +885,31 @@ impl DownloadedBrowsersRegistry {
{
Ok(updated_profiles) => {
if !updated_profiles.is_empty() {
println!(
log::info!(
"Successfully updated {} profiles to version {}:",
updated_profiles.len(),
version
);
for update_msg in updated_profiles {
println!(" {update_msg}");
log::info!(" {update_msg}");
}
}
}
Err(e) => {
eprintln!("CRITICAL: Failed to update profiles to version {version}: {e}");
eprintln!("This may cause profile version inconsistencies and cleanup issues");
log::error!("CRITICAL: Failed to update profiles to version {version}: {e}");
log::error!("This may cause profile version inconsistencies and cleanup issues");
}
}
}
Err(e) => {
eprintln!("Failed to download {browser} {version} for profile '{profile_name}': {e}");
log::error!("Failed to download {browser} {version} for profile '{profile_name}': {e}");
}
}
}
// Check if GeoIP database is missing for Camoufox profiles
if self.geoip_downloader.check_missing_geoip_database()? {
println!("GeoIP database is missing for Camoufox profiles, downloading...");
log::info!("GeoIP database is missing for Camoufox profiles, downloading...");
match self
.geoip_downloader
@@ -915,10 +918,10 @@ impl DownloadedBrowsersRegistry {
{
Ok(_) => {
downloaded.push("GeoIP database for Camoufox".to_string());
println!("GeoIP database downloaded successfully");
log::info!("GeoIP database downloaded successfully");
}
Err(e) => {
eprintln!("Failed to download GeoIP database: {e}");
log::error!("Failed to download GeoIP database: {e}");
// Don't fail the entire operation if GeoIP download fails
}
}
@@ -945,9 +948,10 @@ impl DownloadedBrowsersRegistry {
if profile.browser == browser && profile.version != version {
// Check if profile is currently running
if profile.process_id.is_some() {
println!(
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name, profile.version
profile.name,
profile.version
);
continue;
}
@@ -963,18 +967,19 @@ impl DownloadedBrowsersRegistry {
"Updated profile '{}' from {} to {}",
profile.name, profile.version, version
));
println!(
log::info!(
"Successfully updated profile '{}' to version {}",
profile.name, version
profile.name,
version
);
// Save registry after each profile update to ensure consistency
if let Err(e) = self.save() {
eprintln!("Warning: Failed to save registry after profile update: {e}");
log::warn!("Warning: Failed to save registry after profile update: {e}");
}
}
Err(e) => {
eprintln!("Failed to update profile '{}': {}", profile.name, e);
log::error!("Failed to update profile '{}': {}", profile.name, e);
}
}
}
@@ -1016,7 +1021,7 @@ lazy_static::lazy_static! {
static ref DOWNLOADED_BROWSERS_REGISTRY: DownloadedBrowsersRegistry = {
let registry = DownloadedBrowsersRegistry::new();
if let Err(e) = registry.load() {
eprintln!("Warning: Failed to load downloaded browsers registry: {e}");
log::warn!("Warning: Failed to load downloaded browsers registry: {e}");
}
registry
};
+30 -100
View File
@@ -104,7 +104,7 @@ impl Downloader {
let releases = match self.api_client.fetch_zen_releases_with_caching(true).await {
Ok(releases) => releases,
Err(e) => {
eprintln!("Failed to fetch Zen releases: {e}");
log::error!("Failed to fetch Zen releases: {e}");
return Err(format!("Failed to fetch Zen releases from GitHub API: {e}. This might be due to GitHub API rate limiting or network issues. Please try again later.").into());
}
};
@@ -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>(
@@ -644,7 +596,7 @@ impl Downloader {
return Ok(version);
} else {
// Registry says it's downloaded but files don't exist - clean up registry
println!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry.");
log::info!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry.");
self.registry.remove_browser(&browser_str, &version);
self
.registry
@@ -764,7 +716,7 @@ impl Downloader {
let _ = app_handle.emit("download-progress", &progress);
// Verify the browser was downloaded correctly
println!("Verifying download for browser: {browser_str}, version: {version}");
log::info!("Verifying download for browser: {browser_str}, version: {version}");
// Use the browser's own verification method
if !browser.is_version_downloaded(&version, &binaries_dir) {
@@ -838,7 +790,7 @@ impl Downloader {
.registry
.mark_download_completed(&browser_str, &version, browser_dir.clone())
{
eprintln!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
log::warn!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
}
self
.registry
@@ -850,7 +802,7 @@ impl Downloader {
let archive_path = browser_dir.join(&download_info.filename);
if archive_path.exists() {
if let Err(e) = std::fs::remove_file(&archive_path) {
println!("Warning: Could not delete archive file after verification: {e}");
log::warn!("Warning: Could not delete archive file after verification: {e}");
}
}
}
@@ -859,7 +811,7 @@ impl Downloader {
if browser_str == "camoufox" {
// Check if GeoIP database is already available
if !crate::geoip_downloader::GeoIPDownloader::is_geoip_database_available() {
println!("Downloading GeoIP database for Camoufox...");
log::info!("Downloading GeoIP database for Camoufox...");
match self
.geoip_downloader
@@ -867,15 +819,15 @@ impl Downloader {
.await
{
Ok(_) => {
println!("GeoIP database downloaded successfully");
log::info!("GeoIP database downloaded successfully");
}
Err(e) => {
eprintln!("Failed to download GeoIP database: {e}");
log::error!("Failed to download GeoIP database: {e}");
// Don't fail the browser download if GeoIP download fails
}
}
} else {
println!("GeoIP database already available");
log::info!("GeoIP database already available");
}
}
@@ -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;
+51 -73
View File
@@ -55,7 +55,7 @@ impl Extractor {
// If the executable is not in the expected subdirectory, create the structure
if !exe_path.starts_with(&expected_subdir) {
println!("Reorganizing directory structure for {}", browser_type);
log::info!("Reorganizing directory structure for {}", browser_type);
// Create the expected subdirectory
std::fs::create_dir_all(&expected_subdir)?;
@@ -78,19 +78,19 @@ impl Extractor {
// Move the file/directory
if let Err(e) = std::fs::rename(&path, &target_path) {
println!(
log::info!(
"Warning: Failed to move {} to {}: {}",
path.display(),
target_path.display(),
e
);
} else {
println!("Moved {} to {}", path.display(), target_path.display());
log::info!("Moved {} to {}", path.display(), target_path.display());
}
}
}
println!("Directory structure reorganized for {}", browser_type);
log::info!("Directory structure reorganized for {}", browser_type);
}
Ok(())
@@ -117,7 +117,7 @@ impl Extractor {
};
let _ = app_handle.emit("download-progress", &progress);
println!(
log::info!(
"Starting extraction of {} for browser {} version {}",
archive_path.display(),
browser_type.as_str(),
@@ -132,7 +132,7 @@ impl Extractor {
e
)
})?;
println!("Detected format: {actual_format}");
log::info!("Detected format: {actual_format}");
let extraction_result = match actual_format.as_str() {
"dmg" => {
@@ -210,7 +210,7 @@ impl Extractor {
match extraction_result {
Ok(path) => {
println!(
log::info!(
"Successfully extracted {} {} to: {}",
browser_type.as_str(),
version,
@@ -219,7 +219,7 @@ impl Extractor {
Ok(path)
}
Err(e) => {
eprintln!(
log::error!(
"Extraction failed for {} {}: {}",
browser_type.as_str(),
version,
@@ -337,7 +337,7 @@ impl Extractor {
dmg_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!(
log::info!(
"Extracting DMG: {} to {}",
dmg_path.display(),
dest_dir.display()
@@ -353,7 +353,7 @@ impl Extractor {
));
create_dir_all(&mount_point)?;
println!("Created mount point: {}", mount_point.display());
log::info!("Created mount point: {}", mount_point.display());
// Mount the DMG
let output = Command::new("hdiutil")
@@ -369,7 +369,7 @@ impl Extractor {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
println!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
log::info!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
// Clean up mount point before returning error
let _ = fs::remove_dir_all(&mount_point);
@@ -377,7 +377,7 @@ impl Extractor {
return Err(format!("Failed to mount DMG: {stderr}").into());
}
println!("Successfully mounted DMG");
log::info!("Successfully mounted DMG");
// Find the .app directory in the mount point
let app_result = self.find_app_in_directory(&mount_point).await;
@@ -385,7 +385,7 @@ impl Extractor {
let app_entry = match app_result {
Ok(app_path) => app_path,
Err(e) => {
println!("Failed to find .app in mount point: {e}");
log::info!("Failed to find .app in mount point: {e}");
// Try to unmount before returning error
let _ = Command::new("hdiutil")
@@ -397,12 +397,12 @@ impl Extractor {
}
};
println!("Found .app bundle: {}", app_entry.display());
log::info!("Found .app bundle: {}", app_entry.display());
// Copy the .app to the destination
let app_path = dest_dir.join(app_entry.file_name().unwrap());
println!("Copying .app to: {}", app_path.display());
log::info!("Copying .app to: {}", app_path.display());
let output = Command::new("cp")
.args([
@@ -414,7 +414,7 @@ impl Extractor {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Failed to copy app: {stderr}");
log::info!("Failed to copy app: {stderr}");
// Unmount before returning error
let _ = Command::new("hdiutil")
@@ -425,7 +425,7 @@ impl Extractor {
return Err(format!("Failed to copy app: {stderr}").into());
}
println!("Successfully copied .app bundle");
log::info!("Successfully copied .app bundle");
// Remove quarantine attributes
let _ = Command::new("xattr")
@@ -436,7 +436,7 @@ impl Extractor {
.args(["-cr", app_path.to_str().unwrap()])
.output();
println!("Removed quarantine attributes");
log::info!("Removed quarantine attributes");
// Unmount the DMG
let output = Command::new("hdiutil")
@@ -445,10 +445,10 @@ impl Extractor {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Warning: Failed to unmount DMG: {stderr}");
log::warn!("Warning: Failed to unmount DMG: {stderr}");
// Don't fail if unmount fails - the extraction was successful
} else {
println!("Successfully unmounted DMG");
log::info!("Successfully unmounted DMG");
}
// Clean up mount point directory
@@ -486,7 +486,7 @@ impl Extractor {
if path.is_dir() {
if let Some(extension) = path.extension() {
if extension == "app" {
println!("Found .app bundle at depth {}: {}", depth, path.display());
log::info!("Found .app bundle at depth {}: {}", depth, path.display());
return Ok(path);
}
}
@@ -535,7 +535,7 @@ impl Extractor {
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!("Extracting ZIP archive: {}", zip_path.display());
log::info!("Extracting ZIP archive: {}", zip_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(zip_path)
@@ -544,7 +544,7 @@ impl Extractor {
let mut archive = zip::ZipArchive::new(BufReader::new(file))
.map_err(|e| format!("Failed to read ZIP archive {}: {}", zip_path.display(), e))?;
println!("ZIP archive contains {} files", archive.len());
log::info!("ZIP archive contains {} files", archive.len());
for i in 0..archive.len() {
let mut entry = archive
@@ -591,7 +591,7 @@ impl Extractor {
}
}
println!("ZIP extraction completed. Searching for executable...");
log::info!("ZIP extraction completed. Searching for executable...");
self
.find_extracted_executable(dest_dir)
.await
@@ -603,7 +603,7 @@ impl Extractor {
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!("Extracting tar.gz archive: {}", tar_path.display());
log::info!("Extracting tar.gz archive: {}", tar_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(tar_path)?;
@@ -615,7 +615,7 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
println!("tar.gz extraction completed. Searching for executable...");
log::info!("tar.gz extraction completed. Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -624,7 +624,7 @@ impl Extractor {
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!("Extracting tar.bz2 archive: {}", tar_path.display());
log::info!("Extracting tar.bz2 archive: {}", tar_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(tar_path)?;
@@ -636,7 +636,7 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
println!("tar.bz2 extraction completed. Searching for executable...");
log::info!("tar.bz2 extraction completed. Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -645,7 +645,7 @@ impl Extractor {
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!("Extracting tar.xz archive: {}", tar_path.display());
log::info!("Extracting tar.xz archive: {}", tar_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(tar_path)?;
@@ -671,7 +671,7 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
println!("tar.xz extraction completed. Searching for executable...");
log::info!("tar.xz extraction completed. Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -680,7 +680,7 @@ impl Extractor {
msi_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!("Extracting MSI archive: {}", msi_path.display());
log::info!("Extracting MSI archive: {}", msi_path.display());
std::fs::create_dir_all(dest_dir)?;
// Extract MSI in a separate scope to avoid Send issues
@@ -689,7 +689,7 @@ impl Extractor {
extractor.to(dest_dir);
}
println!("MSI extraction completed. Searching for executable...");
log::info!("MSI extraction completed. Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -812,7 +812,7 @@ impl Extractor {
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!("Searching for .app bundle in: {}", dest_dir.display());
log::info!("Searching for .app bundle in: {}", dest_dir.display());
// Use the enhanced recursive search
match self.find_app_in_directory(dest_dir).await {
@@ -820,7 +820,7 @@ impl Extractor {
// Check if the app is in a subdirectory and move it to the root if needed
let app_parent = app_path.parent().unwrap();
if app_parent != dest_dir {
println!(
log::info!(
"Found .app in subdirectory, moving to root: {} -> {}",
app_path.display(),
dest_dir.display()
@@ -837,15 +837,15 @@ impl Extractor {
}
}
println!("Successfully moved .app to: {}", target_path.display());
log::info!("Successfully moved .app to: {}", target_path.display());
Ok(target_path)
} else {
println!("Found .app at root level: {}", app_path.display());
log::info!("Found .app at root level: {}", app_path.display());
Ok(app_path)
}
}
Err(e) => {
println!("Failed to find .app bundle: {e}");
log::info!("Failed to find .app bundle: {e}");
Err("No .app found after extraction".into())
}
}
@@ -856,7 +856,7 @@ impl Extractor {
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!(
log::info!(
"Searching for Windows executable in: {}",
dest_dir.display()
);
@@ -868,16 +868,13 @@ impl Extractor {
"chromium.exe",
"zen.exe",
"brave.exe",
"tor-browser.exe",
"tor.exe",
"mullvad-browser.exe",
];
// First try priority executable names
for exe_name in &priority_exe_names {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() {
println!("Found priority executable: {}", exe_path.display());
log::info!("Found priority executable: {}", exe_path.display());
return Ok(exe_path);
}
}
@@ -885,7 +882,7 @@ impl Extractor {
// Recursively search for executables with depth limit
match self.find_windows_executable_recursive(dest_dir, 0, 3).await {
Ok(exe_path) => {
println!(
log::info!(
"Found executable via recursive search: {}",
exe_path.display()
);
@@ -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);
@@ -983,7 +978,7 @@ impl Extractor {
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
println!("Searching for Linux executable in: {}", dest_dir.display());
log::info!("Searching for Linux executable in: {}", dest_dir.display());
// Enhanced list of common browser executable names
let exe_names = [
@@ -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",
@@ -1031,7 +1017,7 @@ impl Extractor {
for exe_name in &exe_names {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
println!("Found executable at root level: {}", exe_path.display());
log::info!("Found executable at root level: {}", exe_path.display());
return Ok(exe_path);
}
}
@@ -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",
@@ -1078,7 +1059,7 @@ impl Extractor {
for exe_name in &exe_names {
let exe_path = subdir_path.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
println!("Found executable in subdirectory: {}", exe_path.display());
log::info!("Found executable in subdirectory: {}", exe_path.display());
return Ok(exe_path);
}
}
@@ -1091,7 +1072,7 @@ impl Extractor {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".AppImage") && self.is_executable(&path) {
println!("Found AppImage: {}", path.display());
log::info!("Found AppImage: {}", path.display());
return Ok(path);
}
}
@@ -1099,15 +1080,15 @@ impl Extractor {
}
// Last resort: recursive search for any executable file
println!("Performing recursive search for executables...");
log::info!("Performing recursive search for executables...");
match self.find_any_executable_recursive(dest_dir, 0).await {
Ok(path) => {
println!("Found executable via recursive search: {}", path.display());
log::info!("Found executable via recursive search: {}", path.display());
Ok(path)
}
Err(e) => {
// List all files in the directory for debugging
println!("Failed to find executable. Directory contents:");
log::info!("Failed to find executable. Directory contents:");
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -1116,7 +1097,7 @@ impl Extractor {
} else {
false
};
println!(" {} (executable: {})", path.display(), is_exec);
log::info!(" {} (executable: {})", path.display(), is_exec);
}
}
Err(
@@ -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,12 +1195,10 @@ 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")
{
println!(
log::info!(
"Found priority executable at depth {}: {}",
depth,
path.display()
@@ -1262,7 +1240,7 @@ impl Extractor {
a_name.len().cmp(&b_name.len())
});
println!(
log::info!(
"Found potential executable at depth {}: {}",
depth,
potential_executables[0].display()
+3 -3
View File
@@ -120,7 +120,7 @@ impl GroupManager {
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
eprintln!("Failed to emit groups-changed event: {e}");
log::error!("Failed to emit groups-changed event: {e}");
}
Ok(group)
@@ -156,7 +156,7 @@ impl GroupManager {
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
eprintln!("Failed to emit groups-changed event: {e}");
log::error!("Failed to emit groups-changed event: {e}");
}
Ok(updated_group)
@@ -180,7 +180,7 @@ impl GroupManager {
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
eprintln!("Failed to emit groups-changed event: {e}");
log::error!("Failed to emit groups-changed event: {e}");
}
Ok(())
+144 -55
View File
@@ -3,6 +3,7 @@ use std::env;
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
// Store pending URLs that need to be handled when the window is ready
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
@@ -25,7 +26,11 @@ mod platform_browser;
mod profile;
mod profile_importer;
mod proxy_manager;
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;
@@ -36,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::{
@@ -149,7 +155,7 @@ async fn warm_up_nodecar(app: tauri::AppHandle) -> Result<(), String> {
match timeout(Duration::from_secs(120), exec_future).await {
Ok(Ok(_output)) => {
let duration = start_time.elapsed();
println!(
log::info!(
"Nodecar warm-up (frontend-triggered) completed in {:.2}s",
duration.as_secs_f64()
);
@@ -162,11 +168,11 @@ async fn warm_up_nodecar(app: tauri::AppHandle) -> Result<(), String> {
#[tauri::command]
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
println!("handle_url_open called with URL: {url}");
log::info!("handle_url_open called with URL: {url}");
// Check if the main window exists and is ready
if let Some(window) = app.get_webview_window("main") {
println!("Main window exists");
log::debug!("Main window exists");
// Try to show and focus the window first
let _ = window.show();
@@ -178,7 +184,7 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
} else {
// Window doesn't exist yet - add to pending URLs
println!("Main window doesn't exist, adding URL to pending list");
log::debug!("Main window doesn't exist, adding URL to pending list");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url);
}
@@ -221,11 +227,49 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
}
#[tauri::command]
async fn check_proxy_validity(
proxy_id: String,
proxy_settings: crate::browser::ProxySettings,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity(&proxy_id, &proxy_settings)
.await
}
#[tauri::command]
fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::ProxyCheckResult> {
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
}
#[tauri::command]
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();
@@ -241,14 +285,49 @@ pub fn run() {
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
if let Some(url) = startup_url.clone() {
println!("Found startup URL in command line: {url}");
log::info!("Found startup URL in command line: {url}");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url.clone());
}
// Configure logging plugin with separate logs for dev and production
let log_file_name = if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}))
.max_file_size(100_000) // 100KB
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
let now = Local::now();
let timestamp = format!(
"{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis()
);
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
record.level(),
message
))
})
.build(),
)
.plugin(tauri_plugin_single_instance::init(|_, args, _cwd| {
println!("Single instance triggered with args: {args:?}");
log::info!("Single instance triggered with args: {args:?}");
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
@@ -261,7 +340,7 @@ pub fn run() {
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(900.0, 600.0)
.inner_size(800.0, 500.0)
.resizable(false)
.fullscreen(false)
.center()
@@ -275,18 +354,18 @@ pub fn run() {
#[cfg(target_os = "macos")]
{
if let Err(e) = window.set_transparent_titlebar(true) {
eprintln!("Failed to set transparent titlebar: {e}");
log::warn!("Failed to set transparent titlebar: {e}");
}
}
// Set up deep link handler
let handle = app.handle().clone();
#[cfg(any(windows, target_os = "linux"))]
#[cfg(windows)]
{
// For Windows and Linux, register all deep links at runtime for development
// For Windows, register all deep links at runtime
if let Err(e) = app.deep_link().register_all() {
eprintln!("Failed to register deep links: {e}");
log::warn!("Failed to register deep links: {e}");
}
}
@@ -294,7 +373,7 @@ pub fn run() {
{
// On macOS, try to register deep links for development builds
if let Err(e) = app.deep_link().register_all() {
eprintln!(
log::debug!(
"Note: Deep link registration failed on macOS (this is normal for production): {e}"
);
}
@@ -304,11 +383,11 @@ pub fn run() {
let handle = handle.clone();
move |event| {
let urls = event.urls();
println!("Deep link event received with {} URLs", urls.len());
log::info!("Deep link event received with {} URLs", urls.len());
for url in urls {
let url_string = url.to_string();
println!("Deep link received: {url_string}");
log::info!("Deep link received: {url_string}");
// Clone the handle for each async task
let handle_clone = handle.clone();
@@ -316,7 +395,7 @@ pub fn run() {
// Handle the URL asynchronously
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
eprintln!("Failed to handle deep link URL: {e}");
log::error!("Failed to handle deep link URL: {e}");
}
});
}
@@ -326,9 +405,9 @@ pub fn run() {
if let Some(startup_url) = startup_url {
let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move {
println!("Processing startup URL from command line: {startup_url}");
log::info!("Processing startup URL from command line: {startup_url}");
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
eprintln!("Failed to handle startup URL: {e}");
log::error!("Failed to handle startup URL: {e}");
}
});
}
@@ -348,7 +427,7 @@ pub fn run() {
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
eprintln!("Failed to start background updates: {e}");
log::error!("Failed to start background updates: {e}");
}
}
});
@@ -379,9 +458,9 @@ pub fn run() {
};
for url in pending_urls {
println!("Processing pending URL: {url}");
log::info!("Processing pending URL: {url}");
if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
eprintln!("Failed to handle pending URL: {e}");
log::error!("Failed to handle pending URL: {e}");
}
}
});
@@ -396,35 +475,36 @@ pub fn run() {
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
eprintln!("Periodic cleanup failed: {e}");
log::error!("Periodic cleanup failed: {e}");
} else {
println!("Periodic cleanup completed successfully");
log::debug!("Periodic cleanup completed successfully");
}
}
});
let app_handle_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
println!("Starting app update check at startup...");
log::info!("Starting app update check at startup...");
let updater = app_auto_updater::AppAutoUpdater::instance();
match updater.check_for_updates().await {
Ok(Some(update_info)) => {
println!(
log::info!(
"App update available: {} -> {}",
update_info.current_version, update_info.new_version
update_info.current_version,
update_info.new_version
);
// Emit update available event to the frontend
if let Err(e) = app_handle_update.emit("app-update-available", &update_info) {
eprintln!("Failed to emit app update event: {e}");
log::error!("Failed to emit app update event: {e}");
} else {
println!("App update event emitted successfully");
log::debug!("App update event emitted successfully");
}
}
Ok(None) => {
println!("No app updates available");
log::debug!("No app updates available");
}
Err(e) => {
eprintln!("Failed to check for app updates: {e}");
log::error!("Failed to check for app updates: {e}");
}
}
});
@@ -443,7 +523,7 @@ pub fn run() {
// Cleanup completed silently
}
Err(e) => {
eprintln!("Error during Camoufox cleanup: {e}");
log::error!("Error during Camoufox cleanup: {e}");
}
}
}
@@ -458,22 +538,24 @@ pub fn run() {
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
println!("GeoIP database is missing for Camoufox profiles, downloading at startup...");
log::info!(
"GeoIP database is missing for Camoufox profiles, downloading at startup..."
);
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
eprintln!("Failed to download GeoIP database at startup: {e}");
log::error!("Failed to download GeoIP database at startup: {e}");
} else {
println!("GeoIP database downloaded successfully at startup");
log::info!("GeoIP database downloaded successfully at startup");
}
}
Ok(false) => {
// No Camoufox profiles or GeoIP database already available
}
Err(e) => {
eprintln!("Failed to check GeoIP database status at startup: {e}");
log::error!("Failed to check GeoIP database status at startup: {e}");
}
}
});
@@ -492,14 +574,14 @@ pub fn run() {
{
Ok(dead_pids) => {
if !dead_pids.is_empty() {
println!(
log::info!(
"Cleaned up proxies for {} dead browser processes",
dead_pids.len()
);
}
}
Err(e) => {
eprintln!("Error during proxy cleanup: {e}");
log::error!("Error during proxy cleanup: {e}");
}
}
}
@@ -508,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();
@@ -520,7 +603,7 @@ pub fn run() {
let profiles = match runner.profile_manager.list_profiles() {
Ok(p) => p,
Err(e) => {
println!("Warning: Failed to list profiles in status checker: {e}");
log::warn!("Failed to list profiles in status checker: {e}");
continue;
}
};
@@ -540,9 +623,11 @@ pub fn run() {
// Only emit event if state actually changed
if last_state != is_running {
println!(
log::debug!(
"Status checker detected change for profile {}: {} -> {}",
profile.name, last_state, is_running
profile.name,
last_state,
is_running
);
#[derive(serde::Serialize)]
@@ -557,11 +642,12 @@ pub fn run() {
};
if let Err(e) = app_handle_status.emit("profile-running-changed", &payload) {
println!("Warning: Failed to emit profile running changed event: {e}");
log::warn!("Failed to emit profile running changed event: {e}");
} else {
println!(
log::debug!(
"Status checker emitted profile-running-changed event for {}: running={}",
profile.name, is_running
profile.name,
is_running
);
}
@@ -572,10 +658,7 @@ pub fn run() {
}
}
Err(e) => {
println!(
"Warning: Status check failed for profile {}: {}",
profile.name, e
);
log::warn!("Status check failed for profile {}: {}", profile.name, e);
continue;
}
}
@@ -591,12 +674,12 @@ pub fn run() {
match crate::settings_manager::get_app_settings(app_handle_api.clone()).await {
Ok(settings) => {
if settings.api_enabled {
println!("API is enabled in settings, starting API server...");
log::info!("API is enabled in settings, starting API server...");
match crate::api_server::start_api_server_internal(settings.api_port, &app_handle_api)
.await
{
Ok(port) => {
println!("API server started successfully on port {port}");
log::info!("API server started successfully on port {port}");
// Emit success toast to frontend
if let Err(e) = app_handle_api.emit(
"show-toast",
@@ -607,11 +690,11 @@ pub fn run() {
description: Some(format!("API server running on port {port}")),
},
) {
eprintln!("Failed to emit API start toast: {e}");
log::error!("Failed to emit API start toast: {e}");
}
}
Err(e) => {
eprintln!("Failed to start API server at startup: {e}");
log::error!("Failed to start API server at startup: {e}");
// Emit error toast to frontend
if let Err(toast_err) = app_handle_api.emit(
"show-toast",
@@ -622,14 +705,14 @@ pub fn run() {
description: Some(format!("Error: {e}")),
},
) {
eprintln!("Failed to emit API error toast: {toast_err}");
log::error!("Failed to emit API error toast: {toast_err}");
}
}
}
}
}
Err(e) => {
eprintln!("Failed to load app settings for API startup: {e}");
log::error!("Failed to load app settings for API startup: {e}");
}
}
});
@@ -653,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,
@@ -682,6 +766,8 @@ pub fn run() {
get_stored_proxies,
update_stored_proxy,
delete_stored_proxy,
check_proxy_validity,
get_cached_proxy_check,
update_camoufox_config,
get_profile_groups,
get_groups_with_profile_counts,
@@ -695,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");
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
donutbrowser::run()
donutbrowser_lib::run()
}
+115 -421
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;
@@ -8,46 +7,13 @@ use std::process::Command;
#[cfg(target_os = "macos")]
pub mod macos {
use super::*;
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,
}
}
use sysinfo::{Pid, System};
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
println!("Launching browser on macOS: {executable_path:?} with args: {args:?}");
log::info!("Launching browser on macOS: {executable_path:?} with args: {args:?}");
// If the executable is inside an app bundle, launch via Launch Services so
// macOS recognizes the real application for privacy permissions (e.g. Screen Recording).
// This ensures TCC prompts are attributed to the browser app, not our launcher.
@@ -93,7 +59,7 @@ pub mod macos {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
// First try: Use Firefox remote command
println!("Trying Firefox remote command for PID: {pid}");
log::info!("Trying Firefox remote command for PID: {pid}");
let browser = create_browser(browser_type);
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
let remote_args = vec![
@@ -107,17 +73,17 @@ pub mod macos {
match remote_output {
Ok(output) if output.status.success() => {
println!("Firefox remote command succeeded");
log::info!("Firefox remote command succeeded");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!(
log::info!(
"Firefox remote command failed with stderr: {stderr}, trying AppleScript fallback"
);
}
Err(e) => {
println!("Firefox remote command error: {e}, trying AppleScript fallback");
log::info!("Firefox remote command error: {e}, trying AppleScript fallback");
}
}
}
@@ -195,12 +161,12 @@ end try
"#
);
println!("Executing AppleScript fallback for Firefox-based browser (PID: {pid})...");
log::info!("Executing AppleScript fallback for Firefox-based browser (PID: {pid})...");
let output = Command::new("osascript").args(["-e", &script]).output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
println!("AppleScript failed: {error_msg}");
log::info!("AppleScript failed: {error_msg}");
return Err(
format!(
"Both Firefox remote command and AppleScript failed. AppleScript error: {error_msg}"
@@ -208,7 +174,7 @@ end try
.into(),
);
} else {
println!("AppleScript succeeded");
log::info!("AppleScript succeeded");
}
Ok(())
@@ -216,193 +182,132 @@ end try
pub async fn kill_browser_process_impl(
pid: u32,
profile_data_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Attempting to kill browser process with PID: {pid}");
log::info!("Attempting to kill browser process with PID: {pid}");
// For Chromium-based browsers, use immediate aggressive termination
// Chromium browsers are notoriously difficult to kill on macOS due to process spawning
let mut pids_to_kill = vec![pid];
// Step 1: Immediate SIGKILL on main process (no graceful shutdown for Chromium)
println!("Starting immediate SIGKILL for PID: {pid}");
let _ = Command::new("kill")
.args(["-KILL", &pid.to_string()])
.output();
let descendants = get_all_descendant_pids(pid).await;
pids_to_kill.extend(descendants);
// Step 2: Comprehensive process tree termination using multiple methods simultaneously
let _ = kill_chromium_process_tree_aggressive(pid).await;
if let Some(profile_path) = profile_data_path {
let additional_pids = find_processes_by_profile_path(profile_path).await;
for p in additional_pids {
if !pids_to_kill.contains(&p) {
log::info!("Found additional process {} using profile path", p);
pids_to_kill.push(p);
}
}
}
// Step 2.5: Nuclear option - kill all Chromium processes by name pattern
let _ = kill_all_chromium_processes_by_name().await;
log::info!("Total processes to kill: {:?}", pids_to_kill);
for &p in &pids_to_kill {
log::info!("Sending SIGKILL to PID: {p}");
let _ = Command::new("kill")
.args(["-KILL", &p.to_string()])
.output();
}
// Step 3: Use multiple kill strategies in parallel
let pid_str = pid.to_string();
// Kill by parent PID with SIGKILL
let _ = Command::new("pkill")
.args(["-KILL", "-P", &pid_str])
.output();
// Kill by process group with SIGKILL
let _ = Command::new("pkill")
.args(["-KILL", "-g", &pid_str])
.output();
// Kill by session ID
let _ = Command::new("pkill")
.args(["-KILL", "-s", &pid_str])
.output();
// Wait briefly for initial termination
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
// Step 4: Verify and retry with pattern-based killing for common Chromium process names
use sysinfo::{Pid, System};
let system = System::new_all();
// Check if main process still exists
if system.process(Pid::from(pid as usize)).is_some() {
println!("Main process {pid} still running, using pattern-based termination");
// Kill by common Chromium process patterns
let chromium_patterns = [
"Chrome",
"Chromium",
"Brave",
"chrome",
"chromium",
"brave",
"Google Chrome",
"Brave Browser",
"Chrome Helper",
"Chromium Helper",
];
for pattern in &chromium_patterns {
let _ = Command::new("pkill")
.args(["-KILL", "-f", pattern])
for &p in &pids_to_kill {
let system = System::new_all();
if system.process(Pid::from(p as usize)).is_some() {
log::info!("Process {p} still running, retrying kill");
let _ = Command::new("kill")
.args(["-KILL", &p.to_string()])
.output();
}
}
// Step 5: Final aggressive cleanup - kill any remaining processes
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
// One more round of comprehensive killing
let _ = Command::new("pkill")
.args(["-KILL", "-P", &pid_str])
.output();
let _ = Command::new("pkill")
.args(["-KILL", "-g", &pid_str])
.output();
// Final verification with extended wait
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let system = System::new_all();
let mut still_running = Vec::new();
for &p in &pids_to_kill {
if system.process(Pid::from(p as usize)).is_some() {
still_running.push(p);
}
}
if system.process(Pid::from(pid as usize)).is_some() {
// Last resort: try system kill command with different signals
println!("Process {pid} extremely persistent, trying system-level termination");
if !still_running.is_empty() {
log::info!(
"Processes {:?} still running, trying final termination",
still_running
);
let _ = Command::new("/bin/kill").args(["-KILL", &pid_str]).output();
let _ = Command::new("/usr/bin/killall")
.args(["-KILL", "-m", "Chrome"])
.output();
let _ = Command::new("/usr/bin/killall")
.args(["-KILL", "-m", "Chromium"])
.output();
let _ = Command::new("/usr/bin/killall")
.args(["-KILL", "-m", "Brave"])
.output();
for p in &still_running {
let _ = Command::new("/bin/kill")
.args(["-KILL", &p.to_string()])
.output();
}
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let system = System::new_all();
let mut final_still_running = Vec::new();
for &p in &pids_to_kill {
if system.process(Pid::from(p as usize)).is_some() {
final_still_running.push(p);
}
}
if system.process(Pid::from(pid as usize)).is_some() {
println!("WARNING: Process {pid} could not be terminated despite aggressive attempts");
// Don't return error - let the UI update anyway since we tried everything
if !final_still_running.is_empty() {
log::error!(
"ERROR: Processes {:?} could not be terminated despite aggressive attempts",
final_still_running
);
return Err(
format!(
"Failed to terminate browser processes {:?} - still running",
final_still_running
)
.into(),
);
}
}
println!("Aggressive browser termination completed for PID: {pid}");
log::info!("Browser termination completed for PID: {pid}");
Ok(())
}
// Helper function to kill process tree (Chromium browsers often spawn child processes)
async fn kill_chromium_process_tree_aggressive(
pid: u32,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Killing comprehensive process tree for PID: {pid}");
async fn find_processes_by_profile_path(profile_path: &str) -> Vec<u32> {
use sysinfo::System;
// Get all descendant processes using recursive process tree discovery
let descendant_pids = get_all_descendant_pids(pid).await;
println!(
"Found {} descendant processes to terminate",
descendant_pids.len()
);
let mut pids = Vec::new();
let system = System::new_all();
// Kill all descendants first (reverse order - children before parents)
for &desc_pid in descendant_pids.iter().rev() {
if desc_pid != pid {
println!("Terminating descendant process: {desc_pid}");
let _ = Command::new("kill")
.args(["-KILL", &desc_pid.to_string()])
.output();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
// Check if any command line argument contains the profile path
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
pids.push(pid.as_u32());
}
}
// No delay for initial termination
// Force kill any remaining descendants
for &desc_pid in descendant_pids.iter().rev() {
if desc_pid != pid {
let _ = Command::new("kill")
.args(["-KILL", &desc_pid.to_string()])
.output();
}
}
// Also use pkill as a backup to catch any processes we might have missed
let _ = Command::new("pkill")
.args(["-KILL", "-P", &pid.to_string()])
.output();
// On macOS, also try killing by process group for Chromium browsers
let _ = Command::new("pkill")
.args(["-KILL", "-g", &pid.to_string()])
.output();
Ok(())
}
// Helper function to kill all Chromium-related processes by name patterns
async fn kill_all_chromium_processes_by_name(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Killing all Chromium-related processes by name patterns");
let chromium_patterns = [
"Chrome",
"Chromium",
"Brave",
"chrome",
"chromium",
"brave",
"Google Chrome",
"Brave Browser",
"Chrome Helper",
"Chromium Helper",
];
for pattern in &chromium_patterns {
let _ = Command::new("pkill")
.args(["-KILL", "-f", pattern])
.output();
}
Ok(())
pids
}
// Recursively find all descendant processes
@@ -435,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();
println!("Opening URL in TOR/Mullvad browser using file-based approach (PID: {pid})");
// Method 1: Try using a temporary HTML file approach
println!("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(()) => {
println!("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() => {
println!("Successfully opened URL using file-based approach");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("File-based approach failed: {stderr}");
}
Err(e) => {
println!("File-based approach error: {e}");
}
}
}
let _ = std::fs::remove_file(&temp_file_path);
}
Err(e) => {
println!("Failed to create temporary HTML file: {e}");
}
}
// Method 2: Try using the 'open' command directly with the URL
println!("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() => {
println!("Successfully opened URL using direct 'open' command");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Direct 'open' command failed: {stderr}");
}
Err(e) => {
println!("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,
@@ -561,7 +350,7 @@ end try
let pid = profile.process_id.unwrap();
// First, try using the browser's built-in URL opening capability
println!("Trying Chromium URL opening for PID: {pid}");
log::info!("Trying Chromium URL opening for PID: {pid}");
let browser = create_browser(browser_type);
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
@@ -575,15 +364,15 @@ end try
match remote_output {
Ok(output) if output.status.success() => {
println!("Chromium URL opening succeeded");
log::info!("Chromium URL opening succeeded");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Chromium URL opening failed: {stderr}, trying AppleScript");
log::info!("Chromium URL opening failed: {stderr}, trying AppleScript");
}
Err(e) => {
println!("Chromium URL opening error: {e}, trying AppleScript");
log::info!("Chromium URL opening error: {e}, trying AppleScript");
}
}
}
@@ -661,17 +450,17 @@ end try
"#
);
println!("Executing AppleScript for Chromium-based browser (PID: {pid})...");
log::info!("Executing AppleScript for Chromium-based browser (PID: {pid})...");
let output = Command::new("osascript").args(["-e", &script]).output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
println!("AppleScript failed: {error_msg}");
log::info!("AppleScript failed: {error_msg}");
return Err(
format!("Failed to open URL in existing Chromium-based browser: {error_msg}").into(),
);
} else {
println!("AppleScript succeeded");
log::info!("AppleScript succeeded");
}
Ok(())
@@ -682,49 +471,14 @@ 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],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
println!(
log::info!(
"Launching browser on Windows: {:?} with args: {:?}",
executable_path, args
executable_path,
args
);
// Check if the executable exists
@@ -763,7 +517,7 @@ pub mod windows {
.spawn()
.map_err(|e| format!("Failed to launch browser process: {}", e))?;
println!(
log::info!(
"Successfully launched browser process with PID: {}",
child.id()
);
@@ -841,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,
@@ -930,7 +642,7 @@ pub mod windows {
let system = System::new_all();
if let Some(process) = system.process(Pid::from(pid as usize)) {
if process.kill() {
println!("Successfully killed browser process with PID: {pid}");
log::info!("Successfully killed browser process with PID: {pid}");
return Ok(());
}
}
@@ -946,7 +658,7 @@ pub mod windows {
match output {
Ok(result) => {
if result.status.success() {
println!("Successfully killed browser process with PID: {pid} using taskkill");
log::info!("Successfully killed browser process with PID: {pid} using taskkill");
Ok(())
} else {
Err(
@@ -968,22 +680,14 @@ 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],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
println!(
log::info!(
"Launching browser on Linux: {:?} with args: {:?}",
executable_path, args
executable_path,
args
);
// Check if the executable exists and is executable
@@ -1047,7 +751,7 @@ pub mod linux {
// Set the combined LD_LIBRARY_PATH
if !ld_library_path.is_empty() {
cmd.env("LD_LIBRARY_PATH", ld_library_path.join(":"));
println!("Set LD_LIBRARY_PATH to: {}", ld_library_path.join(":"));
log::info!("Set LD_LIBRARY_PATH to: {}", ld_library_path.join(":"));
}
}
@@ -1064,7 +768,7 @@ pub mod linux {
// Disable GPU acceleration if running in headless environments
if std::env::var("DISPLAY").is_err() || std::env::var("WAYLAND_DISPLAY").is_err() {
println!("No display detected, browser may fail to start");
log::info!("No display detected, browser may fail to start");
}
// Attempt to spawn with better error handling for architecture issues
@@ -1132,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,
@@ -1188,7 +882,7 @@ pub mod linux {
return Err(format!("Process {} not found", pid).into());
}
println!("Successfully killed browser process with PID: {pid}");
log::info!("Successfully killed browser process with PID: {pid}");
Ok(())
}
}
+226 -165
View File
@@ -61,7 +61,7 @@ impl ProfileManager {
camoufox_config: Option<CamoufoxConfig>,
group_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
println!("Attempting to create profile: {name}");
log::info!("Attempting to create profile: {name}");
// Check if a profile with this name already exists (case insensitive)
let existing_profiles = self.list_profiles()?;
@@ -86,7 +86,7 @@ impl ProfileManager {
// For Camoufox profiles, generate fingerprint during creation
let final_camoufox_config = if browser == "camoufox" {
let mut config = camoufox_config.unwrap_or_else(|| {
println!("Creating default Camoufox config for profile: {name}");
log::info!("Creating default Camoufox config for profile: {name}");
crate::camoufox_manager::CamoufoxConfig::default()
});
@@ -110,7 +110,7 @@ impl ProfileManager {
let binary_path = browser_dir.join("camoufox");
config.executable_path = Some(binary_path.to_string_lossy().to_string());
println!("Set Camoufox executable path: {:?}", config.executable_path);
log::info!("Set Camoufox executable path: {:?}", config.executable_path);
}
// Pass upstream proxy information to config for fingerprint generation
@@ -137,7 +137,7 @@ impl ProfileManager {
)
};
config.proxy = Some(proxy_url);
println!(
log::info!(
"Using upstream proxy for Camoufox fingerprint generation: {}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
@@ -148,7 +148,7 @@ impl ProfileManager {
// Generate fingerprint if not already provided
if config.fingerprint.is_none() {
println!("Generating fingerprint for Camoufox profile: {name}");
log::info!("Generating fingerprint for Camoufox profile: {name}");
// Use the camoufox launcher to generate the config
@@ -165,6 +165,7 @@ impl ProfileManager {
camoufox_config: None,
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
};
match self
@@ -174,7 +175,7 @@ impl ProfileManager {
{
Ok(generated_fingerprint) => {
config.fingerprint = Some(generated_fingerprint);
println!("Successfully generated fingerprint for profile: {name}");
log::info!("Successfully generated fingerprint for profile: {name}");
}
Err(e) => {
return Err(
@@ -183,7 +184,7 @@ impl ProfileManager {
}
}
} else {
println!("Using provided fingerprint for Camoufox profile: {name}");
log::info!("Using provided fingerprint for Camoufox profile: {name}");
}
// Clear the proxy from config after fingerprint generation
@@ -207,6 +208,7 @@ impl ProfileManager {
camoufox_config: final_camoufox_config,
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
};
// Save profile info
@@ -217,7 +219,7 @@ impl ProfileManager {
return Err(format!("Failed to create profile file for '{name}'").into());
}
println!("Profile '{name}' created successfully with ID: {profile_id}");
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
// Create user.js with common Firefox preferences and apply proxy settings if provided
if let Some(proxy_id_ref) = &proxy_id {
@@ -234,7 +236,7 @@ impl ProfileManager {
// Emit profile creation event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
@@ -320,7 +322,7 @@ impl ProfileManager {
// Emit profile rename event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
@@ -331,7 +333,7 @@ impl ProfileManager {
app_handle: &tauri::AppHandle,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Attempting to delete profile with ID: {profile_id}");
log::info!("Attempting to delete profile with ID: {profile_id}");
// Find the profile by ID
let profile_uuid =
@@ -354,9 +356,9 @@ impl ProfileManager {
// Delete the entire UUID directory (contains both metadata.json and profile data)
if profile_uuid_dir.exists() {
println!("Deleting profile directory: {}", profile_uuid_dir.display());
log::info!("Deleting profile directory: {}", profile_uuid_dir.display());
fs::remove_dir_all(&profile_uuid_dir)?;
println!("Profile directory deleted successfully");
log::info!("Profile directory deleted successfully");
}
// Verify deletion was successful
@@ -364,9 +366,10 @@ impl ProfileManager {
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
}
println!(
log::info!(
"Profile '{}' (ID: {}) deleted successfully",
profile.name, profile_id
profile.name,
profile_id
);
// Rebuild tag suggestions after deletion
@@ -376,12 +379,12 @@ impl ProfileManager {
// Always perform cleanup after profile deletion to remove unused binaries
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
println!("Warning: Failed to cleanup unused binaries after profile deletion: {e}");
log::warn!("Warning: Failed to cleanup unused binaries after profile deletion: {e}");
}
// Emit profile deletion event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
@@ -434,7 +437,7 @@ impl ProfileManager {
// Emit profile update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
@@ -475,7 +478,7 @@ impl ProfileManager {
// Emit profile group assignment event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
@@ -515,7 +518,36 @@ impl ProfileManager {
// Emit profile tags update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
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)
@@ -558,7 +590,7 @@ impl ProfileManager {
// Emit profile deletion event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
@@ -610,14 +642,15 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
println!(
log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name, profile_id
profile.name,
profile_id
);
// Emit profile config update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
@@ -692,12 +725,12 @@ impl ProfileManager {
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
if let Err(e) = app_handle.emit("profile-updated", &profile) {
println!("Warning: Failed to emit profile update event: {e}");
log::warn!("Warning: Failed to emit profile update event: {e}");
}
// Emit general profiles changed event for profile list updates
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
@@ -717,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;
@@ -732,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
@@ -761,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 {
@@ -770,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"),
@@ -799,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
@@ -822,7 +849,7 @@ impl ProfileManager {
// Found a matching process
found_pid = Some(pid.as_u32());
is_running = true;
println!(
log::info!(
"Found browser process with PID: {} for profile: {}",
pid.as_u32(),
profile.name
@@ -849,34 +876,26 @@ 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 {
if merged.process_id != Some(pid) {
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
println!("Warning: Failed to update profile with new PID: {e}");
log::warn!("Warning: Failed to update profile with new PID: {e}");
}
}
} else if merged.process_id.is_some() {
// Clear the PID if no process found
merged.process_id = None;
if let Err(e) = self.save_profile(&merged) {
println!("Warning: Failed to clear profile PID: {e}");
}
// Stop any associated proxy immediately when the browser stops
if let Some(old_pid) = previous_pid {
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
log::warn!("Warning: Failed to clear profile PID: {e}");
}
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &merged) {
println!("Warning: Failed to emit profile update event: {e}");
log::warn!("Warning: Failed to emit profile update event: {e}");
}
}
@@ -916,17 +935,18 @@ impl ProfileManager {
if latest.process_id != camoufox_process.processId {
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
println!("Warning: Failed to update Camoufox profile with process info: {e}");
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}");
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e}");
log::warn!("Warning: Failed to emit profile update event: {e}");
}
println!(
log::info!(
"Camoufox process has started for profile '{}' with PID: {:?}",
profile.name, camoufox_process.processId
profile.name,
camoufox_process.processId
);
}
}
@@ -948,20 +968,14 @@ 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) {
println!("Warning: Failed to clear Camoufox profile process info: {e}");
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) {
println!("Warning: Failed to emit profile update event: {e}");
log::warn!("Warning: Failed to emit profile update event: {e}");
}
}
}
@@ -969,7 +983,7 @@ impl ProfileManager {
}
Err(e) => {
// Error checking status, assume not running and clear process ID
println!("Warning: Failed to check Camoufox status via nodecar: {e}");
log::warn!("Warning: Failed to check Camoufox status via nodecar: {e}");
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
@@ -984,20 +998,17 @@ 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) {
println!("Warning: Failed to clear Camoufox profile process info after error: {e2}");
log::warn!(
"Warning: Failed to clear Camoufox profile process info after error: {e2}"
);
}
// Best-effort stop of proxy tied to old PID
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
// Emit profile update event to frontend
if let Err(e3) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e3}");
log::warn!("Warning: Failed to emit profile update event: {e3}");
}
}
}
@@ -1006,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(),
@@ -1050,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(),
]
}
@@ -1081,63 +1096,87 @@ impl ProfileManager {
internal_proxy: Option<&ProxySettings>,
) -> Result<(), Box<dyn std::error::Error>> {
let user_js_path = profile_data_path.join("user.js");
let mut preferences = Vec::new();
let prefs_js_path = profile_data_path.join("prefs.js");
// 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")?;
// Remove prefs.js if it exists to ensure Firefox reads user.js instead
// Firefox may cache proxy settings in prefs.js, so we need to clear it
if prefs_js_path.exists() {
log::info!("Removing prefs.js to ensure Firefox reads updated user.js settings");
let _ = fs::remove_file(&prefs_js_path);
}
let mut preferences = Vec::new();
// 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 as the primary proxy
format!("HTTP {}:{}", internal.host, internal.port)
// 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");
log::info!(
"Applying manual proxy settings to Firefox profile: {}:{} (is_internal: {}, is_socks: {})",
proxy_host,
proxy_port,
internal_proxy.is_some(),
is_socks
);
// 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 {
// Use user-configured proxy directly
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()),
}
};
// 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(),
]);
}
// 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");
fs::write(&pac_path, pac_content)?;
// Configure Firefox to use the PAC file
// 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\", \"file://{}\");",
pac_path.to_string_lossy()
),
"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
fs::write(user_js_path, preferences.join("\n"))?;
let user_js_content = preferences.join("\n");
fs::write(user_js_path, &user_js_content)?;
log::info!(
"Updated user.js with manual proxy settings: {}:{}",
proxy_host,
proxy_port
);
Ok(())
}
@@ -1185,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)
@@ -1299,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"
);
}
}
@@ -1378,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 {
+2 -19
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,12 +551,13 @@ impl ProfileImporter {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
note: None,
};
// Save the profile metadata
self.profile_manager.save_profile(&profile)?;
println!(
log::info!(
"Successfully imported profile '{}' from '{}'",
new_profile_name,
source_path.display()
@@ -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"
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
use crate::proxy_storage::{
delete_proxy_config, generate_proxy_id, get_proxy_config, is_process_running, list_proxy_configs,
save_proxy_config, ProxyConfig,
};
use std::process::Stdio;
lazy_static::lazy_static! {
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
std::sync::Mutex::new(std::collections::HashMap::new());
}
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());
// Get available port if not specified
let local_port = port.unwrap_or_else(|| {
// Find an available port
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().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()?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
use std::process::Command as StdCommand;
let mut cmd = StdCommand::new(&exe);
cmd.arg("proxy-worker");
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
// 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());
}
// Properly detach the process on Unix by creating a new session
unsafe {
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(())
});
}
// Spawn detached process
let child = cmd.spawn()?;
let pid = child.id();
// Store PID
{
let mut processes = PROXY_PROCESSES.lock().unwrap();
processes.insert(id.clone(), pid);
}
// Update config with PID
let mut config_with_pid = config.clone();
config_with_pid.pid = Some(pid);
save_proxy_config(&config_with_pid)?;
// Don't wait for the child - it's detached
drop(child);
}
#[cfg(windows)]
{
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");
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
// On Windows, use CREATE_NEW_PROCESS_GROUP flag for proper detachment
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
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();
processes.insert(id.clone(), pid);
}
// Update config with PID
let mut config_with_pid = config.clone();
config_with_pid.pid = Some(pid);
save_proxy_config(&config_with_pid)?;
drop(child);
}
// Give the process a moment to start up before checking
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Wait for the worker to bind to the port and update config
// Since we pre-allocated the port, the worker should bind immediately
// We check quickly with short intervals to make startup fast
let mut attempts = 0;
let max_attempts = 40; // 4 seconds max (40 * 100ms) - give it more time to start
loop {
// Use shorter sleep for faster startup
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Some(updated_config) = get_proxy_config(&id) {
// Check if local_url is set (worker has bound and updated config)
if let Some(ref local_url) = updated_config.local_url {
if !local_url.is_empty() {
if let Some(port) = updated_config.local_port {
// Try to connect immediately - port should be ready since we pre-allocated it
match tokio::time::timeout(
tokio::time::Duration::from_millis(100),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
{
Ok(Ok(_stream)) => {
// Port is listening and accepting connections!
return Ok(updated_config);
}
Ok(Err(_)) | Err(_) => {
// Port not ready yet, continue waiting
}
}
}
}
}
}
attempts += 1;
if attempts >= max_attempts {
// Try to get the config one more time for better error message
if let Some(config) = get_proxy_config(&id) {
// Check if process is still running
let process_running = config.pid.map(is_process_running).unwrap_or(false);
return Err(
format!(
"Proxy worker failed to start in time. Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
config.id, config.local_url, config.local_port, config.pid, process_running
)
.into(),
);
}
return Err(
format!(
"Proxy worker failed to start in time. Config not found for id: {}",
id
)
.into(),
);
}
}
}
pub async fn stop_proxy_process(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
let config = get_proxy_config(id);
if let Some(config) = config {
if let Some(pid) = config.pid {
// Kill the process
#[cfg(unix)]
{
use std::process::Command;
let _ = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.output();
}
#[cfg(windows)]
{
use std::process::Command;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.output();
}
// Wait a bit for the process to exit
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Remove from tracking
{
let mut processes = PROXY_PROCESSES.lock().unwrap();
processes.remove(id);
}
// Delete the config file
delete_proxy_config(id);
return Ok(true);
}
}
Ok(false)
}
pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>> {
let configs = list_proxy_configs();
for config in configs {
let _ = stop_proxy_process(&config.id).await;
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
#[cfg(test)]
mod tests {
use super::*;
use crate::proxy_runner::{start_proxy_process, stop_proxy_process};
use crate::proxy_storage::{delete_proxy_config, generate_proxy_id, list_proxy_configs};
use std::process::Command;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::time::sleep;
#[tokio::test]
async fn test_proxy_storage() {
// Test proxy config storage
let id = generate_proxy_id();
let config = crate::proxy_storage::ProxyConfig::new(id.clone(), "DIRECT".to_string(), Some(8080));
// Save config
crate::proxy_storage::save_proxy_config(&config).unwrap();
// Load config
let loaded = crate::proxy_storage::get_proxy_config(&id).unwrap();
assert_eq!(loaded.id, id);
assert_eq!(loaded.upstream_url, "DIRECT");
assert_eq!(loaded.local_port, Some(8080));
// Delete config
assert!(crate::proxy_storage::delete_proxy_config(&id));
assert!(crate::proxy_storage::get_proxy_config(&id).is_none());
}
#[tokio::test]
async fn test_proxy_id_generation() {
let id1 = generate_proxy_id();
let id2 = generate_proxy_id();
assert_ne!(id1, id2);
assert!(id1.starts_with("proxy_"));
}
#[tokio::test]
async fn test_proxy_process_lifecycle() {
// Start a direct proxy
let config = start_proxy_process(None, Some(0)).await.unwrap();
let id = config.id.clone();
// Verify config was saved
let loaded = crate::proxy_storage::get_proxy_config(&id).unwrap();
assert_eq!(loaded.id, id);
// Wait a bit for the proxy to start
sleep(Duration::from_millis(500)).await;
// Stop the proxy
let stopped = stop_proxy_process(&id).await.unwrap();
assert!(stopped);
// Verify config was deleted
assert!(crate::proxy_storage::get_proxy_config(&id).is_none());
}
#[tokio::test]
async fn test_proxy_with_upstream_http() {
// Start a proxy with HTTP upstream (using a non-existent proxy for testing)
let upstream_url = "http://127.0.0.1:9999";
let config = start_proxy_process(Some(upstream_url.to_string()), Some(0))
.await
.unwrap();
let id = config.id.clone();
// Wait a bit
sleep(Duration::from_millis(500)).await;
// Clean up
let _ = stop_proxy_process(&id).await;
}
#[tokio::test]
async fn test_proxy_with_upstream_socks5() {
// Start a proxy with SOCKS5 upstream
let upstream_url = "socks5://127.0.0.1:1080";
let config = start_proxy_process(Some(upstream_url.to_string()), Some(0))
.await
.unwrap();
let id = config.id.clone();
// Wait a bit
sleep(Duration::from_millis(500)).await;
// Clean up
let _ = stop_proxy_process(&id).await;
}
#[tokio::test]
async fn test_proxy_port_assignment() {
// Start multiple proxies and verify they get different ports
let config1 = start_proxy_process(None, None).await.unwrap();
sleep(Duration::from_millis(100)).await;
let config2 = start_proxy_process(None, None).await.unwrap();
// They should have different IDs
assert_ne!(config1.id, config2.id);
// Clean up
let _ = stop_proxy_process(&config1.id).await;
let _ = stop_proxy_process(&config2.id).await;
}
#[tokio::test]
async fn test_proxy_list() {
// Start a few proxies
let config1 = start_proxy_process(None, None).await.unwrap();
sleep(Duration::from_millis(100)).await;
let config2 = start_proxy_process(None, None).await.unwrap();
// List all proxies
let configs = list_proxy_configs();
assert!(configs.len() >= 2);
// Clean up
let _ = stop_proxy_process(&config1.id).await;
let _ = stop_proxy_process(&config2.id).await;
}
}
+138
View File
@@ -0,0 +1,138 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig {
pub id: String,
pub upstream_url: String, // Can be "DIRECT" for direct proxy
pub local_port: Option<u16>,
pub ignore_proxy_certificate: Option<bool>,
pub local_url: Option<String>,
pub pid: Option<u32>,
#[serde(default)]
pub profile_id: Option<String>,
}
impl ProxyConfig {
pub fn new(id: String, upstream_url: String, local_port: Option<u16>) -> Self {
Self {
id,
upstream_url,
local_port,
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 {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("proxies");
path
}
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
let storage_dir = get_storage_dir();
fs::create_dir_all(&storage_dir)?;
let file_path = storage_dir.join(format!("{}.json", config.id));
let content = serde_json::to_string_pretty(config)?;
fs::write(&file_path, content)?;
Ok(())
}
pub fn get_proxy_config(id: &str) -> Option<ProxyConfig> {
let storage_dir = get_storage_dir();
let file_path = storage_dir.join(format!("{}.json", id));
if !file_path.exists() {
return None;
}
match fs::read_to_string(&file_path) {
Ok(content) => serde_json::from_str(&content).ok(),
Err(_) => None,
}
}
pub fn delete_proxy_config(id: &str) -> bool {
let storage_dir = get_storage_dir();
let file_path = storage_dir.join(format!("{}.json", id));
if !file_path.exists() {
return false;
}
fs::remove_file(&file_path).is_ok()
}
pub fn list_proxy_configs() -> Vec<ProxyConfig> {
let storage_dir = get_storage_dir();
if !storage_dir.exists() {
return Vec::new();
}
let mut configs = Vec::new();
if let Ok(entries) = fs::read_dir(&storage_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<ProxyConfig>(&content) {
configs.push(config);
}
}
}
}
}
configs
}
pub fn update_proxy_config(config: &ProxyConfig) -> bool {
let storage_dir = get_storage_dir();
let file_path = storage_dir.join(format!("{}.json", config.id));
if !file_path.exists() {
return false;
}
match serde_json::to_string_pretty(config) {
Ok(content) => fs::write(&file_path, content).is_ok(),
Err(_) => false,
}
}
pub fn generate_proxy_id() -> String {
format!(
"proxy_{}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
rand::random::<u32>()
)
}
pub fn is_process_running(pid: u32) -> bool {
use sysinfo::{Pid, System};
let system = System::new();
system.process(Pid::from(pid as usize)).is_some()
}
+18 -10
View File
@@ -110,17 +110,17 @@ impl SettingsManager {
Ok(settings) => {
// Save the settings back to ensure any missing fields are written with defaults
if let Err(e) = self.save_settings(&settings) {
eprintln!("Warning: Failed to update settings file with defaults: {e}");
log::warn!("Warning: Failed to update settings file with defaults: {e}");
}
Ok(settings)
}
Err(e) => {
eprintln!("Warning: Failed to parse settings file, using defaults: {e}");
log::warn!("Warning: Failed to parse settings file, using defaults: {e}");
let default_settings = AppSettings::default();
// Try to save default settings to fix the corrupted file
if let Err(save_error) = self.save_settings(&default_settings) {
eprintln!("Warning: Failed to save default settings: {save_error}");
log::warn!("Warning: Failed to save default settings: {save_error}");
}
Ok(default_settings)
@@ -223,8 +223,11 @@ impl SettingsManager {
let hash_bytes = hash_value.as_bytes();
// Take first 32 bytes for AES-256 key
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
let cipher = Aes256Gcm::new(key);
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
// Generate a random nonce
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
@@ -301,8 +304,10 @@ impl SettingsManager {
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes = &file_data[offset..offset + 12];
let nonce = Nonce::from_slice(nonce_bytes);
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce length")?;
let nonce = Nonce::from(nonce_bytes);
offset += 12;
// Read ciphertext
@@ -331,12 +336,15 @@ impl SettingsManager {
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
let cipher = Aes256Gcm::new(key);
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
// Decrypt the token
let plaintext = cipher
.decrypt(nonce, ciphertext)
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
match String::from_utf8(plaintext) {
File diff suppressed because it is too large Load Diff
+74 -82
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()?;
@@ -129,14 +129,14 @@ impl VersionUpdater {
let should_update = state.last_update_time == 0 || elapsed_secs >= update_interval_secs;
if should_update {
println!(
log::debug!(
"Background update needed: last_update={}, elapsed={}h, required={}h",
state.last_update_time,
elapsed_secs / 3600,
state.update_interval_hours
);
} else {
println!(
log::debug!(
"Background update not needed: last_update={}, elapsed={}h, required={}h",
state.last_update_time,
elapsed_secs / 3600,
@@ -152,12 +152,12 @@ impl VersionUpdater {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Only run if an update is actually needed
if !Self::should_run_background_update() {
println!("No startup version update needed");
log::debug!("No startup version update needed");
return Ok(());
}
if let Some(ref app_handle) = self.app_handle {
println!("Running startup version update...");
log::info!("Running startup version update...");
match self.update_all_browser_versions(app_handle).await {
Ok(_) => {
@@ -168,13 +168,13 @@ impl VersionUpdater {
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {e}");
log::error!("Failed to save background update state: {e}");
} else {
println!("Startup version update completed successfully");
log::info!("Startup version update completed successfully");
}
}
Err(e) => {
eprintln!("Startup version update failed: {e}");
log::error!("Startup version update failed: {e}");
return Err(e);
}
}
@@ -280,11 +280,11 @@ impl VersionUpdater {
};
if let Err(e) = app_handle.emit("version-update-progress", &initial_progress) {
eprintln!("Failed to emit initial progress: {e}");
log::error!("Failed to emit initial progress: {e}");
}
for (index, browser) in supported_browsers.iter().enumerate() {
println!("Updating browser versions for: {browser}");
log::debug!("Updating browser versions for: {browser}");
// Emit progress update for current browser
let progress = VersionUpdateProgress {
@@ -297,7 +297,7 @@ impl VersionUpdater {
};
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
eprintln!("Failed to emit progress for {browser}: {e}");
log::error!("Failed to emit progress for {browser}: {e}");
}
match self.update_browser_versions(browser).await {
@@ -323,7 +323,7 @@ impl VersionUpdater {
};
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
eprintln!("Failed to emit progress with versions for {browser}: {e}");
log::error!("Failed to emit progress with versions for {browser}: {e}");
}
}
Err(e) => {
@@ -393,7 +393,7 @@ impl VersionUpdater {
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state after manual update: {e}");
log::error!("Failed to save background update state after manual update: {e}");
}
Ok(results)
@@ -506,7 +506,7 @@ pub async fn clear_all_version_cache_and_refetch(
.auto_updater
.save_auto_update_state(&final_state)
{
eprintln!("Warning: Failed to re-enable browsers after cache clear: {e}");
log::warn!("Failed to re-enable browsers after cache clear: {e}");
}
result?;
@@ -516,41 +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();
fn cleanup_state_file() {
if let Ok(state_file) = VersionUpdater::get_background_update_state_file() {
let _ = fs::remove_file(&state_file);
}
let content = match fs::read_to_string(&state_file) {
Ok(content) => content,
Err(_) => return BackgroundUpdateState::default(),
};
serde_json::from_str(&content).unwrap_or_default()
}
#[test]
#[serial]
fn test_background_update_state_persistence() {
let test_name = "persistence";
let _temp_dir = setup_test_env();
// 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 {
@@ -559,25 +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");
// Load the state back
let loaded_state = load_test_state(test_name);
// Verify file was created
let state_file = VersionUpdater::get_background_update_state_file().unwrap();
assert!(state_file.exists(), "State file should exist after saving");
// 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);
assert_eq!(
loaded_state.update_interval_hours,
test_state.update_interval_hours
loaded_state.last_update_time, test_state.last_update_time,
"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,
"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)
@@ -626,37 +646,9 @@ mod tests {
should_update_never,
"Should update when never updated before"
);
}
#[test]
fn test_cache_dir_creation() {
// This should not panic and should create the directory if it doesn't exist
let cache_dir_result = VersionUpdater::get_cache_dir();
assert!(
cache_dir_result.is_ok(),
"Should successfully get cache directory"
);
let cache_dir = cache_dir_result.unwrap();
assert!(
cache_dir.exists(),
"Cache directory should exist after creation"
);
assert!(cache_dir.is_dir(), "Cache directory should be a directory");
// Verify the path contains expected components
let path_str = cache_dir.to_string_lossy();
assert!(
path_str.contains("version_cache"),
"Path should contain version_cache"
);
// Test that calling it again returns the same directory
let cache_dir2 = VersionUpdater::get_cache_dir().unwrap();
assert_eq!(
cache_dir, cache_dir2,
"Multiple calls should return same directory"
);
// Clean up
cleanup_state_file();
}
#[test]
+15 -14
View File
@@ -1,12 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.12.2",
"productName": "Donut",
"version": "0.13.8",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
"devUrl": "http://localhost:3000",
"beforeBuildCommand": "pnpm build",
"beforeBuildCommand": "pnpm copy-proxy-binary && (test -d ../dist || pnpm build)",
"frontendDist": "../dist"
},
"app": {
@@ -17,9 +17,9 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/nodecar"],
"externalBin": ["binaries/nodecar", "binaries/donut-proxy"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -41,22 +41,23 @@
},
"linux": {
"deb": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
},
"rpm": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
},
"appimage": {
"files": {
"usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"plugins": {
+3 -115
View File
@@ -1,59 +1,13 @@
use std::env;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
/// Utility functions for integration tests
pub struct TestUtils;
impl TestUtils {
/// Build the nodecar binary if it doesn't exist
pub async fn ensure_nodecar_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
{
let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
let project_root = PathBuf::from(cargo_manifest_dir)
.parent()
.unwrap()
.to_path_buf();
let nodecar_dir = project_root.join("nodecar");
let nodecar_binary = nodecar_dir.join("nodecar-bin");
// Check if binary already exists
if nodecar_binary.exists() {
return Ok(nodecar_binary);
}
println!("Building nodecar binary for integration tests...");
// Install dependencies
let install_status = Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&nodecar_dir)
.status()?;
if !install_status.success() {
return Err("Failed to install nodecar dependencies".into());
}
// Build the binary
let build_status = Command::new("pnpm")
.args(["run", "build"])
.current_dir(&nodecar_dir)
.status()?;
if !build_status.success() {
return Err("Failed to build nodecar binary".into());
}
if !nodecar_binary.exists() {
return Err("Nodecar binary was not created successfully".into());
}
Ok(nodecar_binary)
}
/// Execute a nodecar command with timeout
pub async fn execute_nodecar_command(
/// Execute a command (generic, for donut-proxy tests)
#[allow(dead_code)]
pub async fn execute_command(
binary_path: &PathBuf,
args: &[&str],
) -> Result<std::process::Output, Box<dyn std::error::Error + Send + Sync>> {
@@ -64,70 +18,4 @@ impl TestUtils {
Ok(output)
}
/// Check if a port is available
pub async fn is_port_available(port: u16) -> bool {
tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.await
.is_ok()
}
/// Wait for a port to become available or occupied
pub async fn wait_for_port_state(port: u16, should_be_occupied: bool, timeout_secs: u64) -> bool {
let start = std::time::Instant::now();
while start.elapsed().as_secs() < timeout_secs {
let is_available = Self::is_port_available(port).await;
if should_be_occupied && !is_available {
return true; // Port is occupied as expected
} else if !should_be_occupied && is_available {
return true; // Port is available as expected
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
false
}
/// Create a temporary directory for test files
pub fn create_temp_dir() -> Result<tempfile::TempDir, Box<dyn std::error::Error + Send + Sync>> {
Ok(tempfile::tempdir()?)
}
/// Clean up specific nodecar processes by IDs (for targeted test cleanup)
pub async fn cleanup_specific_processes(
nodecar_path: &PathBuf,
proxy_ids: &[String],
camoufox_ids: &[String],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Cleaning up specific test processes...");
// Stop specific proxies
for proxy_id in proxy_ids {
let stop_args = ["proxy", "stop", "--id", proxy_id];
if let Ok(output) = Self::execute_nodecar_command(nodecar_path, &stop_args).await {
if output.status.success() {
println!("Stopped test proxy: {proxy_id}");
}
}
}
// Stop specific camoufox instances
for camoufox_id in camoufox_ids {
let stop_args = ["camoufox", "stop", "--id", camoufox_id];
if let Ok(output) = Self::execute_nodecar_command(nodecar_path, &stop_args).await {
if output.status.success() {
println!("Stopped test camoufox instance: {camoufox_id}");
}
}
}
// Give processes time to clean up
tokio::time::sleep(Duration::from_millis(500)).await;
println!("Test process cleanup completed");
Ok(())
}
}
+640
View File
@@ -0,0 +1,640 @@
mod common;
use common::TestUtils;
use serde_json::Value;
use serial_test::serial;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::sleep;
/// Setup function to ensure donut-proxy binary exists and cleanup stale proxies
async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
let project_root = std::path::PathBuf::from(cargo_manifest_dir)
.parent()
.unwrap()
.to_path_buf();
// Build donut-proxy binary if it doesn't exist
let proxy_binary = project_root
.join("src-tauri")
.join("target")
.join("debug")
.join("donut-proxy");
if !proxy_binary.exists() {
println!("Building donut-proxy binary for integration tests...");
let build_status = std::process::Command::new("cargo")
.args(["build", "--bin", "donut-proxy"])
.current_dir(project_root.join("src-tauri"))
.status()?;
if !build_status.success() {
return Err("Failed to build donut-proxy binary".into());
}
}
if !proxy_binary.exists() {
return Err("donut-proxy binary was not created successfully".into());
}
// Clean up any stale proxies from previous test runs
let _ = TestUtils::execute_command(&proxy_binary, &["proxy", "stop"]).await;
Ok(proxy_binary)
}
/// Helper to track and cleanup proxy processes
struct ProxyTestTracker {
proxy_ids: Vec<String>,
binary_path: std::path::PathBuf,
}
impl ProxyTestTracker {
fn new(binary_path: std::path::PathBuf) -> Self {
Self {
proxy_ids: Vec::new(),
binary_path,
}
}
fn track_proxy(&mut self, proxy_id: String) {
self.proxy_ids.push(proxy_id);
}
async fn cleanup_all(&self) {
for proxy_id in &self.proxy_ids {
let _ =
TestUtils::execute_command(&self.binary_path, &["proxy", "stop", "--id", proxy_id]).await;
}
}
}
impl Drop for ProxyTestTracker {
fn drop(&mut self) {
let proxy_ids = self.proxy_ids.clone();
let binary_path = self.binary_path.clone();
tokio::spawn(async move {
for proxy_id in &proxy_ids {
let _ =
TestUtils::execute_command(&binary_path, &["proxy", "stop", "--id", proxy_id]).await;
}
});
}
}
/// Test starting a local proxy without upstream proxy (DIRECT)
#[tokio::test]
#[serial]
async fn test_local_proxy_direct() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
println!("Starting local proxy without upstream (DIRECT)...");
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!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
let local_url = config["localUrl"].as_str().unwrap();
let upstream_url = config["upstreamUrl"].as_str().unwrap();
tracker.track_proxy(proxy_id.clone());
println!(
"Proxy started: id={}, port={}, url={}, upstream={}",
proxy_id, local_port, local_url, upstream_url
);
// Verify proxy is listening
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy is listening on port {local_port}");
}
Err(e) => {
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
}
}
// Test making 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";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
if response_str.contains("200 OK") || response_str.contains("origin") {
println!("Proxy successfully forwarded HTTP request");
} else {
println!(
"Warning: Proxy response may be unexpected: {}",
&response_str[..response_str.len().min(200)]
);
}
// Cleanup
tracker.cleanup_all().await;
Ok(())
}
/// Test chaining local proxies (local proxy -> local proxy -> internet)
#[tokio::test]
#[serial]
async fn test_chained_local_proxies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
println!("Testing chained local proxies...");
// Start first proxy (DIRECT - connects to internet)
let output1 = TestUtils::execute_command(&binary_path, &["proxy", "start"]).await?;
if !output1.status.success() {
let stderr = String::from_utf8_lossy(&output1.stderr);
let stdout = String::from_utf8_lossy(&output1.stdout);
return Err(format!("Failed to start first proxy - stdout: {stdout}, stderr: {stderr}").into());
}
let config1: Value = serde_json::from_str(&String::from_utf8(output1.stdout)?)?;
let proxy1_id = config1["id"].as_str().unwrap().to_string();
let proxy1_port = config1["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy1_id.clone());
println!("First proxy started on port {}", proxy1_port);
// Wait for first proxy to be ready
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", proxy1_port)).await {
Ok(_) => println!("First proxy is ready"),
Err(e) => return Err(format!("First proxy not ready: {e}").into()),
}
// Start second proxy chained to first proxy
let output2 = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&proxy1_port.to_string(),
"--type",
"http",
],
)
.await?;
if !output2.status.success() {
let stderr = String::from_utf8_lossy(&output2.stderr);
let stdout = String::from_utf8_lossy(&output2.stdout);
return Err(
format!("Failed to start second proxy - stdout: {stdout}, stderr: {stderr}").into(),
);
}
let config2: Value = serde_json::from_str(&String::from_utf8(output2.stdout)?)?;
let proxy2_id = config2["id"].as_str().unwrap().to_string();
let proxy2_port = config2["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy2_id.clone());
println!(
"Second proxy started on port {} (chained to proxy on port {})",
proxy2_port, proxy1_port
);
// Wait for second proxy to be ready
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", proxy2_port)).await {
Ok(_) => println!("Second proxy is ready"),
Err(e) => return Err(format!("Second proxy not ready: {e}").into()),
}
// Test making an HTTP request through the chained proxy
let mut stream = TcpStream::connect(("127.0.0.1", proxy2_port)).await?;
let request =
b"GET http://httpbin.org/ip HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
if response_str.contains("200 OK") || response_str.contains("origin") {
println!("Chained proxy successfully forwarded HTTP request");
} else {
println!(
"Warning: Chained proxy response may be unexpected: {}",
&response_str[..response_str.len().min(200)]
);
}
// Cleanup
tracker.cleanup_all().await;
Ok(())
}
/// Test starting a local proxy with HTTP upstream proxy
#[tokio::test]
#[serial]
async fn test_local_proxy_with_http_upstream(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// Start a mock HTTP upstream proxy server
let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let upstream_addr = upstream_listener.local_addr()?;
let upstream_port = upstream_addr.port();
let upstream_handle = tokio::spawn(async move {
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Response, StatusCode};
use hyper_util::rt::TokioIo;
while let Ok((stream, _)) = upstream_listener.accept().await {
let io = TokioIo::new(stream);
tokio::task::spawn(async move {
let service = service_fn(|_req| async {
Ok::<_, hyper::Error>(
Response::builder()
.status(StatusCode::OK)
.body(Full::new(Bytes::from("Upstream Proxy Response")))
.unwrap(),
)
});
let _ = http1::Builder::new().serve_connection(io, service).await;
});
}
});
sleep(Duration::from_millis(200)).await;
println!("Starting local proxy with HTTP upstream proxy...");
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&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: id={}, port={}", proxy_id, local_port);
// Verify proxy is listening
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy is listening on port {local_port}");
}
Err(e) => {
upstream_handle.abort();
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
}
}
// Cleanup
tracker.cleanup_all().await;
upstream_handle.abort();
Ok(())
}
/// Test multiple proxies running simultaneously
#[tokio::test]
#[serial]
async fn test_multiple_proxies_simultaneously(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
println!("Starting multiple proxies simultaneously...");
let mut proxy_ports = Vec::new();
// 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() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(
format!(
"Failed to start proxy {} - stdout: {}, stderr: {}",
i + 1,
stdout,
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);
proxy_ports.push(local_port);
println!("Proxy {} started on port {}", i + 1, local_port);
// 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(),
);
}
}
}
}
}
// Verify all proxies are still listening
for (i, port) in proxy_ports.iter().enumerate() {
match TcpStream::connect(("127.0.0.1", *port)).await {
Ok(_) => {
println!("Proxy {} is listening on port {}", i + 1, port);
}
Err(e) => {
return Err(format!("Proxy {} on port {} is not listening: {e}", i + 1, port).into());
}
}
}
// Cleanup
tracker.cleanup_all().await;
Ok(())
}
/// Test proxy listing
#[tokio::test]
#[serial]
async fn test_proxy_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// 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();
tracker.track_proxy(proxy_id.clone());
// List proxies
let list_output = TestUtils::execute_command(&binary_path, &["proxy", "list"]).await?;
if !list_output.status.success() {
return Err("Failed to list proxies".into());
}
let list_stdout = String::from_utf8(list_output.stdout)?;
let proxies: Vec<Value> = serde_json::from_str(&list_stdout)?;
// Verify our proxy is in the list
let found = proxies.iter().any(|p| p["id"].as_str() == Some(&proxy_id));
assert!(found, "Proxy should be in the list");
// Cleanup
tracker.cleanup_all().await;
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]
async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let _tracker = ProxyTestTracker::new(binary_path.clone());
// 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;
// Verify proxy is running
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => println!("Proxy is running"),
Err(_) => return Err("Proxy is not running".into()),
}
// Stop the proxy
let stop_output =
TestUtils::execute_command(&binary_path, &["proxy", "stop", "--id", &proxy_id]).await?;
if !stop_output.status.success() {
return Err("Failed to stop proxy".into());
}
// Wait a bit for the process to exit
sleep(Duration::from_millis(500)).await;
// Verify proxy is stopped (connection should fail)
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => return Err("Proxy should be stopped but is still listening".into()),
Err(_) => println!("Proxy successfully stopped"),
}
Ok(())
}
-999
View File
@@ -1,999 +0,0 @@
mod common;
use common::TestUtils;
use serde_json::Value;
/// Setup function to ensure clean state before tests
async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = TestUtils::ensure_nodecar_binary().await?;
// Only clean up test-specific processes, not all processes
// This prevents interfering with actual app usage during testing
println!("Setting up test environment...");
Ok(nodecar_path)
}
/// Helper to track and cleanup specific test resources
struct TestResourceTracker {
proxy_ids: Vec<String>,
camoufox_ids: Vec<String>,
nodecar_path: std::path::PathBuf,
}
impl TestResourceTracker {
fn new(nodecar_path: std::path::PathBuf) -> Self {
Self {
proxy_ids: Vec::new(),
camoufox_ids: Vec::new(),
nodecar_path,
}
}
fn track_proxy(&mut self, proxy_id: String) {
self.proxy_ids.push(proxy_id);
}
fn track_camoufox(&mut self, camoufox_id: String) {
self.camoufox_ids.push(camoufox_id);
}
async fn cleanup_all(&self) {
// Use targeted cleanup to only stop test-specific processes
let _ = TestUtils::cleanup_specific_processes(
&self.nodecar_path,
&self.proxy_ids,
&self.camoufox_ids,
)
.await;
}
}
impl Drop for TestResourceTracker {
fn drop(&mut self) {
// Ensure cleanup happens even if test panics
let proxy_ids = self.proxy_ids.clone();
let camoufox_ids = self.camoufox_ids.clone();
let nodecar_path = self.nodecar_path.clone();
tokio::spawn(async move {
let _ = TestUtils::cleanup_specific_processes(&nodecar_path, &proxy_ids, &camoufox_ids).await;
});
}
}
/// Integration tests for nodecar proxy functionality
#[tokio::test]
async fn test_nodecar_proxy_lifecycle() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
// Test proxy start with a known working upstream
let args = [
"proxy",
"start",
"--host",
"httpbin.org",
"--proxy-port",
"80",
"--type",
"http",
];
println!("Starting proxy with nodecar...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
tracker.cleanup_all().await;
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
// Verify proxy configuration structure
assert!(config["id"].is_string(), "Proxy ID should be a string");
assert!(
config["localPort"].is_number(),
"Local port should be a number"
);
assert!(
config["localUrl"].is_string(),
"Local URL should be a string"
);
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 with ID: {proxy_id} on port: {local_port}");
// Wait for the proxy to start listening
let is_listening = TestUtils::wait_for_port_state(local_port, true, 10).await;
assert!(
is_listening,
"Proxy should be listening on the assigned port"
);
// Test stopping the proxy
let stop_args = ["proxy", "stop", "--id", &proxy_id];
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
assert!(stop_output.status.success(), "Proxy stop should succeed");
let port_available = TestUtils::wait_for_port_state(local_port, false, 5).await;
assert!(
port_available,
"Port should be available after stopping proxy"
);
tracker.cleanup_all().await;
Ok(())
}
/// Test proxy with authentication
#[tokio::test]
async fn test_nodecar_proxy_with_auth() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
let args = [
"proxy",
"start",
"--host",
"httpbin.org",
"--proxy-port",
"80",
"--type",
"http",
"--username",
"testuser",
"--password",
"testpass",
];
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
tracker.track_proxy(proxy_id.clone());
// Verify upstream URL contains encoded credentials
if let Some(upstream_url) = config["upstreamUrl"].as_str() {
assert!(
upstream_url.contains("testuser"),
"Upstream URL should contain username"
);
// Password might be encoded, so we check for the presence of auth info
assert!(
upstream_url.contains("@"),
"Upstream URL should contain auth separator"
);
}
}
tracker.cleanup_all().await;
Ok(())
}
/// Test proxy list functionality
#[tokio::test]
async fn test_nodecar_proxy_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
// Start a proxy first
let start_args = [
"proxy",
"start",
"--host",
"httpbin.org",
"--proxy-port",
"80",
"--type",
"http",
];
let start_output = TestUtils::execute_nodecar_command(&nodecar_path, &start_args).await?;
if start_output.status.success() {
let stdout = String::from_utf8(start_output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
tracker.track_proxy(proxy_id.clone());
// Test list command
let list_args = ["proxy", "list"];
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
assert!(list_output.status.success(), "Proxy list should succeed");
let list_stdout = String::from_utf8(list_output.stdout)?;
let proxy_list: Value = serde_json::from_str(&list_stdout)?;
assert!(proxy_list.is_array(), "Proxy list should be an array");
let proxies = proxy_list.as_array().unwrap();
assert!(
!proxies.is_empty(),
"Should have at least one proxy in the list"
);
// Find our proxy in the list
let found_proxy = proxies.iter().find(|p| p["id"].as_str() == Some(&proxy_id));
assert!(found_proxy.is_some(), "Started proxy should be in the list");
}
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox functionality
#[tokio::test]
async fn test_nodecar_camoufox_lifecycle() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
let temp_dir = TestUtils::create_temp_dir()?;
let profile_path = temp_dir.path().join("test_profile");
let args = [
"camoufox",
"start",
"--profile-path",
profile_path.to_str().unwrap(),
"--headless",
];
println!("Starting Camoufox with nodecar...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// If Camoufox is not installed or times out, skip the test
if stderr.contains("not installed")
|| stderr.contains("not found")
|| stderr.contains("timeout")
|| stdout.contains("timeout")
{
println!("Skipping Camoufox test - Camoufox not available or timed out");
tracker.cleanup_all().await;
return Ok(());
}
tracker.cleanup_all().await;
return Err(format!("Camoufox start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
// Verify Camoufox configuration structure
assert!(config["id"].is_string(), "Camoufox ID should be a string");
let camoufox_id = config["id"].as_str().unwrap().to_string();
tracker.track_camoufox(camoufox_id.clone());
println!("Camoufox started with ID: {camoufox_id}");
// Test stopping Camoufox
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
assert!(stop_output.status.success(), "Camoufox stop should succeed");
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox with URL opening
#[tokio::test]
async fn test_nodecar_camoufox_with_url() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
let temp_dir = TestUtils::create_temp_dir()?;
let profile_path = temp_dir.path().join("test_profile_url");
let args = [
"camoufox",
"start",
"--profile-path",
profile_path.to_str().unwrap(),
"--url",
"https://httpbin.org/get",
"--headless",
];
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let camoufox_id = config["id"].as_str().unwrap().to_string();
tracker.track_camoufox(camoufox_id.clone());
// Verify URL is set
if let Some(url) = config["url"].as_str() {
assert_eq!(
url, "https://httpbin.org/get",
"URL should match what was provided"
);
}
// Test stopping Camoufox explicitly
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
assert!(stop_output.status.success(), "Camoufox stop should succeed");
} else {
println!("Skipping Camoufox URL test - likely not installed");
tracker.cleanup_all().await;
return Ok(());
}
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox list functionality
#[tokio::test]
async fn test_nodecar_camoufox_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let tracker = TestResourceTracker::new(nodecar_path.clone());
// Test list command (should work even without Camoufox installed)
let list_args = ["camoufox", "list"];
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
assert!(list_output.status.success(), "Camoufox list should succeed");
let list_stdout = String::from_utf8(list_output.stdout)?;
let camoufox_list: Value = serde_json::from_str(&list_stdout)?;
assert!(camoufox_list.is_array(), "Camoufox list should be an array");
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox process tracking and management
#[tokio::test]
async fn test_nodecar_camoufox_process_tracking(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
let temp_dir = TestUtils::create_temp_dir()?;
let profile_path = temp_dir.path().join("test_profile_tracking");
// Start multiple Camoufox instances
let mut instance_ids: Vec<String> = Vec::new();
for i in 0..2 {
let instance_profile_path = format!("{}_instance_{}", profile_path.to_str().unwrap(), i);
let args = [
"camoufox",
"start",
"--profile-path",
&instance_profile_path,
"--headless",
];
println!("Starting Camoufox instance {i}...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// If Camoufox is not installed, skip the test
if stderr.contains("not installed") || stderr.contains("not found") {
println!("Skipping Camoufox process tracking test - Camoufox not installed");
tracker.cleanup_all().await;
return Ok(());
}
tracker.cleanup_all().await;
return Err(
format!("Camoufox instance {i} start failed - stdout: {stdout}, stderr: {stderr}").into(),
);
}
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let camoufox_id = config["id"].as_str().unwrap().to_string();
instance_ids.push(camoufox_id.clone());
tracker.track_camoufox(camoufox_id.clone());
println!("Camoufox instance {i} started with ID: {camoufox_id}");
}
// Verify all instances are tracked
let list_args = ["camoufox", "list"];
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
assert!(list_output.status.success(), "Camoufox list should succeed");
let list_stdout = String::from_utf8(list_output.stdout)?;
println!("Camoufox list output: {list_stdout}");
let instances: Value = serde_json::from_str(&list_stdout)?;
let instances_array = instances.as_array().unwrap();
println!("Found {} instances in list", instances_array.len());
// Verify our instances are in the list
for instance_id in &instance_ids {
let instance_found = instances_array
.iter()
.any(|i| i["id"].as_str() == Some(instance_id));
if !instance_found {
println!("Instance {instance_id} not found in list. Available instances:");
for instance in instances_array {
if let Some(id) = instance["id"].as_str() {
println!(" - {id}");
}
}
}
assert!(
instance_found,
"Camoufox instance {instance_id} should be found in list"
);
}
// Stop all instances individually
for instance_id in &instance_ids {
println!("Stopping Camoufox instance: {instance_id}");
let stop_args = ["camoufox", "stop", "--id", instance_id];
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
if stop_output.status.success() {
let stop_stdout = String::from_utf8(stop_output.stdout)?;
if let Ok(stop_result) = serde_json::from_str::<Value>(&stop_stdout) {
let success = stop_result["success"].as_bool().unwrap_or(false);
if !success {
println!("Warning: Stop command returned success=false for instance {instance_id}");
}
} else {
println!("Warning: Could not parse stop result for instance {instance_id}");
}
} else {
println!("Warning: Stop command failed for instance {instance_id}");
}
}
// Verify all instances are removed
let list_output_after = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
let instances_after: Value = serde_json::from_str(&String::from_utf8(list_output_after.stdout)?)?;
let instances_after_array = instances_after.as_array().unwrap();
for instance_id in &instance_ids {
let instance_still_exists = instances_after_array
.iter()
.any(|i| i["id"].as_str() == Some(instance_id));
assert!(
!instance_still_exists,
"Stopped Camoufox instance {instance_id} should not be found in list"
);
}
println!("Camoufox process tracking test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox with various configuration options
#[tokio::test]
async fn test_nodecar_camoufox_configuration_options(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
let temp_dir = TestUtils::create_temp_dir()?;
let profile_path = temp_dir.path().join("test_profile_config");
let args = [
"camoufox",
"start",
"--profile-path",
profile_path.to_str().unwrap(),
"--block-images",
"--max-width",
"1920",
"--max-height",
"1080",
"--headless",
];
println!("Starting Camoufox with configuration options...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// If Camoufox is not installed, skip the test
if stderr.contains("not installed") || stderr.contains("not found") {
println!("Skipping Camoufox configuration test - Camoufox not installed");
tracker.cleanup_all().await;
return Ok(());
}
tracker.cleanup_all().await;
return Err(
format!("Camoufox with config start failed - stdout: {stdout}, stderr: {stderr}").into(),
);
}
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let camoufox_id = config["id"].as_str().unwrap().to_string();
tracker.track_camoufox(camoufox_id.clone());
println!("Camoufox with configuration started with ID: {camoufox_id}");
// Verify configuration was applied by checking the profile path
if let Some(returned_profile_path) = config["profilePath"].as_str() {
assert!(
returned_profile_path.contains("test_profile_config"),
"Profile path should match what was provided"
);
}
// Test stopping Camoufox explicitly
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
assert!(stop_output.status.success(), "Camoufox stop should succeed");
println!("Camoufox configuration test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox generate-config command with basic options
#[ignore = "CI is rate limited for camoufox download"]
#[tokio::test]
async fn test_nodecar_camoufox_generate_config_basic(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let tracker = TestResourceTracker::new(nodecar_path.clone());
let args = [
"camoufox",
"generate-config",
"--max-width",
"1920",
"--max-height",
"1080",
"--block-images",
];
println!("Testing Camoufox config generation with basic options...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
tracker.cleanup_all().await;
return Err(
format!("Camoufox generate-config failed - stdout: {stdout}, stderr: {stderr}").into(),
);
}
let stdout = String::from_utf8(output.stdout)?;
println!("Generated config output: {stdout}");
// Parse the generated config as JSON
let config: Value = serde_json::from_str(&stdout)?;
// Verify the config contains expected properties
assert!(
config.is_object(),
"Generated config should be a JSON object"
);
// Check for some expected fingerprint properties
assert!(
config.get("screen.width").is_some(),
"Config should contain screen.width"
);
assert!(
config.get("screen.height").is_some(),
"Config should contain screen.height"
);
assert!(
config.get("navigator.userAgent").is_some(),
"Config should contain navigator.userAgent"
);
println!("Camoufox generate-config basic test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox generate-config command with custom fingerprint
#[ignore = "CI is rate limited for camoufox download"]
#[tokio::test]
async fn test_nodecar_camoufox_generate_config_custom_fingerprint(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let tracker = TestResourceTracker::new(nodecar_path.clone());
// Create a custom fingerprint JSON
let custom_fingerprint = r#"{
"screen.width": 1440,
"screen.height": 900,
"navigator.userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/140.0",
"navigator.platform": "TestPlatform",
"timezone": "America/New_York",
"locale:language": "en",
"locale:region": "US"
}"#;
let args = [
"camoufox",
"generate-config",
"--fingerprint",
custom_fingerprint,
"--block-webrtc",
];
println!("Testing Camoufox config generation with custom fingerprint...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
tracker.cleanup_all().await;
return Err(
format!("Camoufox generate-config with custom fingerprint failed - stdout: {stdout}, stderr: {stderr}").into(),
);
}
let stdout = String::from_utf8(output.stdout)?;
// Parse the generated config as JSON
let config: Value = serde_json::from_str(&stdout)?;
// Verify the config contains expected properties
assert!(
config.is_object(),
"Generated config should be a JSON object"
);
// Check that our custom values are preserved
assert_eq!(
config.get("screen.width").and_then(|v| v.as_u64()),
Some(1440),
"Custom screen width should be preserved"
);
assert_eq!(
config.get("screen.height").and_then(|v| v.as_u64()),
Some(900),
"Custom screen height should be preserved"
);
assert_eq!(
config.get("navigator.userAgent").and_then(|v| v.as_str()),
Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/140.0"),
"Custom user agent should be preserved"
);
assert_eq!(
config.get("timezone").and_then(|v| v.as_str()),
Some("America/New_York"),
"Custom timezone should be preserved"
);
println!("Camoufox generate-config custom fingerprint test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
/// Test nodecar command validation
#[tokio::test]
async fn test_nodecar_command_validation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let tracker = TestResourceTracker::new(nodecar_path.clone());
// Test invalid command
let invalid_args = ["invalid", "command"];
let output = TestUtils::execute_nodecar_command(&nodecar_path, &invalid_args).await?;
assert!(!output.status.success(), "Invalid command should fail");
tracker.cleanup_all().await;
Ok(())
}
/// Test concurrent proxy operations
#[tokio::test]
async fn test_nodecar_concurrent_proxies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
// Start multiple proxies concurrently
let mut handles = vec![];
for i in 0..3 {
let nodecar_path_clone = nodecar_path.clone();
let handle = tokio::spawn(async move {
let args = [
"proxy",
"start",
"--host",
"httpbin.org",
"--proxy-port",
"80",
"--type",
"http",
];
TestUtils::execute_nodecar_command(&nodecar_path_clone, &args).await
});
handles.push((i, handle));
}
// Wait for all proxies to start
for (i, handle) in handles {
match handle.await.map_err(|e| format!("Join error: {e}"))? {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
tracker.track_proxy(proxy_id.clone());
println!("Proxy {i} started successfully");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Proxy {i} failed to start: {stderr}");
}
Err(e) => {
println!("Proxy {i} error: {e}");
}
}
}
tracker.cleanup_all().await;
Ok(())
}
/// Test proxy with different upstream types
#[tokio::test]
async fn test_nodecar_proxy_types() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
let test_cases = vec![
("http", "httpbin.org", "80"),
("https", "httpbin.org", "443"),
];
for (proxy_type, host, port) in test_cases {
println!("Testing {proxy_type} proxy to {host}:{port}");
let args = [
"proxy",
"start",
"--host",
host,
"--proxy-port",
port,
"--type",
proxy_type,
];
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
tracker.track_proxy(proxy_id.clone());
println!("{proxy_type} proxy test passed");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("{proxy_type} proxy test failed: {stderr}");
}
}
tracker.cleanup_all().await;
Ok(())
}
/// Test direct proxy (no upstream) functionality
#[tokio::test]
async fn test_nodecar_direct_proxy() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
// Test starting a direct proxy (no upstream)
let args = ["proxy", "start"];
println!("Starting direct proxy with nodecar...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
tracker.cleanup_all().await;
return Err(format!("Direct proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let stdout = String::from_utf8(output.stdout)?;
let config: Value = serde_json::from_str(&stdout)?;
// Verify proxy configuration structure
assert!(config["id"].is_string(), "Proxy ID should be a string");
assert!(
config["localPort"].is_number(),
"Local port should be a number"
);
assert!(
config["localUrl"].is_string(),
"Local URL should be a string"
);
assert_eq!(
config["upstreamUrl"].as_str().unwrap(),
"DIRECT",
"Upstream URL should be DIRECT"
);
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!("Direct proxy started with ID: {proxy_id} on port: {local_port}");
// Wait for the proxy to start listening
let is_listening = TestUtils::wait_for_port_state(local_port, true, 10).await;
assert!(
is_listening,
"Direct proxy should be listening on the assigned port"
);
// Test stopping the proxy
let stop_args = ["proxy", "stop", "--id", &proxy_id];
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
assert!(
stop_output.status.success(),
"Direct proxy stop should succeed"
);
let port_available = TestUtils::wait_for_port_state(local_port, false, 5).await;
assert!(
port_available,
"Port should be available after stopping direct proxy"
);
println!("Direct proxy test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
/// Test SOCKS5 proxy chaining - create two proxies where the second uses the first as upstream
#[tokio::test]
async fn test_nodecar_socks5_proxy_chaining() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
let nodecar_path = setup_test().await?;
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
// Step 1: Start a SOCKS5 proxy with a known working upstream (httpbin.org)
let socks5_args = [
"proxy",
"start",
"--host",
"httpbin.org",
"--proxy-port",
"80",
"--type",
"http", // Use HTTP upstream for the first proxy
];
println!("Starting first proxy with HTTP upstream...");
let socks5_output = TestUtils::execute_nodecar_command(&nodecar_path, &socks5_args).await?;
if !socks5_output.status.success() {
let stderr = String::from_utf8_lossy(&socks5_output.stderr);
let stdout = String::from_utf8_lossy(&socks5_output.stdout);
tracker.cleanup_all().await;
return Err(format!("First proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let socks5_stdout = String::from_utf8(socks5_output.stdout)?;
let socks5_config: Value = serde_json::from_str(&socks5_stdout)?;
let socks5_proxy_id = socks5_config["id"].as_str().unwrap().to_string();
let socks5_local_port = socks5_config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(socks5_proxy_id.clone());
println!("First proxy started with ID: {socks5_proxy_id} on port: {socks5_local_port}");
// Step 2: Start a second proxy that uses the first proxy as upstream
let http_proxy_args = [
"proxy",
"start",
"--upstream",
&format!("http://127.0.0.1:{socks5_local_port}"),
];
println!("Starting second proxy with first proxy as upstream...");
let http_output = TestUtils::execute_nodecar_command(&nodecar_path, &http_proxy_args).await?;
if !http_output.status.success() {
let stderr = String::from_utf8_lossy(&http_output.stderr);
let stdout = String::from_utf8_lossy(&http_output.stdout);
tracker.cleanup_all().await;
return Err(
format!("Second proxy with chained upstream failed - stdout: {stdout}, stderr: {stderr}")
.into(),
);
}
let http_stdout = String::from_utf8(http_output.stdout)?;
let http_config: Value = serde_json::from_str(&http_stdout)?;
let http_proxy_id = http_config["id"].as_str().unwrap().to_string();
let http_local_port = http_config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(http_proxy_id.clone());
println!(
"Second proxy started with ID: {http_proxy_id} on port: {http_local_port} (chained through first proxy)"
);
// Verify both proxies are listening by waiting for them to be occupied
let socks5_listening = TestUtils::wait_for_port_state(socks5_local_port, true, 5).await;
let http_listening = TestUtils::wait_for_port_state(http_local_port, true, 5).await;
assert!(
socks5_listening,
"First proxy should be listening on port {socks5_local_port}"
);
assert!(
http_listening,
"Second proxy should be listening on port {http_local_port}"
);
// Clean up both proxies
let stop_http_args = ["proxy", "stop", "--id", &http_proxy_id];
let stop_socks5_args = ["proxy", "stop", "--id", &socks5_proxy_id];
let http_stop_result = TestUtils::execute_nodecar_command(&nodecar_path, &stop_http_args).await;
let socks5_stop_result =
TestUtils::execute_nodecar_command(&nodecar_path, &stop_socks5_args).await;
// Verify cleanup
assert!(
http_stop_result.is_ok() && http_stop_result.unwrap().status.success(),
"Second proxy stop should succeed"
);
assert!(
socks5_stop_result.is_ok() && socks5_stop_result.unwrap().status.success(),
"First proxy stop should succeed"
);
let http_port_available = TestUtils::wait_for_port_state(http_local_port, false, 5).await;
let socks5_port_available = TestUtils::wait_for_port_state(socks5_local_port, false, 5).await;
assert!(
http_port_available,
"Second proxy port should be available after stopping"
);
assert!(
socks5_port_available,
"First proxy port should be available after stopping"
);
println!("Proxy chaining test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
+7
View File
@@ -1,10 +1,13 @@
"use client";
import { Geist, Geist_Mono } from "next/font/google";
import "@/styles/globals.css";
import "flag-icons/css/flag-icons.min.css";
import { useEffect } from "react";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WindowDragArea } from "@/components/window-drag-area";
import { setupLogging } from "@/lib/logger";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -21,6 +24,10 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
useEffect(() => {
void setupLogging();
}, []);
return (
<html lang="en" suppressHydrationWarning>
<body
+55 -16
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)))
@@ -694,13 +717,10 @@ export default function Home() {
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-background">
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
<main className="flex flex-col items-center w-full max-w-3xl">
<div className="w-full">
<HomeHeader
selectedProfiles={selectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
onImportProfileDialogOpen={setImportProfileDialogOpen}
@@ -710,7 +730,7 @@ export default function Home() {
onSearchQueryChange={setSearchQuery}
/>
</div>
<div className="space-y-4 w-full">
<div className="w-full mt-2.5">
<GroupBadges
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
@@ -731,12 +751,15 @@ export default function Home() {
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
/>
</div>
</main>
{isInitializing && (
<div className="fixed inset-0 z-[1000] backdrop-blur-sm bg-background/30 flex items-center justify-center">
<div className="fixed inset-0 z-1000 backdrop-blur-sm bg-background/30 flex items-center justify-center">
<div className="bg-background rounded-xl p-6 shadow-xl border border-border/10 w-[320px] text-center">
<div className="text-lg font-medium">Initializing</div>
<div className="mt-1 mb-2 text-sm text-gray-600 dark:text-gray-300">
@@ -808,6 +831,11 @@ export default function Home() {
}}
profile={currentProfileForCamoufoxConfig}
onSave={handleSaveCamoufoxConfig}
isRunning={
currentProfileForCamoufoxConfig
? runningProfiles.has(currentProfileForCamoufoxConfig.id)
: false
}
/>
<GroupManagementDialog
@@ -828,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)}
+35 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { FaDownload, FaTimes } from "react-icons/fa";
import { FaDownload, FaExternalLinkAlt, FaTimes } from "react-icons/fa";
import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -60,6 +60,16 @@ export function AppUpdateToast({
await onUpdate(updateInfo);
};
const handleViewRelease = () => {
if (updateInfo.release_page_url) {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
detail: updateInfo.release_page_url,
});
window.dispatchEvent(event);
}
};
const showDownloadProgress =
isUpdating &&
updateProgress?.stage === "downloading" &&
@@ -101,6 +111,11 @@ export function AppUpdateToast({
<>
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">{updateInfo.new_version}</span>
{updateInfo.manual_update_required && (
<span className="block mt-1 text-muted-foreground/80">
Manual download required on Linux
</span>
)}
</>
)}
</div>
@@ -155,14 +170,25 @@ export function AppUpdateToast({
{!isUpdating && (
<div className="flex gap-2 items-center mt-3">
<RippleButton
onClick={() => void handleUpdateClick()}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="w-3 h-3" />
Update Now
</RippleButton>
{updateInfo.manual_update_required ? (
<RippleButton
onClick={handleViewRelease}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
View Release
</RippleButton>
) : (
<RippleButton
onClick={() => void handleUpdateClick()}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="w-3 h-3" />
Update Now
</RippleButton>
)}
<RippleButton
variant="outline"
onClick={onDismiss}
+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 (
+176
View File
@@ -0,0 +1,176 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { LuX } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface DataTableActionBarProps<TData>
extends React.ComponentProps<typeof motion.div> {
table: Table<TData>;
visible?: boolean;
portalContainer?: Element | DocumentFragment | null;
}
function DataTableActionBar<TData>({
table,
visible: visibleProp,
portalContainer: portalContainerProp,
children,
className,
...props
}: DataTableActionBarProps<TData>) {
const [mounted, setMounted] = React.useState(false);
React.useLayoutEffect(() => {
setMounted(true);
}, []);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
table.toggleAllRowsSelected(false);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [table]);
const portalContainer =
portalContainerProp ?? (mounted ? globalThis.document?.body : null);
if (!portalContainer) return null;
const visible =
visibleProp ?? table.getFilteredSelectedRowModel().rows.length > 0;
return ReactDOM.createPortal(
<AnimatePresence>
{visible && (
<motion.div
role="toolbar"
aria-orientation="horizontal"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn(
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
className,
)}
{...props}
>
{children}
</motion.div>
)}
</AnimatePresence>,
portalContainer,
);
}
interface DataTableActionBarActionProps
extends React.ComponentProps<typeof Button> {
tooltip?: string;
isPending?: boolean;
}
function DataTableActionBarAction({
size = "sm",
tooltip,
isPending,
disabled,
className,
children,
...props
}: DataTableActionBarActionProps) {
const trigger = (
<Button
variant="secondary"
size={size}
className={cn(
"gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5",
size === "icon" ? "size-7" : "h-7",
className,
)}
disabled={disabled || isPending}
{...props}
>
{isPending ? (
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" />
) : (
children
)}
</Button>
);
if (!tooltip) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
);
}
interface DataTableActionBarSelectionProps<TData> {
table: Table<TData>;
}
function DataTableActionBarSelection<TData>({
table,
}: DataTableActionBarSelectionProps<TData>) {
const onClearSelection = React.useCallback(() => {
table.toggleAllRowsSelected(false);
}, [table]);
return (
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
<span className="whitespace-nowrap text-xs">
{table.getFilteredSelectedRowModel().rows.length} selected
</span>
<div className="mr-1 ml-2 h-4 w-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-5"
onClick={onClearSelection}
>
<LuX className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
>
<p>Clear selection</p>
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
<abbr title="Escape" className="no-underline">
Esc
</abbr>
</kbd>
</TooltipContent>
</Tooltip>
</div>
);
}
export {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
};
+25
View File
@@ -0,0 +1,25 @@
import { getFlagIconClass } from "@/lib/flag-utils";
import { cn } from "@/lib/utils";
interface FlagIconProps {
countryCode?: string;
className?: string;
squared?: boolean;
}
export function FlagIcon({
countryCode,
className,
squared = false,
}: FlagIconProps) {
if (!countryCode) {
return null;
}
const flagClass = getFlagIconClass(countryCode);
if (!flagClass) {
return null;
}
return <span className={cn(flagClass, squared && "fis", className)} />;
}
+161 -17
View File
@@ -1,5 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import type { GroupWithCount } from "@/types";
@@ -17,9 +18,124 @@ export function GroupBadges({
groups,
isLoading,
}: GroupBadgesProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftFade, setShowLeftFade] = useState(false);
const [showRightFade, setShowRightFade] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null);
const hasMovedRef = useRef(false);
const clickBlockedRef = useRef(false);
const checkScrollPosition = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollLeft, scrollWidth, clientWidth } = container;
setShowLeftFade(scrollLeft > 0);
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const container = scrollContainerRef.current;
if (!container) return;
e.preventDefault();
dragStartRef.current = {
x: e.clientX,
scrollLeft: container.scrollLeft,
};
hasMovedRef.current = false;
setIsDragging(true);
container.style.cursor = "grabbing";
container.style.userSelect = "none";
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !dragStartRef.current) return;
const container = scrollContainerRef.current;
if (!container) return;
const deltaX = e.clientX - dragStartRef.current.x;
const distance = Math.abs(deltaX);
if (distance > 5) {
hasMovedRef.current = true;
}
container.scrollLeft = dragStartRef.current.scrollLeft - deltaX;
checkScrollPosition();
},
[isDragging, checkScrollPosition],
);
const handleMouseUp = useCallback(() => {
if (!isDragging) return;
const container = scrollContainerRef.current;
if (container) {
container.style.cursor = "";
container.style.userSelect = "";
}
clickBlockedRef.current = hasMovedRef.current;
setIsDragging(false);
dragStartRef.current = null;
setTimeout(() => {
hasMovedRef.current = false;
clickBlockedRef.current = false;
}, 100);
}, [isDragging]);
useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, [checkScrollPosition]);
useEffect(() => {
if (groups.length === 0) {
setShowLeftFade(false);
setShowRightFade(false);
return;
}
const container = scrollContainerRef.current;
if (!container) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
checkScrollPosition();
});
});
}, [groups, checkScrollPosition]);
if (isLoading && !groups.length) {
return (
<div className="flex flex-wrap gap-2 mb-4">
<div className="flex gap-2 mb-4">
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
Loading groups...
</div>
@@ -28,22 +144,50 @@ export function GroupBadges({
}
return (
<div className="flex flex-wrap gap-2 mb-4">
{groups.map((group) => (
<Badge
key={group.id}
variant={selectedGroupId === group.id ? "default" : "secondary"}
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80"
onClick={() => {
onGroupSelect(selectedGroupId === group.id ? "default" : group.id);
}}
>
<span>{group.name}</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
</Badge>
))}
<div className="relative mb-4">
{showLeftFade && (
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
)}
{showRightFade && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
)}
<div
ref={scrollContainerRef}
role="region"
aria-label="Profile groups"
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
onScroll={checkScrollPosition}
onMouseDown={handleMouseDown}
>
{groups.map((group) => (
<Badge
key={group.id}
variant={selectedGroupId === group.id ? "default" : "secondary"}
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 flex-shrink-0"
onClick={(e) => {
if (hasMovedRef.current || clickBlockedRef.current) {
e.preventDefault();
e.stopPropagation();
return;
}
onGroupSelect(
selectedGroupId === group.id ? "default" : group.id,
);
}}
onMouseDown={(e) => {
if (isDragging) {
e.preventDefault();
e.stopPropagation();
}
}}
>
<span>{group.name}</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
</Badge>
))}
</div>
</div>
);
}
+77 -50
View File
@@ -7,6 +7,7 @@ import { LuPencil, LuTrash2 } from "react-icons/lu";
import { CreateGroupDialog } from "@/components/create-group-dialog";
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
import { EditGroupDialog } from "@/components/edit-group-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -17,6 +18,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
@@ -25,7 +27,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { ProfileGroup } from "@/types";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { GroupWithCount, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface GroupManagementDialogProps {
@@ -39,7 +46,7 @@ export function GroupManagementDialog({
onClose,
onGroupManagementComplete,
}: GroupManagementDialogProps) {
const [groups, setGroups] = useState<ProfileGroup[]>([]);
const [groups, setGroups] = useState<GroupWithCount[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,13 +54,17 @@ export function GroupManagementDialog({
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<ProfileGroup | null>(null);
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>(
null,
);
const loadGroups = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const groupList = await invoke<ProfileGroup[]>("get_profile_groups");
const groupList = await invoke<GroupWithCount[]>(
"get_groups_with_profile_counts",
);
setGroups(groupList);
} catch (err) {
console.error("Failed to load groups:", err);
@@ -64,23 +75,19 @@ export function GroupManagementDialog({
}, []);
const handleGroupCreated = useCallback(
(newGroup: ProfileGroup) => {
setGroups((prev) => [...prev, newGroup]);
(_newGroup: ProfileGroup) => {
void loadGroups();
onGroupManagementComplete();
},
[onGroupManagementComplete],
[loadGroups, onGroupManagementComplete],
);
const handleGroupUpdated = useCallback(
(updatedGroup: ProfileGroup) => {
setGroups((prev) =>
prev.map((group) =>
group.id === updatedGroup.id ? updatedGroup : group,
),
);
(_updatedGroup: ProfileGroup) => {
void loadGroups();
onGroupManagementComplete();
},
[onGroupManagementComplete],
[loadGroups, onGroupManagementComplete],
);
const handleGroupDeleted = useCallback(() => {
@@ -88,12 +95,12 @@ export function GroupManagementDialog({
onGroupManagementComplete();
}, [loadGroups, onGroupManagementComplete]);
const handleEditGroup = useCallback((group: ProfileGroup) => {
const handleEditGroup = useCallback((group: GroupWithCount) => {
setSelectedGroup(group);
setEditDialogOpen(true);
}, []);
const handleDeleteGroup = useCallback((group: ProfileGroup) => {
const handleDeleteGroup = useCallback((group: GroupWithCount) => {
setSelectedGroup(group);
setDeleteDialogOpen(true);
}, []);
@@ -148,41 +155,61 @@ export function GroupManagementDialog({
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
{group.name}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
>
<LuPencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Profiles</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
{group.name}
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit group</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete group</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
+25 -47
View File
@@ -1,7 +1,7 @@
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { LuSearch, LuTrash2, LuUsers, LuX } from "react-icons/lu";
import { LuSearch, LuUsers, LuX } from "react-icons/lu";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -12,13 +12,9 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type Props = {
selectedProfiles: string[];
onBulkGroupAssignment: () => void;
onBulkDelete: () => void;
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
onGroupManagementDialogOpen: (open: boolean) => void;
@@ -29,9 +25,6 @@ type Props = {
};
const HomeHeader = ({
selectedProfiles,
onBulkGroupAssignment,
onBulkDelete,
onSettingsDialogOpen,
onProxyManagementDialogOpen,
onGroupManagementDialogOpen,
@@ -48,7 +41,7 @@ const HomeHeader = ({
window.dispatchEvent(event);
};
return (
<div className="flex justify-between items-center">
<div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center">
<button
type="button"
@@ -58,36 +51,7 @@ const HomeHeader = ({
>
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
</button>
{selectedProfiles.length > 0 ? (
<div className="flex gap-3 items-center">
<span className="text-sm font-medium">
{selectedProfiles.length} profile
{selectedProfiles.length !== 1 ? "s" : ""} selected
</span>
<div className="flex gap-2">
<RippleButton
variant="outline"
size="sm"
onClick={onBulkGroupAssignment}
className="flex gap-2 items-center"
>
<LuUsers className="w-4 h-4" />
Assign to Group
</RippleButton>
<RippleButton
variant="destructive"
size="sm"
onClick={onBulkDelete}
className="flex gap-2 items-center"
>
<LuTrash2 className="w-4 h-4" />
Delete
</RippleButton>
</div>
</div>
) : (
<CardTitle>Donut</CardTitle>
)}
<CardTitle>Donut</CardTitle>
</div>
<div className="flex gap-2 items-center">
<div className="relative">
@@ -112,13 +76,22 @@ const HomeHeader = ({
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center h-[36px]"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
<span>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center h-[36px]"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
</Tooltip>
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@@ -169,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 -7
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);
@@ -330,9 +327,6 @@ export function ImportProfileDialog({
<span className="font-medium">
{profile.name}
</span>
<span className="text-xs text-muted-foreground">
{profile.description}
</span>
</div>
</div>
</SelectItem>
-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 (
File diff suppressed because it is too large Load Diff
+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>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { FiCheck } from "react-icons/fi";
import { toast } from "sonner";
import { FlagIcon } from "@/components/flag-icon";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatRelativeTime } from "@/lib/flag-utils";
import type { ProxyCheckResult, StoredProxy } from "@/types";
interface ProxyCheckButtonProps {
proxy: StoredProxy;
profileId: string;
checkingProfileId: string | null;
cachedResult?: ProxyCheckResult;
onCheckComplete?: (result: ProxyCheckResult) => void;
onCheckFailed?: (result: ProxyCheckResult) => void;
disabled?: boolean;
setCheckingProfileId?: (id: string | null) => void;
}
export function ProxyCheckButton({
proxy,
profileId,
checkingProfileId,
cachedResult,
onCheckComplete,
onCheckFailed,
disabled = false,
setCheckingProfileId,
}: ProxyCheckButtonProps) {
const [localResult, setLocalResult] = React.useState<
ProxyCheckResult | undefined
>(cachedResult);
React.useEffect(() => {
setLocalResult(cachedResult);
}, [cachedResult]);
const handleCheck = React.useCallback(async () => {
if (checkingProfileId === profileId) return;
setCheckingProfileId?.(profileId);
try {
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: proxy.proxy_settings,
});
setLocalResult(result);
onCheckComplete?.(result);
// Show toast with location
const locationParts: string[] = [];
if (result.city) locationParts.push(result.city);
if (result.country) locationParts.push(result.country);
const location =
locationParts.length > 0 ? locationParts.join(", ") : "Unknown";
toast.success(
<div className="flex flex-col">
Your proxy location is:
<div className="flex items-center whitespace-nowrap">
{location}
{result.country_code && (
<FlagIcon
countryCode={result.country_code}
className="ml-1 text-sm"
/>
)}
</div>
</div>,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Proxy check failed: ${errorMessage}`);
// Save failed check result
const failedResult: ProxyCheckResult = {
ip: "",
city: undefined,
country: undefined,
country_code: undefined,
timestamp: Math.floor(Date.now() / 1000),
is_valid: false,
};
setLocalResult(failedResult);
onCheckFailed?.(failedResult);
} finally {
setCheckingProfileId?.(null);
}
}, [
proxy,
profileId,
checkingProfileId,
onCheckComplete,
onCheckFailed,
setCheckingProfileId,
]);
const isCurrentlyChecking = checkingProfileId === profileId;
const result = localResult;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center">
<FlagIcon countryCode={result.country_code} className="h-2.5" />
<FiCheck className="absolute bottom-[-6px] right-[-4px]" />
</span>
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isCurrentlyChecking ? (
<p>Checking proxy...</p>
) : result?.is_valid ? (
<div className="space-y-1">
<p className="flex items-center gap-1">
{result.country_code && (
<FlagIcon countryCode={result.country_code} />
)}
{[result.city, result.country].filter(Boolean).join(", ") ||
"Unknown"}
</p>
<p className="text-xs text-primary-foreground/70">
IP: {result.ip}
</p>
<p className="text-xs text-primary-foreground/70">
Checked {formatRelativeTime(result.timestamp)}
</p>
</div>
) : result && !result.is_valid ? (
<div>
<p>Proxy check failed</p>
<p className="text-xs text-primary-foreground/70">
Failed {formatRelativeTime(result.timestamp)}
</p>
</div>
) : (
<p>Check proxy validity</p>
)}
</TooltipContent>
</Tooltip>
);
}
+160 -124
View File
@@ -2,8 +2,10 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import * as React from "react";
import { useCallback, useState } from "react";
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
@@ -12,19 +14,29 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { trimName } from "@/lib/name-utils";
import type { StoredProxy } from "@/types";
import type { ProxyCheckResult, StoredProxy } from "@/types";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
interface ProxyManagementDialogProps {
@@ -40,9 +52,37 @@ export function ProxyManagementDialog({
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [checkingProxyId, setCheckingProxyId] = useState<string | null>(null);
const [proxyCheckResults, setProxyCheckResults] = useState<
Record<string, ProxyCheckResult>
>({});
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
// Load cached check results on mount and when proxies change
React.useEffect(() => {
const loadCachedResults = async () => {
const results: Record<string, ProxyCheckResult> = {};
for (const proxy of storedProxies) {
try {
const cached = await invoke<ProxyCheckResult | null>(
"get_cached_proxy_check",
{ proxyId: proxy.id },
);
if (cached) {
results[proxy.id] = cached;
}
} catch (_error) {
// Ignore errors
}
}
setProxyCheckResults(results);
};
if (storedProxies.length > 0) {
void loadCachedResults();
}
}, [storedProxies]);
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
// Open in-app confirmation dialog
setProxyToDelete(proxy);
@@ -82,139 +122,135 @@ export function ProxyManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<div className="flex gap-2 items-center">
<FiWifi className="w-5 h-5" />
<DialogTitle>Proxy Management</DialogTitle>
</div>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Proxy Management</DialogTitle>
<DialogDescription>
Manage your saved proxy configurations for reuse across profiles
</DialogDescription>
</DialogHeader>
<div className="flex flex-col flex-1 gap-4 py-4 min-h-0">
{/* Header with Create Button */}
<div className="flex flex-shrink-0 justify-between items-center">
<div>
<h3 className="text-lg font-medium">Stored Proxies</h3>
<p className="text-sm text-muted-foreground">
Manage your saved proxy configurations for reuse across
profiles
</p>
</div>
<div className="space-y-4">
{/* Create new proxy button */}
<div className="flex justify-between items-center">
<Label>Proxies</Label>
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create Proxy
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
{/* Proxy List - Scrollable */}
<div className="flex-1 min-h-0">
{isLoading && (
<div className="flex justify-center items-center py-6">
<div className="w-8 h-8 rounded-full border-b-2 animate-spin border-primary"></div>
</div>
)}
{storedProxies.length === 0 && !isLoading ? (
<div className="flex flex-col justify-center items-center h-32 text-center">
<FiWifi className="mx-auto mb-4 w-12 h-12 text-muted-foreground" />
<p className="mb-2 text-muted-foreground">
No proxies configured
</p>
<p className="mb-4 text-sm text-muted-foreground">
Create your first proxy configuration to get started
</p>
<RippleButton variant="outline" onClick={handleCreateProxy}>
<FiPlus className="mr-2 w-4 h-4" />
Create First Proxy
</RippleButton>
</div>
) : (
<ScrollArea className="h-[240px] pr-2">
<div className="space-y-2">
{storedProxies.map((proxy) => (
<div
key={proxy.id}
className="flex justify-between items-center p-1 rounded border bg-card"
>
<div className="flex-1 ml-2 min-w-0">
{proxy.name.length > 30 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block font-medium truncate text-card-foreground">
{trimName(proxy.name)}
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
</TooltipContent>
</Tooltip>
) : (
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
)}
</div>
<div className="mr-2">
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</div>
<div className="flex flex-shrink-0 gap-1 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<FiEdit2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
>
<FiTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
{/* Proxies list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading proxies...
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the button
above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
{proxy.name}
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProfileId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</div>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<RippleButton onClick={onClose}>Close</RippleButton>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
Close
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+28 -13
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{" "}
@@ -911,9 +908,27 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</li>
<li>
<code className="font-mono">
POST /profiles/{"{"}id{"}"}/run?headless=true|false
POST /profiles/{"{"}id{"}"}/run
</code>{" "}
launch with remote debugging
<span className="ml-1 text-muted-foreground">
(body: {"{"}url?, headless?{"}"})
</span>
</li>
<li>
<code className="font-mono">
POST /profiles/{"{"}id{"}"}/open-url
</code>{" "}
open URL in running profile
<span className="ml-1 text-muted-foreground">
(body: {"{"}url{"}"})
</span>
</li>
<li>
<code className="font-mono">
POST /profiles/{"{"}id{"}"}/kill
</code>{" "}
stop browser process
</li>
</ul>
</div>
@@ -1055,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>
File diff suppressed because it is too large Load Diff
+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,
};
+2 -2
View File
@@ -67,7 +67,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"px-2 h-10 font-medium text-left align-middle whitespace-nowrap text-foreground",
"px-2 h-8 font-medium text-left align-middle whitespace-nowrap text-foreground",
className,
)}
{...props}
@@ -79,7 +79,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn("p-2 align-middle whitespace-nowrap", className)}
className={cn("px-2 py-1 align-middle whitespace-nowrap", className)}
{...props}
/>
);
+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,
};

Some files were not shown because too many files have changed in this diff Show More