Compare commits

...

215 Commits

Author SHA1 Message Date
zhom 3bec00a2cd chore: reset lock file 2025-07-11 03:43:52 +04:00
zhom 3b78971df8 chore: pnpm update 2025-07-11 03:31:05 +04:00
zhom 5f9a716f62 chore: version bump 2025-07-11 03:22:36 +04:00
zhom 4d07984d99 chore: hide camoufox 2025-07-11 03:22:11 +04:00
zhom 188e14e5b5 style: copy 2025-07-11 03:10:53 +04:00
zhom bc1b9e9757 style: copy 2025-07-11 03:10:00 +04:00
zhom e742e5fdfa style: copy 2025-07-11 03:09:38 +04:00
zhom 9ce7757cb2 chore: version bump 2025-07-08 06:26:39 +04:00
zhom 3ca454a2c5 style: adjust modal height 2025-07-08 04:57:25 +04:00
zhom 689ac8e3ca fix: windows build correct string literal 2025-07-07 07:34:55 +04:00
zhom 0e1c5dcfb6 docs: add feature description 2025-07-07 07:33:41 +04:00
zhom f22a9f3557 style: copy 2025-07-07 07:13:26 +04:00
zhom 5a76fe3221 tests: treat all camoufox versions as stable 2025-07-07 07:13:03 +04:00
zhom 5edad9b97c fix: prevent version downgrade for camoufox 2025-07-07 07:04:49 +04:00
zhom 38556fc504 style: copy and minor self-update modal logic change 2025-07-07 06:44:15 +04:00
zhom 703ca2c50b feat: add anti-detect functionality 2025-07-07 06:19:43 +04:00
zhom 198046fca9 Merge pull request #42 from zhom/dependabot/cargo/src-tauri/rust-dependencies-77d4c5ce85
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 10 updates
2025-07-05 11:33:20 +00:00
zhom fdcce5c86a Merge pull request #41 from zhom/dependabot/npm_and_yarn/frontend-dependencies-199434007a
deps(deps): bump the frontend-dependencies group with 35 updates
2025-07-05 11:33:00 +00:00
zhom 1cd1c7b59d Merge pull request #40 from zhom/dependabot/github_actions/github-actions-4aaa0eafdc
ci(deps): bump crate-ci/typos from 1.33.1 to 1.34.0 in the github-actions group
2025-07-05 11:32:43 +00:00
dependabot[bot] d803361fca deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.12.20` | `0.12.22` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.45.1` | `1.46.1` |
| [async-channel](https://github.com/smol-rs/async-channel) | `2.3.1` | `2.4.0` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.27` | `1.2.28` |
| [h2](https://github.com/hyperium/h2) | `0.4.10` | `0.4.11` |
| [rust-ini](https://github.com/zonyitoo/rust-ini) | `0.21.1` | `0.21.2` |
| [serde_with](https://github.com/jonasbb/serde_with) | `3.13.0` | `3.14.0` |
| [serde_with_macros](https://github.com/jonasbb/serde_with) | `3.13.0` | `3.14.0` |
| [shared_child](https://github.com/oconnor663/shared_child.rs) | `1.1.0` | `1.1.1` |
| [sigchld](https://github.com/oconnor663/sigchld.rs) | `0.2.3` | `0.2.4` |


Updates `reqwest` from 0.12.20 to 0.12.22
- [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.20...v0.12.22)

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

Updates `async-channel` from 2.3.1 to 2.4.0
- [Release notes](https://github.com/smol-rs/async-channel/releases)
- [Changelog](https://github.com/smol-rs/async-channel/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/async-channel/compare/v2.3.1...v2.4.0)

Updates `cc` from 1.2.27 to 1.2.28
- [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.27...cc-v1.2.28)

Updates `h2` from 0.4.10 to 0.4.11
- [Release notes](https://github.com/hyperium/h2/releases)
- [Changelog](https://github.com/hyperium/h2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/h2/compare/v0.4.10...v0.4.11)

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

Updates `serde_with` from 3.13.0 to 3.14.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.13.0...v3.14.0)

Updates `serde_with_macros` from 3.13.0 to 3.14.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.13.0...v3.14.0)

Updates `shared_child` from 1.1.0 to 1.1.1
- [Commits](https://github.com/oconnor663/shared_child.rs/compare/1.1.0...1.1.1)

Updates `sigchld` from 0.2.3 to 0.2.4
- [Commits](https://github.com/oconnor663/sigchld.rs/compare/0.2.3...0.2.4)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tokio
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: async-channel
  dependency-version: 2.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: h2
  dependency-version: 0.4.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rust-ini
  dependency-version: 0.21.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serde_with
  dependency-version: 3.14.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.14.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: shared_child
  dependency-version: 1.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sigchld
  dependency-version: 0.2.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 09:32:16 +00:00
dependabot[bot] 2f6f20eb29 deps(deps): bump the frontend-dependencies group with 35 updates
---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: sonner
  dependency-version: 2.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 24.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: tw-animate-css
  dependency-version: 1.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: dotenv
  dependency-version: 17.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/env"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-powerpc64le-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 09:18:32 +00:00
dependabot[bot] 59272e0cff 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.33.1 to 1.34.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/b1ae8d918b6e85bd611117d3d9a3be4f903ee5e4...392b78fe18a52790c53f42456e46124f77346842)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 09:12:21 +00:00
zhom cac2273ad3 chore: version bump 2025-07-05 02:40:15 +04:00
zhom 1691a7a06b refactor: make mullvad handle links the same way tor browser does 2025-07-05 02:38:17 +04:00
zhom 5a4718fba6 refactor: more robust profile import logic 2025-07-05 01:58:27 +04:00
zhom 336543d06e chore: version bump 2025-07-04 03:38:54 +04:00
zhom 73cc6c2ac5 build: use zip on windows 2025-07-04 03:23:34 +04:00
zhom f4c96ec0c6 fix: accept unused profile path on linux in arguments 2025-07-04 03:17:42 +04:00
zhom f84b3c2812 chore: disable rust codeql 2025-07-04 02:57:06 +04:00
zhom 29603076f7 chore: don't try to install nodecar dependencies 2025-07-04 02:44:46 +04:00
zhom 76bcb73b39 chore: instal system dependencies only for rust codeql check 2025-07-04 02:41:30 +04:00
zhom 51983bf3a5 style: scroll data table instead of page 2025-07-04 02:36:56 +04:00
zhom eda83cf439 chore: install dependencies on ubuntu-latest 2025-07-04 02:13:22 +04:00
zhom 7b6ea00838 feat: add proxy management 2025-07-04 01:56:41 +04:00
zhom d8f07ddb11 chore: install ubuntu dependencies after setting up rust 2025-07-03 23:17:42 +04:00
zhom 1b0ebbc666 chore: install build dependencies on ubuntu in codeql 2025-07-03 22:52:59 +04:00
zhom d377809c77 chore: remove dead code 2025-07-03 21:50:52 +04:00
zhom fbf36b49df chore: remove unused dependencies 2025-07-03 21:50:34 +04:00
zhom 341751c9b2 refactor: update profile storage structure 2025-07-03 21:34:56 +04:00
zhom eea227d853 chore: add codeql for rust code 2025-07-03 20:41:43 +04:00
zhom 29b6aed475 feat: show donwload bar for app self-update 2025-07-03 17:52:50 +04:00
zhom 050f8b5353 chore: pnpm update 2025-07-03 02:31:47 +04:00
zhom 8793de8c87 chore: update greetings message 2025-07-01 05:13:49 +04:00
zhom 7408ec876c chore: version bump 2025-07-01 05:11:50 +04:00
zhom fc8c358088 refactor: fetch chromium versions after 200+ new builds 2025-07-01 05:10:59 +04:00
zhom b11495e3b9 fix: dropdowns are not visible 2025-07-01 05:10:28 +04:00
zhom 11567ca50e chore: pnpm update 2025-06-29 19:01:02 +04:00
zhom 1c2d5b3774 Merge pull request #38 from zhom/dependabot/npm_and_yarn/frontend-dependencies-63052f5461
deps(deps): bump the frontend-dependencies group with 12 updates
2025-06-29 14:52:29 +00:00
dependabot[bot] 852066ef41 deps(deps): bump the frontend-dependencies group with 12 updates
Bumps the frontend-dependencies group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [@tauri-apps/cli](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-darwin-arm64](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-darwin-x64](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-linux-arm-gnueabihf](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-linux-arm64-gnu](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-linux-arm64-musl](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-linux-riscv64-gnu](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-linux-x64-gnu](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-linux-x64-musl](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-win32-arm64-msvc](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-win32-ia32-msvc](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [@tauri-apps/cli-win32-x64-msvc](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |


Updates `@tauri-apps/cli` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v2.6.1...@tauri-apps/cli-v2.6.2)

Updates `@tauri-apps/cli-darwin-arm64` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-darwin-x64` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-linux-arm-gnueabihf` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-linux-arm64-gnu` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-linux-arm64-musl` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-linux-riscv64-gnu` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-linux-x64-gnu` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-linux-x64-musl` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-win32-arm64-msvc` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-win32-ia32-msvc` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

Updates `@tauri-apps/cli-win32-x64-msvc` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.6.1...tauri-v2.6.2)

---
updated-dependencies:
- dependency-name: "@tauri-apps/cli"
  dependency-version: 2.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-arm64"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-x64"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm-gnueabihf"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-gnu"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-musl"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-riscv64-gnu"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-gnu"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-musl"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-arm64-msvc"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-ia32-msvc"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-x64-msvc"
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-29 13:41:14 +00:00
zhom 9622d85e73 Merge pull request #37 from zhom/dependabot/github_actions/github-actions-b68af14af7
ci(deps): bump tauri-apps/tauri-action from 0.5.21 to 0.5.22 in the github-actions group
2025-06-29 13:38:48 +00:00
dependabot[bot] 4e2b87c5f1 ci(deps): bump tauri-apps/tauri-action in the github-actions group
Bumps the github-actions group with 1 update: [tauri-apps/tauri-action](https://github.com/tauri-apps/tauri-action).


Updates `tauri-apps/tauri-action` from 0.5.21 to 0.5.22
- [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/8c94c894075e92c8a2b668b2d35c57e1e38cfdfb...564aea5a8075c7a54c167bb0cf5b3255314a7f9d)

---
updated-dependencies:
- dependency-name: tauri-apps/tauri-action
  dependency-version: 0.5.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-29 13:30:55 +00:00
zhom 2099dadbc0 chore: fully migrate to biome 2025-06-29 17:28:42 +04:00
zhom 00e4eb2715 chore: update dependabot automerge config 2025-06-29 17:07:23 +04:00
zhom 33bc4476a4 chore: update biome config schema version 2025-06-29 17:06:56 +04:00
zhom 0ad8988f7e Merge pull request #36 from zhom/dependabot/github_actions/github-actions-0bae03cf66
ci(deps): bump the github-actions group across 1 directory with 2 updates
2025-06-28 16:17:35 +00:00
zhom 2b3aaf1e92 Merge pull request #35 from zhom/dependabot/npm_and_yarn/frontend-dependencies-a36879a10f
deps(deps): bump the frontend-dependencies group across 1 directory with 86 updates
2025-06-28 16:17:22 +00:00
zhom 5a10e0b696 Merge pull request #34 from zhom/dependabot/cargo/src-tauri/rust-dependencies-c98a71ca2f
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 36 updates
2025-06-28 16:17:09 +00:00
dependabot[bot] 9e48ddbf3e deps(rust)(deps): bump the rust-dependencies group
---
updated-dependencies:
- dependency-name: tauri-plugin-opener
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-fs
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-shell
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-deep-link
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-single-instance
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: brotli
  dependency-version: 8.0.1
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: brotli-decompressor
  dependency-version: 5.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: bumpalo
  dependency-version: 3.19.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: crunchy
  dependency-version: 0.2.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cssparser
  dependency-version: 0.29.6
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: html5ever
  dependency-version: 0.29.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: itoa
  dependency-version: 1.0.15
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: kuchikiki
  dependency-version: 0.8.8-speedreader
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: markup5ever
  dependency-version: 0.14.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: muda
  dependency-version: 0.17.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: num_enum
  dependency-version: 0.7.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: num_enum_derive
  dependency-version: 0.7.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: phf_macros
  dependency-version: 0.10.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: selectors
  dependency-version: 0.24.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: servo_arc
  dependency-version: 0.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tao
  dependency-version: 0.34.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-codegen
  dependency-version: 2.3.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-macros
  dependency-version: 2.3.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin
  dependency-version: 2.3.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime
  dependency-version: 2.7.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime-wry
  dependency-version: 2.7.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-utils
  dependency-version: 2.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tray-icon
  dependency-version: 0.21.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: webview2-com
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: webview2-com-sys
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: windows-registry
  dependency-version: 0.5.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: wry
  dependency-version: 0.52.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-28 16:09:43 +00:00
dependabot[bot] bcbb2c1d42 ci(deps): bump the github-actions group across 1 directory with 2 updates
Bumps the github-actions group with 2 updates in the / directory: [swatinem/rust-cache](https://github.com/swatinem/rust-cache) and [tauri-apps/tauri-action](https://github.com/tauri-apps/tauri-action).


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

Updates `tauri-apps/tauri-action` from 0.5.20 to 0.5.21
- [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/42e9df6c59070d114bf90dcd3943a1b8f138b113...8c94c894075e92c8a2b668b2d35c57e1e38cfdfb)

---
updated-dependencies:
- dependency-name: swatinem/rust-cache
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: tauri-apps/tauri-action
  dependency-version: 0.5.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-28 16:06:05 +00:00
zhom 391bfdabdc build: inherit secrets in steps 2025-06-28 19:59:51 +04:00
dependabot[bot] 7b2dc84b5b deps(deps): bump the frontend-dependencies group across 1 directory with 86 updates
---
updated-dependencies:
- dependency-name: "@tauri-apps/api"
  dependency-version: 2.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-deep-link"
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-dialog"
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-fs"
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-opener"
  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.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@eslint/js"
  dependency-version: 9.30.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli"
  dependency-version: 2.6.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 24.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 4.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: dotenv
  dependency-version: 17.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@babel/compat-data"
  dependency-version: 7.27.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@babel/core"
  dependency-version: 7.27.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@babel/traverse"
  dependency-version: 7.27.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.0.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rolldown/pluginutils"
  dependency-version: 1.0.0-beta.19
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-powerpc64le-gnu"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/node"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-android-arm64"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darwin-arm64"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darwin-x64"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-freebsd-x64"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm-gnueabihf"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm64-gnu"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm64-musl"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-x64-gnu"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-x64-musl"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-wasm32-wasi"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-win32-arm64-msvc"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-win32-x64-msvc"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide"
  dependency-version: 4.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-arm64"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-x64"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm-gnueabihf"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-gnu"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-musl"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-riscv64-gnu"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-gnu"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-musl"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-arm64-msvc"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-ia32-msvc"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-x64-msvc"
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/project-service"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/scope-manager"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/tsconfig-utils"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/type-utils"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/types"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/typescript-estree"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/visitor-keys"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: browserslist
  dependency-version: 4.25.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: electron-to-chromium
  dependency-version: 1.5.177
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: enhanced-resolve
  dependency-version: 5.18.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.44.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-28 15:39:09 +00:00
zhom ddc09726f4 build: inherit secrets for automerge 2025-06-28 19:12:16 +04:00
zhom e1451d3fbb build: use updated dependabot token 2025-06-28 16:28:42 +04:00
zhom b18df6499f build: use default token for dependabot automerge workflow 2025-06-28 15:24:42 +04:00
zhom c5c2563a4e chore: version bump 2025-06-26 19:19:09 +04:00
zhom 8475f42821 refactor: improve titlebar interactions on macos 2025-06-26 19:17:38 +04:00
zhom f51aa9ed85 refactor: better state control for browser download 2025-06-22 06:23:27 +04:00
zhom 3d3a3b3816 chore: linting 2025-06-22 06:04:02 +04:00
zhom e090881917 Merge pull request #31 from zhom/dependabot/cargo/src-tauri/rust-dependencies-679f27469d
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 16 updates
2025-06-22 00:53:38 +00:00
zhom b46976f47d Merge pull request #29 from zhom/dependabot/github_actions/github-actions-97a53f9a15
ci(deps): bump google/osv-scanner-action from 2.0.2 to 2.0.3 in the github-actions group
2025-06-22 00:53:27 +00:00
dependabot[bot] 39a978682c ci(deps): bump google/osv-scanner-action in the github-actions group
Bumps the github-actions group with 1 update: [google/osv-scanner-action](https://github.com/google/osv-scanner-action).


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

---
updated-dependencies:
- dependency-name: google/osv-scanner-action
  dependency-version: 2.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-22 00:52:23 +00:00
zhom 38e58e604b chore: add token var to automerge 2025-06-22 04:24:51 +04:00
dependabot[bot] ffcff2ce7c deps(rust)(deps): bump the rust-dependencies group
---
updated-dependencies:
- dependency-name: tauri-plugin-opener
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-shell
  dependency-version: 2.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: wiremock
  dependency-version: 0.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: autocfg
  dependency-version: 1.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: embed-resource
  dependency-version: 3.0.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: errno
  dependency-version: 0.3.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libc
  dependency-version: 0.2.174
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: liblzma
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: r-efi
  dependency-version: 5.3.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rustls
  dependency-version: 0.23.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: shared_child
  dependency-version: 1.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: slab
  dependency-version: 0.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing-attributes
  dependency-version: 0.1.30
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy
  dependency-version: 0.8.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy-derive
  dependency-version: 0.8.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-21 23:11:07 +00:00
zhom c8ea31f85d Merge pull request #30 from zhom/dependabot/npm_and_yarn/frontend-dependencies-424214cd75
deps(deps): bump the frontend-dependencies group with 80 updates
2025-06-21 18:06:22 +00:00
zhom 7ac6e21dbc chore: pass default token 2025-06-21 21:11:58 +04:00
dependabot[bot] 7533993909 deps(deps): bump the frontend-dependencies group with 80 updates
---
updated-dependencies:
- dependency-name: "@tauri-apps/plugin-opener"
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: next
  dependency-version: 15.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.0.4
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@next/eslint-plugin-next"
  dependency-version: 15.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 24.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.34.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.34.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: eslint-config-next
  dependency-version: 15.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: lint-staged
  dependency-version: 16.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.34.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.0.4
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@next/env"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 15.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-powerpc64le-gnu"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@types/estree"
  dependency-version: 1.0.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/project-service"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/scope-manager"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/tsconfig-utils"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/type-utils"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/types"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/typescript-estree"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@typescript-eslint/visitor-keys"
  dependency-version: 8.34.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-android-arm-eabi"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-android-arm64"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-darwin-arm64"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-darwin-x64"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-freebsd-x64"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-arm-gnueabihf"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-arm-musleabihf"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-arm64-gnu"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-arm64-musl"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-ppc64-gnu"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-riscv64-gnu"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-riscv64-musl"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-s390x-gnu"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-x64-gnu"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-linux-x64-musl"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-wasm32-wasi"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-win32-arm64-msvc"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-win32-ia32-msvc"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@unrs/resolver-binding-win32-x64-msvc"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: caniuse-lite
  dependency-version: 1.0.30001724
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: eslint-module-utils
  dependency-version: 2.12.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: eslint-plugin-import
  dependency-version: 2.32.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: unrs-resolver
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-21 17:03:46 +00:00
zhom 8176f45e41 chore: use default github token in dependabot 2025-06-21 20:41:42 +04:00
zhom f55a3f7155 chore: add github token to dependabot 2025-06-21 19:45:01 +04:00
zhom 7d74ac09d9 refactor: update chromium after 100+ versions 2025-06-19 08:57:48 +04:00
zhom d314fa1f71 chore: store user input in variables 2025-06-19 07:24:33 +04:00
zhom 968969cf1e docs: clean up 2025-06-19 06:37:52 +04:00
zhom a7a3d99881 chore: ask for more info on issue 2025-06-19 06:36:15 +04:00
zhom 80cd2e4e7f build: generate release notes 2025-06-19 06:35:34 +04:00
zhom 6361a039bc chore: version bump 2025-06-19 03:52:26 +04:00
zhom 8005ec90b6 refactor: improve auto-delete and auto-install browser logic 2025-06-19 03:36:06 +04:00
zhom cdf30b7baa Merge pull request #28 from zhom/contributors-readme-action-OBsPbmEa9K
docs(contributor): contributors readme action update
2025-06-18 02:20:40 +00:00
github-actions[bot] fadef414fe docs(contributor): contrib-readme-action has updated readme 2025-06-18 02:17:09 +00:00
zhom e1c55233f7 chore: fix permissions for contributors workflow 2025-06-18 06:14:36 +04:00
zhom 801a2b5732 docs: rename agent instructions doc 2025-06-18 06:11:38 +04:00
zhom abe5c691ce docs: stale issue workflow 2025-06-18 06:08:15 +04:00
zhom 2f9a17c6e0 docs: automatically add contributors to readme 2025-06-18 05:38:38 +04:00
zhom fcdb80f75a docs: github newcomer greetings 2025-06-18 05:34:53 +04:00
zhom 7568e7998d chore : version bump 2025-06-18 03:00:45 +04:00
zhom e0f4f93c30 fix: don't create unique temp dir for every cli call 2025-06-18 02:59:11 +04:00
zhom d142b7f79b style: don't show release notes 2025-06-18 02:39:40 +04:00
zhom dc5553a5d3 chore: version bump 2025-06-18 01:30:02 +04:00
zhom 07445ff95b build: add content read permissions for linting workflows 2025-06-18 01:26:34 +04:00
zhom 6ecbc39e46 build: pin action versions 2025-06-18 01:25:21 +04:00
zhom 67849c00d5 refactor: use tmp for temp dirs and add more robust error handling for updateProxyConfig 2025-06-18 01:19:10 +04:00
zhom bdf71e4ef8 build: revert dependabot automerge workflow 2025-06-18 00:57:40 +04:00
zhom 2d2ebba40e build: assign read permission to all actions without one 2025-06-18 00:17:58 +04:00
zhom 2caac5bf4c build: pin action versions 2025-06-18 00:12:26 +04:00
zhom a816fbb140 chore: version bump 2025-06-17 19:24:17 +04:00
zhom c954668ed1 fix: launch chromium proxy directly instead of pac file 2025-06-17 19:22:18 +04:00
zhom 2db27b5ffd refactor: only use is_browser_version_nightly for release checks 2025-06-17 17:52:28 +04:00
zhom 845e9f28ad fix: don't let the user create profile or change version if latest version not downloaded 2025-06-17 16:29:42 +04:00
zhom ee8c6dcc85 chore: add checks for unused ts exports 2025-06-17 16:13:17 +04:00
zhom 08453fe9a6 build: update codeql permissions 2025-06-17 07:05:20 +04:00
zhom b486f00875 chore: version bump 2025-06-17 07:00:27 +04:00
zhom 703154b30f chore: linting 2025-06-17 07:00:18 +04:00
zhom 130f8b86d1 refactor: browser auto-update 2025-06-17 06:55:52 +04:00
zhom 607ed66e29 style: copy 2025-06-17 06:32:47 +04:00
zhom 9570b6d605 build: fix codeql permissions 2025-06-17 06:28:34 +04:00
zhom 2d92cbb0e5 refactor: fetch release information the same way for manual and automatic checks 2025-06-17 06:17:57 +04:00
zhom 251016609f refactor: change event emitting and remove sleep 2025-06-17 04:05:17 +04:00
zhom bddf796946 refactor: don't mark updates as automatic and fetch versions via version_updater only 2025-06-17 03:44:59 +04:00
zhom 8d793a6868 style: make release type selector behave the same in both creation and change modals 2025-06-17 03:42:47 +04:00
zhom 469f161293 refactor: add extra settings to not show update prompt 2025-06-17 03:24:10 +04:00
zhom 9756e64319 refactor: increase default update interval for firefox 2025-06-17 03:22:30 +04:00
zhom 800544ede9 refactor: supress all update prompts in the ui for firefox 2025-06-17 03:21:21 +04:00
zhom aa2228a8aa fix: show switch release option for correct browsers 2025-06-17 03:10:16 +04:00
zhom 432e5bff90 refactor: switch download mode from 0 to 2 on firefox 2025-06-17 03:06:50 +04:00
zhom f4b60eb6c7 chore: linting 2025-06-17 03:04:06 +04:00
zhom 30122c5781 reafctor: get cache releases first and don't return twilight in results 2025-06-17 03:00:09 +04:00
zhom b71d84fda4 refactor: improve signatures for public functions 2025-06-17 02:58:46 +04:00
zhom 859af72724 refactor: improve auto-update inside browser prevention 2025-06-17 02:57:30 +04:00
zhom 0360a89ceb style: remove 'enable automatic browser updates' setting 2025-06-17 02:46:33 +04:00
zhom cb6f744d6b style: only show the select menu if both stable and nightly releases are available 2025-06-17 02:42:03 +04:00
zhom 575d7f80b1 style: remove switch release option for zen and chromium 2025-06-17 02:40:48 +04:00
zhom d05b69ff3d build: require codeql and spellcheck to pass successfully before build starts 2025-06-16 23:28:53 +04:00
zhom 54abb11129 build: run spellcheck on build 2025-06-16 03:41:38 +04:00
zhom 04c690c750 build: run codeql before build 2025-06-16 03:30:13 +04:00
zhom 9a4be86e95 style: copy 2025-06-15 05:40:18 +04:00
zhom 6d013d86aa Merge pull request #26 from zhom/dependabot/cargo/src-tauri/rust-dependencies-f6a8cef228
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 2 updates
2025-06-14 18:26:30 +00:00
zhom 769fbf9d75 Merge pull request #25 from zhom/dependabot/github_actions/github-actions-eba975d771
ci(deps): bump stefanzweifel/git-auto-commit-action from 4 to 6 in the github-actions group
2025-06-14 18:26:09 +00:00
zhom 6e62abc601 chore: version bump 2025-06-14 22:22:17 +04:00
dependabot[bot] 8848fa8130 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 2 updates: [serde_with](https://github.com/jonasbb/serde_with) and [serde_with_macros](https://github.com/jonasbb/serde_with).


Updates `serde_with` from 3.12.0 to 3.13.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.12.0...v3.13.0)

Updates `serde_with_macros` from 3.12.0 to 3.13.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.12.0...v3.13.0)

---
updated-dependencies:
- dependency-name: serde_with
  dependency-version: 3.13.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.13.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 18:21:50 +00:00
zhom 1f0ecbe36e docs: use dark-themed preview for users with dark mode on 2025-06-14 22:21:26 +04:00
dependabot[bot] f83f2033fe ci(deps): bump stefanzweifel/git-auto-commit-action
Bumps the github-actions group with 1 update: [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action).


Updates `stefanzweifel/git-auto-commit-action` from 4 to 6
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v4...v6)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 18:02:10 +00:00
zhom 821cd4ea82 style: only allow user to switch between releases 2025-06-14 21:55:18 +04:00
zhom d3a63c37bf test: update mock 2025-06-14 19:20:51 +04:00
zhom 95cd2426c3 feat: automatically update browsers on new versions 2025-06-14 19:03:02 +04:00
zhom 5a3fb7b2b0 refactor: install chromium update after 50 new builds 2025-06-14 18:44:03 +04:00
zhom 767a0701ce refactor: don't check for nodecar dependency updates 2025-06-14 18:33:33 +04:00
zhom ec61d51c07 build: update changelog generation workflow 2025-06-14 17:33:38 +04:00
zhom 545c518a55 feat: don't spam update notification, show more concise toasts, don't fetch unsupported browser updates 2025-06-14 16:58:55 +04:00
zhom c99eee2c21 style: use proper zen icon 2025-06-14 16:08:26 +04:00
zhom 7f3683cc2e style: show 'not supported' for tor browser proxies 2025-06-14 15:57:33 +04:00
zhom baac3a533a refactor: fetch 100 latest app updates from github releases 2025-06-14 15:43:50 +04:00
zhom 5cd1774ffc chore: remove dependabot automerge workflow 2025-06-14 15:42:02 +04:00
zhom cb87641890 Merge pull request #23 from zhom/dependabot/cargo/src-tauri/rust-dependencies-af3af11ff5
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 17 updates
2025-06-14 11:34:34 +00:00
zhom 3df5ac671b Merge pull request #24 from zhom/dependabot/npm_and_yarn/frontend-dependencies-f9895d0c1f
deps(deps): bump the frontend-dependencies group with 3 updates
2025-06-14 11:34:05 +00:00
dependabot[bot] 390f79f97b deps(deps): bump the frontend-dependencies group with 3 updates
Bumps the frontend-dependencies group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [lint-staged](https://github.com/lint-staged/lint-staged) and [undici-types](https://github.com/nodejs/undici).


Updates `@types/node` from 22.15.31 to 24.0.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.0 to 16.1.1
- [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.0...v16.1.1)

Updates `undici-types` from 6.21.0 to 7.8.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.21.0...v7.8.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: lint-staged
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: undici-types
  dependency-version: 7.8.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 10:18:35 +00:00
dependabot[bot] c4dc2ed50c deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 17 updates:

| Package | From | To |
| --- | --- | --- |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.12.19` | `0.12.20` |
| [windows](https://github.com/microsoft/windows-rs) | `0.61.1` | `0.61.3` |
| [adler2](https://github.com/oyvindln/adler2) | `2.0.0` | `2.0.1` |
| [bytemuck](https://github.com/Lokathor/bytemuck) | `1.23.0` | `1.23.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.26` | `1.2.27` |
| [cfg-if](https://github.com/rust-lang/cfg-if) | `1.0.0` | `1.0.1` |
| [enumflags2](https://github.com/meithecatte/enumflags2) | `0.7.11` | `0.7.12` |
| [enumflags2_derive](https://github.com/meithecatte/enumflags2) | `0.7.11` | `0.7.12` |
| [hermit-abi](https://github.com/hermit-os/hermit-rs) | `0.5.1` | `0.5.2` |
| [libc](https://github.com/rust-lang/libc) | `0.2.172` | `0.2.173` |
| [memchr](https://github.com/BurntSushi/memchr) | `2.7.4` | `2.7.5` |
| [miniz_oxide](https://github.com/Frommi/miniz_oxide) | `0.8.8` | `0.8.9` |
| [plist](https://github.com/ebarnard/rust-plist) | `1.7.1` | `1.7.2` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.32.0` | `0.37.5` |
| redox_syscall | `0.5.12` | `0.5.13` |
| [rustc-demangle](https://github.com/rust-lang/rustc-demangle) | `0.1.24` | `0.1.25` |
| [windows-link](https://github.com/microsoft/windows-rs) | `0.1.1` | `0.1.3` |


Updates `reqwest` from 0.12.19 to 0.12.20
- [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.19...v0.12.20)

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

Updates `adler2` from 2.0.0 to 2.0.1
- [Changelog](https://github.com/oyvindln/adler2/blob/main/CHANGELOG.md)
- [Commits](https://github.com/oyvindln/adler2/commits)

Updates `bytemuck` from 1.23.0 to 1.23.1
- [Changelog](https://github.com/Lokathor/bytemuck/blob/main/changelog.md)
- [Commits](https://github.com/Lokathor/bytemuck/compare/v1.23.0...v1.23.1)

Updates `cc` from 1.2.26 to 1.2.27
- [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.26...cc-v1.2.27)

Updates `cfg-if` from 1.0.0 to 1.0.1
- [Release notes](https://github.com/rust-lang/cfg-if/releases)
- [Changelog](https://github.com/rust-lang/cfg-if/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cfg-if/compare/1.0.0...v1.0.1)

Updates `enumflags2` from 0.7.11 to 0.7.12
- [Release notes](https://github.com/meithecatte/enumflags2/releases)
- [Commits](https://github.com/meithecatte/enumflags2/compare/v0.7.11...v0.7.12)

Updates `enumflags2_derive` from 0.7.11 to 0.7.12
- [Release notes](https://github.com/meithecatte/enumflags2/releases)
- [Commits](https://github.com/meithecatte/enumflags2/compare/v0.7.11...v0.7.12)

Updates `hermit-abi` from 0.5.1 to 0.5.2
- [Release notes](https://github.com/hermit-os/hermit-rs/releases)
- [Commits](https://github.com/hermit-os/hermit-rs/compare/hermit-abi-0.5.1...hermit-abi-0.5.2)

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

Updates `memchr` from 2.7.4 to 2.7.5
- [Commits](https://github.com/BurntSushi/memchr/compare/2.7.4...2.7.5)

Updates `miniz_oxide` from 0.8.8 to 0.8.9
- [Changelog](https://github.com/Frommi/miniz_oxide/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Frommi/miniz_oxide/commits)

Updates `plist` from 1.7.1 to 1.7.2
- [Release notes](https://github.com/ebarnard/rust-plist/releases)
- [Commits](https://github.com/ebarnard/rust-plist/compare/v1.7.1...v1.7.2)

Updates `quick-xml` from 0.32.0 to 0.37.5
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.32.0...v0.37.5)

Updates `redox_syscall` from 0.5.12 to 0.5.13

Updates `rustc-demangle` from 0.1.24 to 0.1.25
- [Commits](https://github.com/rust-lang/rustc-demangle/commits)

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

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows
  dependency-version: 0.61.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: adler2
  dependency-version: 2.0.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bytemuck
  dependency-version: 1.23.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.27
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cfg-if
  dependency-version: 1.0.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: enumflags2
  dependency-version: 0.7.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: enumflags2_derive
  dependency-version: 0.7.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hermit-abi
  dependency-version: 0.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libc
  dependency-version: 0.2.173
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: memchr
  dependency-version: 2.7.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: miniz_oxide
  dependency-version: 0.8.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: plist
  dependency-version: 1.7.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: quick-xml
  dependency-version: 0.37.5
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: redox_syscall
  dependency-version: 0.5.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rustc-demangle
  dependency-version: 0.1.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows-link
  dependency-version: 0.1.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-14 09:44:26 +00:00
zhom 3b7315cc0d docs: add fossa badge 2025-06-14 03:42:52 +04:00
zhom bbd0f5df0c chore: version bump 2025-06-14 02:51:24 +04:00
zhom 8e7982bdf8 fix: properly handle urls added by system events 2025-06-14 02:11:40 +04:00
zhom 9ac662aee8 chore: use single-instance plugin as described by the docs 2025-06-13 21:40:50 +04:00
zhom 2394716ea3 chore: version bump 2025-06-11 04:43:18 +04:00
zhom 06992f8b9a build: use nightly-date-hash naming for rolling releases 2025-06-11 04:23:03 +04:00
zhom 5a454e3647 chore: linting 2025-06-11 04:17:07 +04:00
zhom 3f91d92d8b refactor: add missing linting dependency 2025-06-11 04:00:12 +04:00
zhom c586046542 refactor: codacy autofix 2025-06-11 03:56:14 +04:00
zhom 42f63172fb docs: add codacy badge 2025-06-11 03:55:36 +04:00
zhom f717600fcb style: trucate profile name in table after over 15 characters 2025-06-11 03:40:01 +04:00
zhom c807ea5596 build: add changelog generation on release 2025-06-11 03:23:16 +04:00
zhom df2c1316d4 build: disable windows build for regular releases 2025-06-11 03:04:34 +04:00
zhom 1fdc552dc7 fix: make sure proxy configuration is discovered properly in the production build 2025-06-11 03:02:40 +04:00
zhom ab563f81fa style: update proxy settings form to match proxy-settings-dialog 2025-06-11 02:40:03 +04:00
zhom d17545bd05 docs: add extra check for agents to run tests and linting before finishing 2025-06-11 02:33:23 +04:00
zhom 29c329b432 feat: update proxy ui to accept credential outside url too 2025-06-11 01:55:54 +04:00
zhom 4d7bbe719f chore: run biome on nodecar file changes 2025-06-11 01:52:07 +04:00
zhom 5b869a6115 test: add tests for proxy manager 2025-06-10 00:33:58 +04:00
zhom c4b1745a0f style: update ui to accept proxy separately 2025-06-09 23:19:34 +04:00
zhom ac293f6204 build: rename nightly release tags for better ordering 2025-06-09 23:18:55 +04:00
zhom 7f3a3287d6 build: switch win x64 build to windows-latest 2025-06-09 19:48:30 +04:00
zhom dd9347d429 build: set proper shell for the hash step 2025-06-09 19:19:57 +04:00
zhom 615d7b8c8a build: fix windows build warnings 2025-06-09 18:56:38 +04:00
zhom 5c26ab5c33 build: windows build invalid lifetime fix 2025-06-09 18:02:48 +04:00
zhom dccaf6c7de build: blind windows ci build fixes 2025-06-09 15:20:06 +04:00
zhom bf5b2886f5 build: blind windows ci build fixes 2025-06-09 15:01:10 +04:00
zhom bbc12bcc03 fix: blind windows ci build fixes 2025-06-09 06:25:22 +04:00
zhom 6d437f30e1 feat: add windows build to ci (broken) 2025-06-09 06:05:22 +04:00
zhom 4b0ab6b732 refactor: windows pipeline test 2025-06-09 06:04:26 +04:00
zhom a802895491 fix: same fix for discovery 2025-06-09 03:05:20 +04:00
zhom a57f90899b fix: don't try to enter into nested directory on linux for chromium binary 2025-06-09 02:59:57 +04:00
zhom 3ebc714b23 chore: version bump 2025-06-08 20:27:50 +04:00
zhom 1acd4781b5 fix: properly handle permissions on macos 2025-06-08 20:27:17 +04:00
zhom a5b9afafcb docs: update agent instructions 2025-06-08 17:06:10 +04:00
zhom 0c8dd5ace5 Merge pull request #19 from zhom/dependabot/npm_and_yarn/frontend-dependencies-6813ab5a43
deps(deps): bump the frontend-dependencies group with 21 updates
2025-06-07 14:29:46 +04:00
dependabot[bot] e8c3188657 deps(deps): bump the frontend-dependencies group with 21 updates
Bumps the frontend-dependencies group with 21 updates:

| Package | From | To |
| --- | --- | --- |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-loongarch64-gnu](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-powerpc64le-gnu](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |
| [rollup](https://github.com/rollup/rollup) | `4.41.1` | `4.42.0` |


Updates `@rollup/rollup-android-arm-eabi` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-android-arm64` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-darwin-arm64` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-darwin-x64` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-freebsd-arm64` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-freebsd-x64` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-arm64-musl` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-loongarch64-gnu` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-powerpc64le-gnu` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-x64-gnu` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-linux-x64-musl` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `@rollup/rollup-win32-x64-msvc` from 4.41.1 to 4.42.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.41.1...v4.42.0)

Updates `rollup` from 4.41.1 to 4.42.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.41.1...v4.42.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-07 10:21:57 +00:00
zhom e6f0b2b9e9 Merge pull request #18 from zhom/dependabot/cargo/src-tauri/rust-dependencies-b0de36e2fd
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 13 updates
2025-06-07 14:05:42 +04:00
dependabot[bot] 394406e134 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [bumpalo](https://github.com/fitzgen/bumpalo) | `3.18.0` | `3.18.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.25` | `1.2.26` |
| [flate2](https://github.com/rust-lang/flate2-rs) | `1.1.1` | `1.1.2` |
| [hyper-rustls](https://github.com/rustls/hyper-rustls) | `0.27.6` | `0.27.7` |
| [liblzma-sys](https://github.com/portable-network-archive/liblzma-rs) | `0.4.3` | `0.4.4` |
| [libz-rs-sys](https://github.com/trifectatechfoundation/zlib-rs) | `0.5.0` | `0.5.1` |
| [serde_spanned](https://github.com/toml-rs/toml) | `0.6.8` | `0.6.9` |
| [smallvec](https://github.com/servo/rust-smallvec) | `1.15.0` | `1.15.1` |
| [toml_datetime](https://github.com/toml-rs/toml) | `0.6.9` | `0.6.11` |
| [toml_write](https://github.com/toml-rs/toml) | `0.1.1` | `0.1.2` |
| [tracing-attributes](https://github.com/tokio-rs/tracing) | `0.1.28` | `0.1.29` |
| [tracing-core](https://github.com/tokio-rs/tracing) | `0.1.33` | `0.1.34` |
| [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs) | `0.5.0` | `0.5.1` |


Updates `bumpalo` from 3.18.0 to 3.18.1
- [Changelog](https://github.com/fitzgen/bumpalo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fitzgen/bumpalo/compare/v3.18.0...v3.18.1)

Updates `cc` from 1.2.25 to 1.2.26
- [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.25...cc-v1.2.26)

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

Updates `hyper-rustls` from 0.27.6 to 0.27.7
- [Release notes](https://github.com/rustls/hyper-rustls/releases)
- [Commits](https://github.com/rustls/hyper-rustls/compare/v/0.27.6...v/0.27.7)

Updates `liblzma-sys` from 0.4.3 to 0.4.4
- [Release notes](https://github.com/portable-network-archive/liblzma-rs/releases)
- [Commits](https://github.com/portable-network-archive/liblzma-rs/compare/liblzma-sys-0.4.3...liblzma-sys-0.4.4)

Updates `libz-rs-sys` from 0.5.0 to 0.5.1
- [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.0...v0.5.1)

Updates `serde_spanned` from 0.6.8 to 0.6.9
- [Commits](https://github.com/toml-rs/toml/compare/serde_spanned-v0.6.8...serde_spanned-v0.6.9)

Updates `smallvec` from 1.15.0 to 1.15.1
- [Release notes](https://github.com/servo/rust-smallvec/releases)
- [Commits](https://github.com/servo/rust-smallvec/compare/v1.15.0...v1.15.1)

Updates `toml_datetime` from 0.6.9 to 0.6.11
- [Commits](https://github.com/toml-rs/toml/compare/toml_datetime-v0.6.9...toml_datetime-v0.6.11)

Updates `toml_write` from 0.1.1 to 0.1.2
- [Commits](https://github.com/toml-rs/toml/compare/toml_write-v0.1.1...toml_write-v0.1.2)

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

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

Updates `zlib-rs` from 0.5.0 to 0.5.1
- [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.0...v0.5.1)

---
updated-dependencies:
- dependency-name: bumpalo
  dependency-version: 3.18.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: flate2
  dependency-version: 1.1.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hyper-rustls
  dependency-version: 0.27.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: liblzma-sys
  dependency-version: 0.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libz-rs-sys
  dependency-version: 0.5.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serde_spanned
  dependency-version: 0.6.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: smallvec
  dependency-version: 1.15.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: toml_datetime
  dependency-version: 0.6.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: toml_write
  dependency-version: 0.1.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing-attributes
  dependency-version: 0.1.29
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing-core
  dependency-version: 0.1.34
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zlib-rs
  dependency-version: 0.5.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-07 09:45:20 +00:00
zhom b0ca14c184 chore: version bump 2025-06-07 06:14:34 +04:00
zhom eea94ad360 chore: linter 2025-06-07 06:12:44 +04:00
zhom 2b678ed04d fix: hide brave releases without zip files for linux 2025-06-07 06:00:55 +04:00
zhom dff201ddec chore: add cursor rule 2025-06-07 05:46:32 +04:00
zhom 743ad59348 fix: consider all brave releases nightly if there is no release name 2025-06-07 05:42:57 +04:00
zhom d43e9ef21b chore: ui copy 2025-06-07 05:30:33 +04:00
zhom 7515cbacd6 refactor: check for similar-named brave binaries 2025-06-07 05:25:57 +04:00
zhom f41172e822 fix: update brave url for linux arm 2025-06-07 05:12:04 +04:00
zhom 25ce691bbc fix: use correct file extension on linux 2025-06-07 05:02:24 +04:00
zhom b945ee7088 chore: add cursor rule 2025-06-07 04:39:57 +04:00
zhom eb3589b4c0 chore: add cursor rule 2025-06-07 02:19:55 +04:00
zhom b71b9a00ca docs: update feature list in readme 2025-06-07 02:08:03 +04:00
zhom 6535b37c98 Merge pull request #17 from zhom/docs/readme-tauri-duplicate-link
docs: remove duplicate tauri link
2025-06-07 01:20:39 +04:00
zhom bc72a837e2 docs: remove duplicate tauri link 2025-06-07 01:18:27 +04:00
zhom fb84068d30 docs: add features section to readme 2025-06-07 00:30:06 +04:00
zhom 5024eab062 chore: version bump 2025-06-07 00:01:01 +04:00
zhom 8137f9bf8d fix: adjust download logic to work with latest firefox cdn 2025-06-06 23:40:51 +04:00
zhom e2547c6ec7 docs: update agent instructions 2025-06-06 23:29:10 +04:00
zhom d8d59d2bd5 fix: extraction and version detection 2025-06-06 23:22:45 +04:00
zhom b84350eb13 docs: add ai agents instruction file 2025-06-06 23:22:29 +04:00
zhom 383cef916c docs: remove duplicate star history graph 2025-06-06 14:35:13 +04:00
zhom 743bc059be docs: update readme 2025-06-06 14:33:52 +04:00
113 changed files with 16556 additions and 8000 deletions
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Don't leave comments that don't add value
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
+1 -20
View File
@@ -1,4 +1,5 @@
version: 2
updates:
# Frontend dependencies (root package.json)
- package-ecosystem: "npm"
@@ -13,30 +14,10 @@ updates:
frontend-dependencies:
patterns:
- "*"
ignore:
- dependency-name: "eslint"
versions: ">= 9"
commit-message:
prefix: "deps"
include: "scope"
# Nodecar dependencies
- package-ecosystem: "npm"
directory: "/nodecar"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
allow:
- dependency-type: "all"
groups:
nodecar-dependencies:
patterns:
- "*"
commit-message:
prefix: "deps(nodecar)"
include: "scope"
# Rust dependencies
- package-ecosystem: "cargo"
directory: "/src-tauri"
+95
View File
@@ -0,0 +1,95 @@
name: "CodeQL"
on:
workflow_call:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "16 13 * * 5"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
# - language: rust
# build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
targets: x86_64-unknown-linux-gnu
- name: Install system dependencies (Rust only)
if: matrix.language == 'rust'
run: |
sudo apt-get update
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
with:
workdir: ./src-tauri
- name: Install dependencies from lockfile
run: pnpm install --frozen-lockfile
- name: Install rust dependencies
if: matrix.language == 'rust'
working-directory: ./src-tauri
run: |
cargo build
- name: Build nodecar sidecar
if: matrix.language == 'rust'
shell: bash
working-directory: ./nodecar
run: |
pnpm run build:linux-x64
- name: Copy nodecar binary to Tauri binaries
if: matrix.language == 'rust'
shell: bash
run: |
mkdir -p src-tauri/binaries
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
- name: Initialize CodeQL
uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
with:
queries: security-extended
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
with:
category: "/language:${{matrix.language}}"
+21
View File
@@ -0,0 +1,21 @@
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
contrib-readme-job:
runs-on: ubuntu-latest
name: Automatically update the contributors list in the README
permissions:
contents: write
pull-requests: write
steps:
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@1ff4c56187458b34cd602aee93e897344ce34bfc #v2.3.10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+30 -6
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
@@ -32,29 +32,53 @@ jobs:
if: ${{ github.actor == 'dependabot[bot]' }}
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
contents: read
lint-rust:
name: Lint Rust
if: ${{ github.actor == 'dependabot[bot]' }}
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
contents: read
codeql:
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
security-events: write
contents: read
packages: read
actions: read
spellcheck:
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions:
contents: read
dependabot-automerge:
name: Dependabot Automerge
if: ${{ github.actor == 'dependabot[bot]' }}
needs: [security-scan, lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
runs-on: ubuntu-latest
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b #v2.4.0
secrets: inherit
with:
compat-lookup: true
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge minor and patch updates
uses: ridedott/merge-me-action@v2
uses: ridedott/merge-me-action@338053c6f9b9311a6be80208f6f0723981e40627 #v2.10.122
secrets: inherit
with:
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
PRESET: DEPENDABOT_MINOR
MERGE_METHOD: SQUASH
PRESET: DEPENDABOT_MINOR
MAXIMUM_RETRIES: 5
timeout-minutes: 10
+16
View File
@@ -0,0 +1,16 @@
name: Greetings
on: [pull_request_target, issues]
jobs:
greeting:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@34f15e814fe48ac9312ccf29db4e74fa767cbab7 #v1.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
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."
+173
View File
@@ -0,0 +1,173 @@
name: Issue Validation
on:
issues:
types: [opened]
permissions:
issues: write
models: read
jobs:
validate-issue:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Get issue templates
id: get-templates
run: |
# Read the issue templates
if [ -f ".github/ISSUE_TEMPLATE/01-bug-report.md" ]; then
echo "bug-template-exists=true" >> $GITHUB_OUTPUT
fi
if [ -f ".github/ISSUE_TEMPLATE/02-feature-request.md" ]; then
echo "feature-template-exists=true" >> $GITHUB_OUTPUT
fi
- name: Create issue analysis prompt
id: create-prompt
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
run: |
cat > issue_analysis.txt << EOF
## Issue Content to Analyze:
**Title:** $ISSUE_TITLE
**Body:**
$ISSUE_BODY
**Labels:** $ISSUE_LABELS
EOF
- name: Validate issue with AI
id: validate
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
with:
prompt-file: issue_analysis.txt
system-prompt: |
You are an issue validation assistant for Donut Browser, an browser orchestrator.
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
**For Bug Reports, the issue should include:**
1. Clear description of the problem
2. Steps to reproduce the issue (numbered list preferred)
3. Expected vs actual behavior
4. Environment information (OS, browser version, etc.)
5. Error messages, stack traces, or screenshots if applicable
**For Feature Requests, the issue should include:**
1. Clear description of the requested feature
2. Use case or problem it solves
3. Proposed solution or how it should work
4. Priority level or importance
**General Requirements for all issues:**
1. Descriptive title
2. Sufficient detail to understand and act upon
3. Professional tone and clear communication
Respond in JSON format with the following structure:
```json
{
"is_valid": true|false,
"issue_type": "bug_report"|"feature_request"|"other",
"missing_info": [
"List of missing required information"
],
"suggestions": [
"Specific suggestions for improvement"
],
"overall_assessment": "Brief assessment of the issue quality"
}
```
Be constructive and helpful in your feedback. If the issue is incomplete, provide specific guidance on what's needed.
model: gpt-4o
- name: Parse validation result and take action
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get the AI response
VALIDATION_RESULT='${{ steps.validate.outputs.response }}'
# Extract JSON from the response (handle potential markdown formatting)
JSON_RESULT=$(echo "$VALIDATION_RESULT" | sed -n '/```json/,/```/p' | sed '1d;$d' || echo "$VALIDATION_RESULT")
# Parse JSON fields
IS_VALID=$(echo "$JSON_RESULT" | jq -r '.is_valid // false')
ISSUE_TYPE=$(echo "$JSON_RESULT" | jq -r '.issue_type // "other"')
MISSING_INFO=$(echo "$JSON_RESULT" | jq -r '.missing_info[]? // empty' | sed 's/^/- /')
SUGGESTIONS=$(echo "$JSON_RESULT" | jq -r '.suggestions[]? // empty' | sed 's/^/- /')
ASSESSMENT=$(echo "$JSON_RESULT" | jq -r '.overall_assessment // "No assessment provided"')
echo "Issue validation result: $IS_VALID"
echo "Issue type: $ISSUE_TYPE"
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
# Post the comment
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
# Add a label to indicate validation needed
gh issue edit ${{ github.event.issue.number }} --add-label "needs-info"
echo "✅ Validation comment posted and 'needs-info' label added"
else
echo "✅ Issue contains sufficient information"
# Add appropriate labels based on issue type
case "$ISSUE_TYPE" in
"bug_report")
gh issue edit ${{ github.event.issue.number }} --add-label "bug"
;;
"feature_request")
gh issue edit ${{ github.event.issue.number }} --add-label "enhancement"
;;
esac
fi
- name: Cleanup
run: |
rm -f issue_analysis.txt comment.md
+7 -9
View File
@@ -16,6 +16,9 @@ on:
- ".github/workflows/lint-rs.yml"
- ".github/workflows/osv.yml"
permissions:
contents: read
jobs:
build:
strategy:
@@ -31,13 +34,13 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Set up Node.js v22
uses: actions/setup-node@v4
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -45,10 +48,5 @@ jobs:
- name: Install dependencies from lockfile
run: pnpm install --frozen-lockfile
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --frozen-lockfile
- name: Run lint step
run: pnpm run lint:js
+8 -9
View File
@@ -24,6 +24,9 @@ on:
- "tsconfig.json"
- "biome.json"
permissions:
contents: read
jobs:
build:
strategy:
@@ -39,20 +42,21 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
components: rustfmt, clippy
- name: Install cargo-audit
@@ -67,11 +71,6 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --frozen-lockfile
- name: Build nodecar binary
shell: bash
working-directory: ./nodecar
+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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
+5 -1
View File
@@ -16,16 +16,20 @@ jobs:
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
contents: read
lint-rust:
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
contents: read
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
@@ -0,0 +1,118 @@
name: Generate Release Notes
on:
release:
types: [published]
permissions:
contents: write
models: read
jobs:
generate-release-notes:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') && !github.event.release.prerelease
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
with:
fetch-depth: 0 # Fetch full history to compare with previous release
- name: Get previous release tag
id: get-previous-tag
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
echo "No previous release found, using initial commit"
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD)
fi
echo "current-tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
echo "previous-tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
echo "Previous release: $PREVIOUS_TAG"
echo "Current release: $CURRENT_TAG"
- name: Get commit messages between releases
id: get-commits
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)
# Get changed files summary
CHANGED_FILES=$(git diff --name-status $PREVIOUS_TAG..$CURRENT_TAG | head -20)
# Save to files for AI processing
echo "$COMMIT_LOG" > commits.txt
echo "$CHANGED_FILES" > changes.txt
echo "commits-file=commits.txt" >> $GITHUB_OUTPUT
echo "changes-file=changes.txt" >> $GITHUB_OUTPUT
- name: Generate release notes with AI
id: generate-notes
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
with:
prompt-file: commits.txt
system-prompt: |
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful browser orchestrator.
Analyze the provided commit messages and generate well-structured release notes following this format:
## What's New in ${{ steps.get-previous-tag.outputs.current-tag }}
[Brief 1-2 sentence overview of the release]
### ✨ New Features
[List new features with brief descriptions]
### 🐛 Bug Fixes
[List bug fixes]
### 🔧 Improvements
[List improvements and enhancements]
### 📚 Documentation
[List documentation updates if any]
### 🔄 Dependencies
[List dependency updates if any]
### 🛠️ Developer Experience
[List development-related changes if any]
Guidelines:
- Use clear, user-friendly language
- Group related commits logically
- Omit minor commits like formatting, typos unless significant
- Focus on user-facing changes
- Use emojis sparingly and consistently
- Keep descriptions concise but informative
- If commits are unclear, infer the purpose from the context
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
model: gpt-4o
- name: Update release with generated notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get the generated release notes
RELEASE_NOTES="${{ steps.generate-notes.outputs.response }}"
# Update the release with the generated notes
gh api --method PATCH /repos/${{ github.repository }}/releases/${{ github.event.release.id }} \
--field body="$RELEASE_NOTES"
echo "✅ Release notes updated successfully!"
- name: Cleanup
run: |
rm -f commits.txt changes.txt
+38 -15
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
@@ -31,14 +31,35 @@ jobs:
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
contents: read
lint-rust:
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
contents: read
codeql:
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
security-events: write
contents: read
packages: read
actions: read
spellcheck:
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions:
contents: read
release:
needs: [security-scan, lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
strategy:
@@ -69,14 +90,13 @@ jobs:
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
# Future platforms can be added here:
# - platform: "windows-latest"
# args: "--target x86_64-pc-windows-msvc"
# arch: "x86_64"
# target: "x86_64-pc-windows-msvc"
# pkg_target: "latest-win-x64"
# nodecar_script: "build:win-x64"
# - platform: "windows-latest"
# - platform: "windows-11-arm"
# args: "--target aarch64-pc-windows-msvc"
# arch: "aarch64"
# target: "aarch64-pc-windows-msvc"
@@ -85,19 +105,20 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only)
@@ -107,18 +128,13 @@ 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@v2
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
with:
workdir: ./src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --frozen-lockfile
- name: Build nodecar sidecar
shell: bash
working-directory: ./nodecar
@@ -139,7 +155,7 @@ jobs:
run: pnpm build
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REF_NAME: ${{ github.ref_name }}
@@ -150,3 +166,10 @@ jobs:
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}
- name: Commit CHANGELOG.md
uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 #v6.0.1
with:
branch: main
commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
file_pattern: CHANGELOG.md
+55 -21
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
@@ -30,14 +30,35 @@ jobs:
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
contents: read
lint-rust:
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
contents: read
codeql:
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
security-events: write
contents: read
packages: read
actions: read
spellcheck:
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions:
contents: read
rolling-release:
needs: [security-scan, lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
strategy:
@@ -68,22 +89,35 @@ jobs:
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
- platform: "windows-latest"
args: "--target x86_64-pc-windows-msvc"
arch: "x86_64"
target: "x86_64-pc-windows-msvc"
pkg_target: "latest-win-x64"
nodecar_script: "build:win-x64"
- platform: "windows-11-arm"
args: "--target aarch64-pc-windows-msvc"
arch: "aarch64"
target: "aarch64-pc-windows-msvc"
pkg_target: "latest-win-arm64"
nodecar_script: "build:win-arm64"
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only)
@@ -93,18 +127,13 @@ 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@v2
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
with:
workdir: ./src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --frozen-lockfile
- name: Build nodecar sidecar
shell: bash
working-directory: ./nodecar
@@ -124,21 +153,26 @@ jobs:
- name: Build frontend
run: pnpm build
- name: Get commit hash
id: commit
run: echo "hash=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Generate nightly timestamp
id: timestamp
shell: bash
run: |
TIMESTAMP=$(date -u +"%Y-%m-%d")
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_TAG: "nightly-${{ steps.commit.outputs.hash }}"
GITHUB_REF_NAME: "nightly-${{ steps.commit.outputs.hash }}"
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_SHA: ${{ github.sha }}
with:
tagName: "nightly-${{ steps.commit.outputs.hash }}"
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
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.commit.outputs.hash }}"
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 }}"
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}
+26
View File
@@ -0,0 +1,26 @@
name: Spell Check
permissions:
contents: read
on:
workflow_call:
push:
branches: ["main"]
pull_request:
branches: ["main"]
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
jobs:
spelling:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Spell Check Repo
uses: crate-ci/typos@392b78fe18a52790c53f42456e46124f77346842 #v1.34.0
+21
View File
@@ -0,0 +1,21 @@
name: Mark stale issues and pull requests
on:
schedule:
- cron: "35 23 * * *"
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
stale-pr-message: "This pull request has been inactive for 60 days. Please respond to keep it open."
stale-issue-label: "stale"
stale-pr-label: "stale"
-3
View File
@@ -46,7 +46,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
# eslint
.eslintcache
!**/.gitkeep
+47 -2
View File
@@ -1,30 +1,46 @@
{
"cSpell.words": [
"ahooks",
"akhilmhdh",
"appimage",
"appindicator",
"applescript",
"autoconfig",
"autologin",
"biomejs",
"camoufox",
"cdylib",
"CFURL",
"checkin",
"CLICOLOR",
"clippy",
"cmdk",
"codegen",
"CTYPE",
"datareporting",
"devedition",
"doesn",
"donutbrowser",
"dpkg",
"dtolnay",
"dyld",
"elif",
"errorlevel",
"esac",
"esbuild",
"eslintcache",
"frontmost",
"geoip",
"gettimezone",
"gifs",
"gsettings",
"healthreport",
"hkcu",
"icns",
"idletime",
"Inno",
"KHTML",
"launchservices",
"letterboxing",
"libatk",
"libayatana",
"libcairo",
@@ -33,16 +49,27 @@
"libpango",
"librsvg",
"libwebkit",
"libxdo",
"localtime",
"mmdb",
"mountpoint",
"msiexec",
"msvc",
"msys",
"Mullvad",
"mullvadbrowser",
"nodecar",
"nodemon",
"norestart",
"NSIS",
"ntlm",
"objc",
"orhun",
"osascript",
"peerconnection",
"pixbuf",
"plasmohq",
"prefs",
"propertylist",
"reqwest",
"ridedott",
@@ -53,21 +80,39 @@
"shadcn",
"signon",
"sonner",
"splitn",
"sspi",
"staticlib",
"stefanzweifel",
"subdirs",
"subkey",
"SUPPRESSMSGBOXES",
"swatinem",
"sysinfo",
"systempreferences",
"systemsetup",
"taskkill",
"tasklist",
"tauri",
"TERX",
"timedatectl",
"titlebar",
"Torbrowser",
"trackingprotection",
"turbopack",
"udeps",
"unlisten",
"unminimize",
"unrs",
"urlencoding",
"vercel",
"VERYSILENT",
"webgl",
"webrtc",
"winreg",
"wiremock",
"xattr",
"zhom"
"zhom",
"zoneinfo"
]
}
+6
View File
@@ -0,0 +1,6 @@
# Instructions for AI Agents
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
- Don't leave comments that don't add value
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
+1 -1
View File
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
## Enforcement
Violations of the Code of Conduct may be reported by pinging @zhom on Github. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
-37
View File
@@ -149,43 +149,6 @@ Refs #00000
- Ensure that "Allow edits from maintainers" option is checked
## Types of Contributions
### Bug Reports
When filing bug reports, please include:
- Clear description of the issue
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, version, etc.)
- Screenshots or error logs if applicable
### Feature Requests
When suggesting new features:
- Explain the use case and why it's valuable
- Describe the desired behavior
- Consider alternatives you've thought of
- Check if it aligns with our roadmap
### Code Contributions
- Bug fixes
- New features
- Performance improvements
- Documentation updates
- Test coverage improvements
### Documentation
- README improvements
- Code comments
- API documentation
- Tutorial content
- Translation work
## Architecture Overview
Donut Browser is built with:
+65 -11
View File
@@ -1,15 +1,47 @@
# Donut Browser
<div align="center">
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
<h1>Donut Browser</h1>
<strong>A powerful browser orchestrator that puts you in control of your browsing experience. 🍩</strong>
</div>
<br>
![Donut Browser Logo](assets/logo.png)
<p align="center">
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/v/release/zhom/donutbrowser">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/issues" target="_blank">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs Welcome">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
</a>
</p>
> **A powerful browser orchestrator that puts you in control of your browsing experience. 🍩**
## Donut Browser
[![GitHub Release](https://img.shields.io/github/v/release/zhom/donutbrowser)](https://github.com/zhom/donutbrowser/releases/latest)
[![RPs Welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](https://github.com/zhom/donutbrowser/issues)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](https://github.com/zhom/donutbrowser/blob/main/LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/zhom/donutbrowser?style=social)](https://github.com/zhom/donutbrowser/stargazers)
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
![Donut Browser Preview](assets/preview.png)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png" />
<img alt="Preview" src="assets/preview.png" />
</picture>
## Features
- Create unlimited number of local browser profiles completely isolated from each other
- Proxy support with basic auth for all browsers except for TOR Browser
- Import profiles from your existing browsers
- Automatic updates both for browsers and for the app itself
- Set Donut Browser as your default browser to control in which profile to open links
## Download
@@ -28,8 +60,6 @@ The app can be downloaded from the [releases page](https://github.com/zhom/donut
### Contributing
> Donut Browser is built with [Tauri](https://v2.tauri.app/).
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Issues
@@ -45,7 +75,31 @@ Have questions or want to contribute? We'd love to hear from you!
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date)](https://www.star-history.com/#zhom/donutbrowser&Date)
<a href="https://www.star-history.com/#zhom/donutbrowser&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
</picture>
</a>
## Contributors
<!-- readme: collaborators,contributors -start -->
<table>
<tbody>
<tr>
<td align="center">
<a href="https://github.com/zhom">
<img src="https://avatars.githubusercontent.com/u/2717306?v=4" width="100;" alt="zhom"/>
<br />
<sub><b>zhom</b></sub>
</a>
</td>
</tr>
<tbody>
</table>
<!-- readme: collaborators,contributors -end -->
## Contact
Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 540 KiB

+3 -17
View File
@@ -1,22 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
@@ -25,17 +21,7 @@
"useHookAtTopLevel": "error"
},
"nursery": {
"useGoogleFontDisplay": "error",
"noDocumentImportInPage": "error",
"noHeadElement": "error",
"noHeadImportInDocument": "error",
"noImgElement": "off",
"useComponentExportOnlyModules": {
"level": "error",
"options": {
"allowExportNames": ["metadata", "badgeVariants", "buttonVariants"]
}
}
"useUniqueElementIds": "off"
},
"a11y": {
"useSemanticElements": "off"
-133
View File
@@ -1,133 +0,0 @@
import { FlatCompat } from "@eslint/eslintrc";
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
const eslintConfig = tseslint.config(
eslint.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
...compat.extends("next/core-web-vitals"),
{
// Disabled rules taken from https://biomejs.dev/linter/rules-sources for ones that
// are already handled by Prettier and TypeScript or are not needed
rules: {
// eslint-plugin-jsx-a11y rules - some disabled for performance/specific project needs
"jsx-a11y/alt-text": "off",
"jsx-a11y/anchor-has-content": "off",
"jsx-a11y/anchor-is-valid": "off",
"jsx-a11y/aria-activedescendant-has-tabindex": "off",
"jsx-a11y/aria-props": "off",
"jsx-a11y/aria-proptypes": "off",
"jsx-a11y/aria-role": "off",
"jsx-a11y/aria-unsupported-elements": "off",
"jsx-a11y/autocomplete-valid": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/heading-has-content": "off",
"jsx-a11y/html-has-lang": "off",
"jsx-a11y/iframe-has-title": "off",
"jsx-a11y/img-redundant-alt": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/lang": "off",
"jsx-a11y/media-has-caption": "off",
"jsx-a11y/mouse-events-have-key-events": "off",
"jsx-a11y/no-access-key": "off",
"jsx-a11y/no-aria-hidden-on-focusable": "off",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/no-distracting-elements": "off",
"jsx-a11y/no-interactive-element-to-noninteractive-role": "off",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "off",
"jsx-a11y/no-noninteractive-tabindex": "off",
"jsx-a11y/no-redundant-roles": "off",
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"jsx-a11y/role-has-required-aria-props": "off",
"jsx-a11y/role-supports-aria-props": "off",
"jsx-a11y/scope": "off",
"jsx-a11y/tabindex-no-positive": "off",
// eslint-plugin-react rules - some disabled for performance/specific project needs
"react/button-has-type": "off",
"react/jsx-boolean-value": "off",
"react/jsx-curly-brace-presence": "off",
"react/jsx-fragments": "off",
"react/jsx-key": "off",
"react/jsx-no-comment-textnodes": "off",
"react/jsx-no-duplicate-props": "off",
"react/jsx-no-target-blank": "off",
"react/jsx-no-useless-fragment": "off",
"react/no-array-index-key": "off",
"react/no-children-prop": "off",
"react/no-danger": "off",
"react/no-danger-with-children": "off",
"react/void-dom-elements-no-children": "off",
// eslint-plugin-react-hooks rules - disabled for specific project needs
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
"@typescript-eslint/adjacent-overload-signatures": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-exports": "off",
"@typescript-eslint/consistent-type-imports": "off",
"@typescript-eslint/default-param-last": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-dupe-class-members": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extra-non-null-assertion": "off",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/no-loss-of-precision": "off",
"@typescript-eslint/no-misused-new": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-redeclare": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-restricted-imports": "off",
"@typescript-eslint/no-restricted-types": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unsafe-declaration-merging": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-useless-constructor": "off",
"@typescript-eslint/no-useless-empty-export": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/parameter-properties": "off",
"@typescript-eslint/prefer-as-const": "off",
"@typescript-eslint/prefer-enum-initializers": "off",
"@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/prefer-function-type": "off",
"@typescript-eslint/prefer-literal-enum-member": "off",
"@typescript-eslint/prefer-namespace-keyword": "off",
"@typescript-eslint/prefer-optional-chain": "off",
"@typescript-eslint/require-await": "off",
// Custom rules
"@typescript-eslint/restrict-template-expressions": [
"error",
{
allowNumber: true,
allowBoolean: true,
allowNever: true,
},
],
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
);
export default eslintConfig;
+5 -2
View File
@@ -21,5 +21,8 @@ if [ -z "$TARGET_TRIPLE" ]; then
exit 1
fi
# Copy the file
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
# Copy the file with target triple suffix
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
# Also copy a generic version for Tauri to find
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar${EXT}"
-131
View File
@@ -1,131 +0,0 @@
import { FlatCompat } from "@eslint/eslintrc";
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
const eslintConfig = tseslint.config(
eslint.configs.recommended,
...compat.extends("next/core-web-vitals"),
{
// Disabled rules taken from https://biomejs.dev/linter/rules-sources for ones that
// are already handled by Prettier and TypeScript or are not needed
rules: {
// eslint-plugin-jsx-a11y rules - some disabled for performance/specific project needs
"jsx-a11y/alt-text": "off",
"jsx-a11y/anchor-has-content": "off",
"jsx-a11y/anchor-is-valid": "off",
"jsx-a11y/aria-activedescendant-has-tabindex": "off",
"jsx-a11y/aria-props": "off",
"jsx-a11y/aria-proptypes": "off",
"jsx-a11y/aria-role": "off",
"jsx-a11y/aria-unsupported-elements": "off",
"jsx-a11y/autocomplete-valid": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/heading-has-content": "off",
"jsx-a11y/html-has-lang": "off",
"jsx-a11y/iframe-has-title": "off",
"jsx-a11y/img-redundant-alt": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/lang": "off",
"jsx-a11y/media-has-caption": "off",
"jsx-a11y/mouse-events-have-key-events": "off",
"jsx-a11y/no-access-key": "off",
"jsx-a11y/no-aria-hidden-on-focusable": "off",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/no-distracting-elements": "off",
"jsx-a11y/no-interactive-element-to-noninteractive-role": "off",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "off",
"jsx-a11y/no-noninteractive-tabindex": "off",
"jsx-a11y/no-redundant-roles": "off",
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"jsx-a11y/role-has-required-aria-props": "off",
"jsx-a11y/role-supports-aria-props": "off",
"jsx-a11y/scope": "off",
"jsx-a11y/tabindex-no-positive": "off",
// eslint-plugin-react rules - some disabled for performance/specific project needs
"react/button-has-type": "off",
"react/jsx-boolean-value": "off",
"react/jsx-curly-brace-presence": "off",
"react/jsx-fragments": "off",
"react/jsx-key": "off",
"react/jsx-no-comment-textnodes": "off",
"react/jsx-no-duplicate-props": "off",
"react/jsx-no-target-blank": "off",
"react/jsx-no-useless-fragment": "off",
"react/no-array-index-key": "off",
"react/no-children-prop": "off",
"react/no-danger": "off",
"react/no-danger-with-children": "off",
"react/void-dom-elements-no-children": "off",
// eslint-plugin-react-hooks rules - disabled for specific project needs
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
"@typescript-eslint/adjacent-overload-signatures": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-exports": "off",
"@typescript-eslint/consistent-type-imports": "off",
"@typescript-eslint/default-param-last": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-dupe-class-members": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extra-non-null-assertion": "off",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/no-loss-of-precision": "off",
"@typescript-eslint/no-misused-new": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-redeclare": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-restricted-imports": "off",
"@typescript-eslint/no-restricted-types": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unsafe-declaration-merging": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-useless-constructor": "off",
"@typescript-eslint/no-useless-empty-export": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/parameter-properties": "off",
"@typescript-eslint/prefer-as-const": "off",
"@typescript-eslint/prefer-enum-initializers": "off",
"@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/prefer-function-type": "off",
"@typescript-eslint/prefer-literal-enum-member": "off",
"@typescript-eslint/prefer-namespace-keyword": "off",
"@typescript-eslint/prefer-optional-chain": "off",
"@typescript-eslint/require-await": "off",
// Custom rules
"@typescript-eslint/restrict-template-expressions": [
"error",
{
allowNumber: true,
allowBoolean: true,
allowNever: true,
},
],
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
}
);
export default eslintConfig;
+10 -4
View File
@@ -2,11 +2,12 @@
"name": "nodecar",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"main": "dist/index.js",
"scripts": {
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
"dev": "node --loader ts-node/esm ./src/index.ts",
"start": "node --loader ts-node/esm ./src/index.ts",
"start": "tsc && node ./dist/index.js",
"test": "tsc && node ./dist/test-proxy.js",
"rename-binary": "sh ./copy-binary.sh",
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
"build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
@@ -20,14 +21,19 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^22.15.30",
"@types/node": "^24.0.10",
"@yao-pkg/pkg": "^6.5.1",
"camoufox-js": "^0.6.0",
"commander": "^14.0.0",
"dotenv": "^16.5.0",
"dotenv": "^17.0.1",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"proxy-chain": "^2.5.9",
"tmp": "^0.2.3",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/tmp": "^0.2.6"
}
}
+503
View File
@@ -0,0 +1,503 @@
import { spawn } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
export interface CamoufoxConfig {
id: string;
pid?: number;
executablePath: string;
profilePath: string;
url?: string;
options: CamoufoxLaunchOptions;
}
export interface CamoufoxLaunchOptions {
// Operating system to use for fingerprint generation
os?: "windows" | "macos" | "linux" | string[];
// Blocking options
block_images?: boolean;
block_webrtc?: boolean;
block_webgl?: boolean;
// Security options
disable_coop?: boolean;
// Geolocation options
geoip?: string | boolean;
// UI behavior
humanize?: boolean | number;
// Localization
locale?: string | string[];
// Extensions and fonts
addons?: string[];
fonts?: string[];
custom_fonts_only?: boolean;
exclude_addons?: string[];
// Screen and window
screen?: {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
};
window?: [number, number];
// Fingerprint
fingerprint?: any;
// Version and mode
ff_version?: number;
headless?: boolean;
main_world_eval?: boolean;
// Custom executable path
executable_path?: string;
// Firefox preferences
firefox_user_prefs?: Record<string, any>;
// Proxy settings
proxy?:
| string
| {
server: string;
username?: string;
password?: string;
bypass?: string;
};
// Cache and performance
enable_cache?: boolean;
// Additional options
args?: string[];
env?: Record<string, string | number | boolean>;
debug?: boolean;
virtual_display?: string;
webgl_config?: [string, string];
// Custom options
timezone?: string;
country?: string;
geolocation?: {
latitude: number;
longitude: number;
accuracy?: number;
};
}
// Store for active Camoufox processes
const activeCamoufoxProcesses = new Map<string, CamoufoxConfig>();
/**
* Generate a unique ID for the Camoufox instance
*/
function generateCamoufoxId(): string {
return `camoufox_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Save Camoufox configuration to storage
*/
function saveCamoufoxConfig(config: CamoufoxConfig): void {
try {
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const configFile = path.join(configDir, `${config.id}.json`);
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
activeCamoufoxProcesses.set(config.id, config);
} catch (error) {
console.error(`Failed to save Camoufox config: ${error}`);
}
}
/**
* Load Camoufox configuration from storage
*/
function loadCamoufoxConfig(id: string): CamoufoxConfig | null {
try {
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
if (fs.existsSync(configFile)) {
const config = JSON.parse(fs.readFileSync(configFile, "utf8"));
activeCamoufoxProcesses.set(id, config);
return config;
}
} catch (error) {
console.error(`Failed to load Camoufox config: ${error}`);
}
return null;
}
/**
* Delete Camoufox configuration from storage
*/
function deleteCamoufoxConfig(id: string): boolean {
try {
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
if (fs.existsSync(configFile)) {
fs.unlinkSync(configFile);
}
activeCamoufoxProcesses.delete(id);
return true;
} catch (error) {
console.error(`Failed to delete Camoufox config: ${error}`);
return false;
}
}
/**
* Load all Camoufox configurations on startup
*/
function loadAllCamoufoxConfigs(): void {
try {
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
if (fs.existsSync(configDir)) {
const files = fs.readdirSync(configDir);
for (const file of files) {
if (file.endsWith(".json")) {
const id = path.basename(file, ".json");
loadCamoufoxConfig(id);
}
}
}
} catch (error) {
console.error(`Failed to load Camoufox configs: ${error}`);
}
}
/**
* Check if a process is still running
*/
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
/**
* Convert Camoufox options to command line arguments
*/
function buildCamoufoxArgs(
options: CamoufoxLaunchOptions,
profilePath: string,
url?: string,
): string[] {
const args: string[] = [];
// Always use profile
args.push("-profile", profilePath);
// Cache enabled by default as requested
if (options.enable_cache !== false) {
// Cache is enabled by default in Camoufox, no special args needed
}
// Headless mode
if (options.headless) {
args.push("-headless");
}
// No remote for security (anti-detect)
args.push("-no-remote");
// Custom Firefox user preferences will be written to user.js in profile
// Additional custom args
if (options.args) {
args.push(...options.args);
}
// URL to open
if (url) {
args.push(url);
}
return args;
}
/**
* Create user.js file with Camoufox preferences
*/
function createUserJs(
profilePath: string,
options: CamoufoxLaunchOptions,
): void {
const preferences: string[] = [];
// Anti-detect preferences
preferences.push('user_pref("privacy.resistFingerprinting", true);');
preferences.push(
'user_pref("privacy.resistFingerprinting.letterboxing", true);',
);
preferences.push('user_pref("privacy.trackingprotection.enabled", true);');
// Disable telemetry and data collection
preferences.push(
'user_pref("datareporting.healthreport.uploadEnabled", false);',
);
preferences.push(
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
);
preferences.push('user_pref("toolkit.telemetry.enabled", false);');
preferences.push('user_pref("toolkit.telemetry.unified", false);');
// Block options
if (options.block_images) {
preferences.push('user_pref("permissions.default.image", 2);');
}
if (options.block_webrtc) {
preferences.push('user_pref("media.peerconnection.enabled", false);');
preferences.push('user_pref("media.navigator.enabled", false);');
}
if (options.block_webgl) {
preferences.push('user_pref("webgl.disabled", true);');
preferences.push('user_pref("webgl.disable-extensions", true);');
}
// COOP settings
if (options.disable_coop) {
preferences.push(
'user_pref("browser.tabs.remote.useCrossOriginOpenerPolicy", false);',
);
}
// Locale settings
if (options.locale) {
const localeStr = Array.isArray(options.locale)
? options.locale[0]
: options.locale;
preferences.push(`user_pref("intl.locale.requested", "${localeStr}");`);
preferences.push(`user_pref("general.useragent.locale", "${localeStr}");`);
}
// Timezone
if (options.timezone) {
preferences.push(
`user_pref("privacy.resistFingerprinting.timezone", "${options.timezone}");`,
);
}
// Custom Firefox preferences
if (options.firefox_user_prefs) {
for (const [key, value] of Object.entries(options.firefox_user_prefs)) {
if (typeof value === "string") {
preferences.push(`user_pref("${key}", "${value}");`);
} else if (typeof value === "boolean") {
preferences.push(`user_pref("${key}", ${value});`);
} else if (typeof value === "number") {
preferences.push(`user_pref("${key}", ${value});`);
}
}
}
// Proxy settings
if (options.proxy) {
if (typeof options.proxy === "string") {
// Parse proxy URL
try {
const proxyUrl = new URL(options.proxy);
const port =
parseInt(proxyUrl.port) ||
(proxyUrl.protocol === "https:" ? 443 : 80);
if (proxyUrl.protocol.startsWith("socks")) {
preferences.push('user_pref("network.proxy.type", 1);');
preferences.push(
`user_pref("network.proxy.socks", "${proxyUrl.hostname}");`,
);
preferences.push(`user_pref("network.proxy.socks_port", ${port});`);
if (proxyUrl.protocol === "socks5:") {
preferences.push('user_pref("network.proxy.socks_version", 5);');
} else {
preferences.push('user_pref("network.proxy.socks_version", 4);');
}
} else {
preferences.push('user_pref("network.proxy.type", 1);');
preferences.push(
`user_pref("network.proxy.http", "${proxyUrl.hostname}");`,
);
preferences.push(`user_pref("network.proxy.http_port", ${port});`);
preferences.push(
`user_pref("network.proxy.ssl", "${proxyUrl.hostname}");`,
);
preferences.push(`user_pref("network.proxy.ssl_port", ${port});`);
}
if (proxyUrl.username && proxyUrl.password) {
// Note: Basic auth for proxies is handled differently in modern Firefox
preferences.push(
'user_pref("network.proxy.allow_hijacking_localhost", true);',
);
}
} catch (error) {
console.error(`Invalid proxy URL: ${options.proxy}`);
}
}
}
// Geolocation
if (options.geolocation) {
preferences.push('user_pref("geo.enabled", true);');
preferences.push(
`user_pref("geo.wifi.uri", "data:application/json,{\\"location\\": {\\"lat\\": ${options.geolocation.latitude}, \\"lng\\": ${options.geolocation.longitude}}, \\"accuracy\\": ${options.geolocation.accuracy || 100}}");`,
);
} else {
preferences.push('user_pref("geo.enabled", false);');
}
// Write user.js file
const userJsPath = path.join(profilePath, "user.js");
fs.writeFileSync(userJsPath, preferences.join("\n"));
}
/**
* Launch Camoufox browser with specified options
*/
export async function launchCamoufox(
executablePath: string,
profilePath: string,
options: CamoufoxLaunchOptions = {},
url?: string,
): Promise<CamoufoxConfig> {
const id = generateCamoufoxId();
// Ensure profile directory exists
if (!fs.existsSync(profilePath)) {
fs.mkdirSync(profilePath, { recursive: true });
}
// Create user.js with preferences
createUserJs(profilePath, options);
// Build command line arguments
const args = buildCamoufoxArgs(options, profilePath, url);
// Prepare environment variables
const env = {
...process.env,
...options.env,
};
// Handle virtual display
if (options.virtual_display) {
env.DISPLAY = options.virtual_display;
}
// Launch the process
const child = spawn(executablePath, args, {
env: env as NodeJS.ProcessEnv,
detached: true,
stdio: options.debug ? "inherit" : "ignore",
});
if (!child.pid) {
throw new Error("Failed to launch Camoufox process");
}
const config: CamoufoxConfig = {
id,
pid: child.pid,
executablePath,
profilePath,
url,
options,
};
// Save configuration
saveCamoufoxConfig(config);
// Handle process exit
child.on("exit", (code, signal) => {
console.log(
`Camoufox process ${child.pid} exited with code ${code}, signal ${signal}`,
);
deleteCamoufoxConfig(id);
});
child.on("error", (error) => {
console.error(`Camoufox process error: ${error}`);
deleteCamoufoxConfig(id);
});
// Detach the child process so it can continue running independently
child.unref();
return config;
}
/**
* Stop a Camoufox process by ID
*/
export async function stopCamoufox(id: string): Promise<boolean> {
const config = activeCamoufoxProcesses.get(id) || loadCamoufoxConfig(id);
if (!config || !config.pid) {
return false;
}
try {
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGTERM");
// Wait a moment for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 2000));
// Force kill if still running
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGKILL");
}
}
deleteCamoufoxConfig(id);
return true;
} catch (error) {
console.error(`Failed to stop Camoufox process: ${error}`);
return false;
}
}
/**
* List all Camoufox processes
*/
export function listCamoufoxProcesses(): any[] {
loadAllCamoufoxConfigs();
// Filter out dead processes
const activeConfigs: any[] = [];
for (const [id, config] of activeCamoufoxProcesses) {
if (config.pid && isProcessRunning(config.pid)) {
// Return in snake_case format for Rust compatibility
activeConfigs.push({
id: config.id,
pid: config.pid,
executable_path: config.executablePath,
profile_path: config.profilePath,
url: config.url,
options: config.options,
});
} else {
// Clean up dead processes
deleteCamoufoxConfig(id);
}
}
return activeConfigs;
}
// Load existing configurations on module initialization
loadAllCamoufoxConfigs();
+349 -23
View File
@@ -1,8 +1,13 @@
import { program } from "commander";
import {
launchCamoufox,
listCamoufoxProcesses,
stopCamoufox,
} from "./camoufox-launcher";
import {
startProxyProcess,
stopProxyProcess,
stopAllProxyProcesses,
stopProxyProcess,
} from "./proxy-runner";
import { listProxyConfigs } from "./proxy-storage";
import { runProxyWorker } from "./proxy-worker";
@@ -11,79 +16,127 @@ import { runProxyWorker } from "./proxy-worker";
program
.command("proxy")
.argument("<action>", "start, stop, or list proxies")
.option(
"-u, --upstream <url>",
"upstream proxy URL (protocol://[username:password@]host:port)"
)
.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
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: {
upstream?: string;
host?: string;
proxyPort?: number;
type?: string;
username?: string;
password?: string;
port?: number;
ignoreCertificate?: boolean;
id?: string;
}
upstream?: string;
},
) => {
if (action === "start") {
if (!options.upstream) {
console.error("Error: Upstream proxy URL is required");
console.log(
"Example: proxy start -u http://username:password@proxy.example.com:8080"
let upstreamUrl: string;
// Build upstream URL from individual components if provided
if (options.host && options.proxyPort && options.type) {
const protocol =
options.type === "socks4" || options.type === "socks5"
? options.type
: "http";
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;
} else {
console.error(
"Error: Either --upstream URL or --host, --proxy-port, and --type are required",
);
console.log(
"Example: proxy start --host datacenter.proxyempire.io --proxy-port 9000 --type http --username user --password pass",
);
process.exit(1);
return;
}
try {
const config = await startProxyProcess(options.upstream, {
const config = await startProxyProcess(upstreamUrl, {
port: options.port,
ignoreProxyCertificate: options.ignoreCertificate,
});
console.log(JSON.stringify(config));
// 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: ${JSON.stringify(error)}`);
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(`{
"success": ${stopped}}`);
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
(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(`{
"success": ${stopped}}`);
console.log(JSON.stringify({ success: stopped }));
}
} else {
await stopAllProxyProcesses();
console.log(`{
"success": true}`);
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)
@@ -101,4 +154,277 @@ program
}
});
// Command for Camoufox browser orchestrator
program
.command("camoufox")
.argument("<action>", "launch, stop, list, or open-url for Camoufox browser")
.requiredOption("--executable-path <path>", "path to Camoufox executable")
.requiredOption("--profile-path <path>", "path to browser profile directory")
.option("--url <url>", "URL to open")
.option("--id <id>", "Camoufox instance ID (for stop/open-url actions)")
// Operating system fingerprinting
.option(
"--os <os>",
"OS to emulate (windows, macos, linux, or comma-separated list)",
)
// Blocking options
.option("--block-images", "block all images")
.option("--block-webrtc", "block WebRTC entirely")
.option("--block-webgl", "block WebGL")
// Security options
.option("--disable-coop", "disable Cross-Origin-Opener-Policy")
// Geolocation and IP
.option(
"--geoip <ip>",
"IP address for geolocation spoofing (or 'auto' for automatic)",
)
.option("--country <country>", "country code for geolocation")
.option("--timezone <timezone>", "timezone to spoof")
.option("--latitude <lat>", "latitude for geolocation", parseFloat)
.option("--longitude <lng>", "longitude for geolocation", parseFloat)
// UI and behavior
.option(
"--humanize [duration]",
"humanize cursor movement (optional max duration in seconds)",
(val) => (val ? parseFloat(val) : true),
)
.option("--headless", "run in headless mode")
// Localization
.option("--locale <locale>", "locale(s) to use (comma-separated)")
// Extensions and fonts
.option("--addons <addons>", "Firefox addons to load (comma-separated paths)")
.option("--fonts <fonts>", "additional fonts to load (comma-separated)")
.option("--custom-fonts-only", "use only custom fonts, exclude OS fonts")
.option(
"--exclude-addons <addons>",
"default addons to exclude (comma-separated)",
)
// Screen and window
.option("--screen-min-width <width>", "minimum screen width", parseInt)
.option("--screen-max-width <width>", "maximum screen width", parseInt)
.option("--screen-min-height <height>", "minimum screen height", parseInt)
.option("--screen-max-height <height>", "maximum screen height", parseInt)
.option("--window-width <width>", "fixed window width", parseInt)
.option("--window-height <height>", "fixed window height", parseInt)
// Advanced options
.option("--ff-version <version>", "Firefox version to emulate", parseInt)
.option("--main-world-eval", "enable main world script evaluation")
.option("--webgl-vendor <vendor>", "WebGL vendor string")
.option("--webgl-renderer <renderer>", "WebGL renderer string")
// Proxy
.option(
"--proxy <proxy>",
"proxy URL (protocol://[username:password@]host:port)",
)
// Cache and performance
.option("--disable-cache", "disable browser cache (cache enabled by default)")
// Environment and debugging
.option("--virtual-display <display>", "virtual display number (e.g., :99)")
.option("--debug", "enable debug output")
.option("--args <args>", "additional browser arguments (comma-separated)")
.option("--env <env>", "environment variables (JSON string)")
// Firefox preferences
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
.description("launch and manage Camoufox browser orchestrator instances")
.action(async (action: string, options: any) => {
try {
if (action === "launch") {
// Validate required options
if (!options.executablePath || !options.profilePath) {
console.error(
"Error: --executable-path and --profile-path are required for launch",
);
process.exit(1);
return;
}
// Build Camoufox options
const camoufoxOptions: any = {
enable_cache: !options.disableCache, // Cache enabled by default as requested
};
// OS fingerprinting
if (options.os) {
camoufoxOptions.os = options.os.includes(",")
? options.os.split(",")
: options.os;
}
// Blocking options
if (options.blockImages) camoufoxOptions.block_images = true;
if (options.blockWebrtc) camoufoxOptions.block_webrtc = true;
if (options.blockWebgl) camoufoxOptions.block_webgl = true;
// Security options
if (options.disableCoop) camoufoxOptions.disable_coop = true;
// Geolocation
if (options.geoip) {
camoufoxOptions.geoip =
options.geoip === "auto" ? true : options.geoip;
}
if (options.latitude && options.longitude) {
camoufoxOptions.geolocation = {
latitude: options.latitude,
longitude: options.longitude,
accuracy: 100,
};
}
if (options.country) camoufoxOptions.country = options.country;
if (options.timezone) camoufoxOptions.timezone = options.timezone;
// UI and behavior
if (options.humanize) camoufoxOptions.humanize = options.humanize;
if (options.headless) camoufoxOptions.headless = true;
// Localization
if (options.locale) {
camoufoxOptions.locale = options.locale.includes(",")
? options.locale.split(",")
: options.locale;
}
// Extensions and fonts
if (options.addons) camoufoxOptions.addons = options.addons.split(",");
if (options.fonts) camoufoxOptions.fonts = options.fonts.split(",");
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
if (options.excludeAddons)
camoufoxOptions.exclude_addons = options.excludeAddons.split(",");
// Screen and window
const screen: any = {};
if (options.screenMinWidth) screen.minWidth = options.screenMinWidth;
if (options.screenMaxWidth) screen.maxWidth = options.screenMaxWidth;
if (options.screenMinHeight) screen.minHeight = options.screenMinHeight;
if (options.screenMaxHeight) screen.maxHeight = options.screenMaxHeight;
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
if (options.windowWidth && options.windowHeight) {
camoufoxOptions.window = [options.windowWidth, options.windowHeight];
}
// Advanced options
if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion;
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
if (options.webglVendor && options.webglRenderer) {
camoufoxOptions.webgl_config = [
options.webglVendor,
options.webglRenderer,
];
}
// Proxy
if (options.proxy) camoufoxOptions.proxy = options.proxy;
// Environment and debugging
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay;
if (options.debug) camoufoxOptions.debug = true;
if (options.args) camoufoxOptions.args = options.args.split(",");
if (options.env) {
try {
camoufoxOptions.env = JSON.parse(options.env);
} catch (e) {
console.error("Invalid JSON for --env option");
process.exit(1);
return;
}
}
// Firefox preferences
if (options.firefoxPrefs) {
try {
camoufoxOptions.firefox_user_prefs = JSON.parse(
options.firefoxPrefs,
);
} catch (e) {
console.error("Invalid JSON for --firefox-prefs option");
process.exit(1);
return;
}
}
// Launch Camoufox
const config = await launchCamoufox(
options.executablePath,
options.profilePath,
camoufoxOptions,
options.url,
);
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: config.id,
pid: config.pid,
executable_path: config.executablePath,
profile_path: config.profilePath,
url: config.url,
}),
);
process.exit(0);
} else if (action === "stop") {
if (!options.id) {
console.error("Error: --id is required for stop action");
process.exit(1);
return;
}
const success = await stopCamoufox(options.id);
console.log(JSON.stringify({ success }));
process.exit(0);
} else if (action === "list") {
const processes = listCamoufoxProcesses();
// Convert camelCase to snake_case for Rust compatibility
const rustCompatibleProcesses = processes.map((process) => ({
id: process.id,
pid: process.pid,
executable_path: process.executablePath,
profile_path: process.profilePath,
url: process.url,
}));
console.log(JSON.stringify(rustCompatibleProcesses));
process.exit(0);
} else if (action === "open-url") {
if (!options.id || !options.url) {
console.error(
"Error: --id and --url are required for open-url action",
);
process.exit(1);
return;
}
// This would require implementing URL opening in existing instance
// For now, we'll return an error as this feature would need additional implementation
console.error("open-url action is not yet implemented");
process.exit(1);
} else {
console.error(
"Invalid action. Use 'launch', 'stop', 'list', or 'open-url'",
);
process.exit(1);
}
} catch (error: unknown) {
console.error(
`Camoufox command failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
process.exit(1);
}
});
program.parse();
+38 -26
View File
@@ -3,12 +3,12 @@ import path from "node:path";
import getPort from "get-port";
import {
type ProxyConfig,
saveProxyConfig,
getProxyConfig,
deleteProxyConfig,
isProcessRunning,
generateProxyId,
getProxyConfig,
isProcessRunning,
listProxyConfigs,
saveProxyConfig,
} from "./proxy-storage";
/**
@@ -19,50 +19,53 @@ import {
*/
export async function startProxyProcess(
upstreamUrl: string,
options: { port?: number; ignoreProxyCertificate?: boolean } = {}
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());
const port = options.port ?? (await getPort());
// Create the proxy configuration
const config: ProxyConfig = {
id,
upstreamUrl,
localPort: port,
ignoreProxyCertificate: options.ignoreProxyCertificate || false,
ignoreProxyCertificate: options.ignoreProxyCertificate ?? false,
};
// Save the configuration before starting the process
saveProxyConfig(config);
// Build the command arguments
const args = ["proxy-worker", "start", "--id", id];
const args = [
path.join(__dirname, "index.js"),
"proxy-worker",
"start",
"--id",
id,
];
// Spawn the process
const child = spawn(
process.execPath,
[path.join(__dirname, "index.js"), ...args],
{
detached: true,
stdio: "ignore",
}
);
// 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
// Store the process ID and local URL
config.pid = child.pid;
config.localUrl = `http://localhost:${port}`;
config.localUrl = `http://127.0.0.1:${port}`;
// Update the configuration with the process ID
saveProxyConfig(config);
// Wait a bit to ensure the proxy has started
await new Promise((resolve) => setTimeout(resolve, 500));
// Give the worker a moment to start before returning
await new Promise((resolve) => setTimeout(resolve, 100));
return config;
}
@@ -75,7 +78,9 @@ export async function startProxyProcess(
export async function stopProxyProcess(id: string): Promise<boolean> {
const config = getProxyConfig(id);
if (!config || !config.pid) {
if (!config?.pid) {
// Try to delete the config anyway in case it exists without a PID
deleteProxyConfig(id);
return false;
}
@@ -83,10 +88,16 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
// Check if the process is running
if (isProcessRunning(config.pid)) {
// Send SIGTERM to the process
process.kill(config.pid);
process.kill(config.pid, "SIGTERM");
// Wait a bit to ensure the process has terminated
await new Promise((resolve) => setTimeout(resolve, 300));
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
@@ -95,6 +106,8 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
return true;
} catch (error) {
console.error(`Error stopping proxy ${id}:`, error);
// Delete the configuration even if stopping failed
deleteProxyConfig(id);
return false;
}
}
@@ -106,7 +119,6 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
export async function stopAllProxyProcesses(): Promise<void> {
const configs = listProxyConfigs();
for (const config of configs) {
await stopProxyProcess(config.id);
}
const stopPromises = configs.map((config) => stopProxyProcess(config.id));
await Promise.all(stopPromises);
}
+12 -11
View File
@@ -1,8 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import tmp from "tmp";
// Define the proxy configuration type
export interface ProxyConfig {
id: string;
upstreamUrl: string;
@@ -12,10 +11,8 @@ export interface ProxyConfig {
pid?: number;
}
// Path to store proxy configurations
const STORAGE_DIR = path.join(os.tmpdir(), "donutbrowser", "proxies");
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "proxies");
// Ensure storage directory exists
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
@@ -88,7 +85,7 @@ export function listProxyConfigs(): ProxyConfig[] {
try {
const content = fs.readFileSync(
path.join(STORAGE_DIR, file),
"utf-8"
"utf-8",
);
return JSON.parse(content) as ProxyConfig;
} catch (error) {
@@ -111,14 +108,18 @@ export function listProxyConfigs(): ProxyConfig[] {
export function updateProxyConfig(config: ProxyConfig): boolean {
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
if (!fs.existsSync(filePath)) {
return false;
}
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;
}
@@ -135,7 +136,7 @@ export function isProcessRunning(pid: number): boolean {
// but checks if it exists
process.kill(pid, 0);
return true;
} catch (error) {
} catch {
return false;
}
}
+41 -17
View File
@@ -1,5 +1,5 @@
import { Server } from "proxy-chain";
import { getProxyConfig } from "./proxy-storage";
import { getProxyConfig, updateProxyConfig } from "./proxy-storage";
/**
* Run a proxy server as a worker process
@@ -8,44 +8,68 @@ import { getProxyConfig } from "./proxy-storage";
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: "localhost",
host: "127.0.0.1",
prepareRequestFunction: () => {
return {
upstreamProxyUrl: config.upstreamUrl,
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate || false,
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false,
};
},
});
// Handle process termination
process.on("SIGTERM", async () => {
console.log(`Proxy worker ${id} received SIGTERM, shutting down...`);
await server.close(true);
// Handle process termination gracefully
const gracefulShutdown = async (signal: string) => {
console.log(`Proxy worker ${id} received ${signal}, shutting down...`);
try {
await server.close(true);
console.log(`Proxy worker ${id} shut down successfully`);
} catch (error) {
console.error(`Error during shutdown for proxy ${id}:`, error);
}
process.exit(0);
};
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
console.error(`Uncaught exception in proxy worker ${id}:`, error);
process.exit(1);
});
process.on("SIGINT", async () => {
console.log(`Proxy worker ${id} received SIGINT, shutting down...`);
await server.close(true);
process.exit(0);
process.on("unhandledRejection", (reason) => {
console.error(`Unhandled rejection in proxy worker ${id}:`, reason);
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);
console.log(`Proxy worker ${id} started on port ${server.port}`);
console.log(`Forwarding to upstream proxy: ${config.upstreamUrl}`);
// 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);
}
}
}
-73
View File
@@ -1,73 +0,0 @@
import {
startProxyProcess,
stopProxyProcess,
stopAllProxyProcesses
} from "./proxy-runner";
import { listProxyConfigs } from "./proxy-storage";
// Type definitions
interface ProxyOptions {
port?: number;
ignoreProxyCertificate?: boolean;
}
/**
* Start a local proxy server that forwards to an upstream proxy
* @param upstreamProxyUrl The upstream proxy URL (protocol://[username:password@]host:port)
* @param options Optional configuration
* @returns Promise resolving to the local proxy URL
*/
export async function startProxy(
upstreamProxyUrl: string,
options: ProxyOptions = {}
): Promise<string> {
const config = await startProxyProcess(upstreamProxyUrl, {
port: options.port,
ignoreProxyCertificate: options.ignoreProxyCertificate,
});
return config.localUrl || `http://localhost:${config.localPort}`;
}
/**
* Stop a specific proxy by its upstream URL
* @param upstreamProxyUrl The upstream proxy URL to stop
* @returns Promise resolving to true if proxy was found and stopped, false otherwise
*/
export async function stopProxy(upstreamProxyUrl: string): Promise<boolean> {
// Find all proxies with this upstream URL
const configs = listProxyConfigs().filter(
config => config.upstreamUrl === upstreamProxyUrl
);
if (configs.length === 0) {
return false;
}
// Stop all matching proxies
let success = true;
for (const config of configs) {
const stopped = await stopProxyProcess(config.id);
if (!stopped) {
success = false;
}
}
return success;
}
/**
* Get a list of all active proxy upstream URLs
* @returns Array of upstream proxy URLs
*/
export function getActiveProxies(): string[] {
return listProxyConfigs().map(config => config.upstreamUrl);
}
/**
* Stop all active proxies
* @returns Promise that resolves when all proxies are stopped
*/
export async function stopAllProxies(): Promise<void> {
await stopAllProxyProcesses();
}
+28 -32
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.3.0",
"version": "0.7.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -11,15 +11,16 @@
"test": "pnpm test:rust",
"test:rust": "cd src-tauri && cargo test",
"lint": "pnpm lint:js && pnpm lint:rust",
"lint:js": "biome check src/ && tsc --noEmit && next lint",
"lint:js": "biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"format:js": "biome check src/ --fix",
"format:js": "biome check src/ --write --unsafe",
"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 run --bin check_unused_commands"
},
"dependencies": {
@@ -32,51 +33,46 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "~2.3.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/api": "^2.6.0",
"@tauri-apps/plugin-deep-link": "^2.4.0",
"@tauri-apps/plugin-dialog": "^2.3.0",
"@tauri-apps/plugin-fs": "~2.4.0",
"@tauri-apps/plugin-opener": "^2.4.0",
"ahooks": "^3.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"next": "^15.3.3",
"next": "^15.3.5",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.0"
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.28.0",
"@next/eslint-plugin-next": "^15.3.3",
"@tailwindcss/postcss": "^4.1.8",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.15.30",
"@types/react": "^19.1.6",
"@biomejs/biome": "2.0.6",
"@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2.6.2",
"@types/node": "^24.0.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"@vitejs/plugin-react": "^4.5.1",
"eslint": "^9.28.0",
"eslint-config-next": "^15.3.3",
"eslint-plugin-react-hooks": "^5.2.0",
"@vitejs/plugin-react": "^4.6.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"tailwindcss": "^4.1.8",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.33.1"
"lint-staged": "^16.1.2",
"tailwindcss": "^4.1.11",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3"
},
"packageManager": "pnpm@10.11.1",
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix",
"eslint --cache --fix"
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix"
],
"src-tauri/**/*.rs": [
"cd src-tauri && cargo fmt --all",
+2121 -3363
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,9 +1,9 @@
packages:
- "nodecar"
- nodecar
onlyBuiltDependencies:
- "@biomejs/biome"
- "@tailwindcss/oxide"
- '@biomejs/biome'
- '@tailwindcss/oxide'
- esbuild
- sharp
- sqlite3
- unrs-resolver
+447 -275
View File
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.3.0"
version = "0.7.2"
description = "Simple Yet Powerful Browser Orchestrator"
authors = ["zhom@github"]
edition = "2021"
@@ -27,25 +27,50 @@ tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
directories = "6"
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full"] }
sysinfo = "0.35"
lazy_static = "1.4"
base64 = "0.22"
zip = "4"
async-trait = "0.1"
futures-util = "0.3"
uuid = { version = "1.0", features = ["v4", "serde"] }
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(windows)'.dependencies]
zip = "4"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation="0.10"
objc2 = "0.6.1"
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
"Win32_System_Diagnostics_Debug",
"Win32_System_SystemInformation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Registry",
"Win32_UI_Shell",
] }
[dev-dependencies]
tempfile = "3.13.0"
tokio-test = "0.4.4"
wiremock = "0.6"
hyper = { version = "1.0", 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"] }
[features]
# by default Tauri runs in production mode
+29 -19
View File
@@ -2,47 +2,57 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<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>
<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>
<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>
<key>CFBundleDisplayName</key>
<string>Donut Browser</string>
<key>CFBundleName</key>
<string>Donut Browser</string>
<key>CFBundleIdentifier</key>
<string>com.donutbrowser</string>
<key>CFBundleURLName</key>
<string>com.donutbrowser</string>
<key>CFBundleExecutable</key>
<string>donutbrowser</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.3.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Donut Browser</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>HTML document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.html</string>
<string>public.xhtml</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Web Browser</string>
<string>Web site URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
<key>CFBundleURLIconFile</key>
<string>icon.icns</string>
<key>LSHandlerRank</key>
<string>Owner</string>
</dict>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Donut Browser</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
</dict>
</plist>
+1 -12
View File
@@ -1,14 +1,3 @@
function FindProxyForURL(url, host) {
const proxyString = "{{proxy_url}}";
// Split the proxy string to get the credentials part
const parts = proxyString.split(" ")[1].split("@");
if (parts.length > 1) {
const credentials = parts[0];
const encodedCredentials = encodeURIComponent(credentials);
// Replace the original credentials with encoded ones
return proxyString.replace(credentials, encodedCredentials);
}
return proxyString;
return "{{proxy_url}}";
}
+1 -1
View File
@@ -17,7 +17,7 @@ fn main() {
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
println!("cargo:rustc-env=BUILD_VERSION=v{version}");
} else if let Ok(commit_hash) = std::env::var("GITHUB_SHA") {
// For nightly builds, use commit hash
// For nightly builds, use timestamp format or fallback to commit hash
let short_hash = &commit_hash[0..7.min(commit_hash.len())];
println!("cargo:rustc-env=BUILD_VERSION=nightly-{short_hash}");
} else {
+10 -1
View File
@@ -19,7 +19,16 @@
"shell:allow-spawn",
"shell:allow-stdin-write",
"deep-link:default",
"deep-link:allow-register",
"deep-link:allow-unregister",
"deep-link:allow-is-registered",
"deep-link:allow-get-current",
"dialog:default",
"dialog:allow-open"
"dialog:allow-open",
"macos-permissions:default",
"macos-permissions:allow-request-microphone-permission",
"macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission"
]
}
+16
View File
@@ -12,5 +12,21 @@
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-output</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+410 -101
View File
@@ -9,21 +9,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
use crate::browser::GithubRelease;
#[derive(Debug, Clone, PartialEq, Eq)]
struct VersionComponent {
major: u32,
minor: u32,
patch: u32,
pre_release: Option<PreRelease>,
pub struct VersionComponent {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub pre_release: Option<PreRelease>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreRelease {
kind: PreReleaseKind,
number: Option<u32>,
pub struct PreRelease {
pub kind: PreReleaseKind,
pub number: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum PreReleaseKind {
pub enum PreReleaseKind {
Alpha,
Beta,
RC,
@@ -32,7 +32,7 @@ enum PreReleaseKind {
}
impl VersionComponent {
fn parse(version: &str) -> Self {
pub fn parse(version: &str) -> Self {
let version = version.trim();
// Handle special case for Zen Browser twilight releases
@@ -229,10 +229,45 @@ pub fn is_nightly_version(version: &str) -> bool {
version_comp.pre_release.is_some()
}
// Browser-specific alpha version detection for Zen Browser
pub fn is_zen_nightly_version(version: &str) -> bool {
// For Zen Browser, only "twilight" is considered alpha/pre-release
version.to_lowercase() == "twilight"
/// Centralized function to determine if a browser version/release is nightly/prerelease
/// This is the single source of truth for nightly detection across the entire codebase
pub fn is_browser_version_nightly(
browser: &str,
version: &str,
release_name: Option<&str>,
) -> bool {
match browser {
"zen" => {
// For Zen Browser, only "twilight" is considered nightly
version.to_lowercase() == "twilight"
}
"brave" => {
// For Brave Browser, only releases titled "Release" are stable, everything else is nightly
if let Some(name) = release_name {
!name.starts_with("Release")
} else {
true
}
}
"firefox" | "firefox-developer" => {
// For Firefox, use the category from the API response to determine stability
// This will be handled in the API parsing, so this fallback is for cached versions
is_nightly_version(version)
}
"mullvad-browser" | "tor-browser" => is_nightly_version(version),
"chromium" => {
// Chromium builds are generally stable snapshots
false
}
"camoufox" => {
// For Camoufox, beta versions are actually the stable releases
false
}
_ => {
// Default fallback
is_nightly_version(version)
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -256,7 +291,6 @@ pub struct BrowserRelease {
pub version: String,
pub date: String,
pub is_prerelease: bool,
pub download_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -278,7 +312,6 @@ pub struct ApiClient {
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
mozilla_download_base: String,
}
impl ApiClient {
@@ -291,7 +324,6 @@ impl ApiClient {
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(),
mozilla_download_base: "https://download.mozilla.org".to_string(),
}
}
@@ -302,7 +334,6 @@ impl ApiClient {
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
mozilla_download_base: String,
) -> Self {
Self {
client: Client::new(),
@@ -311,7 +342,6 @@ impl ApiClient {
github_api_base,
chromium_api_base,
tor_archive_base,
mozilla_download_base,
}
}
@@ -449,11 +479,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
)),
is_prerelease: is_browser_version_nightly("firefox", &version, None),
}
})
.collect(),
@@ -489,10 +515,6 @@ impl ApiClient {
version: release.version.clone(),
date: release.date,
is_prerelease: !is_stable,
download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, release.version
)),
})
} else {
None
@@ -534,11 +556,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
)),
is_prerelease: is_browser_version_nightly("firefox-developer", &version, None),
}
})
.collect(),
@@ -580,10 +598,6 @@ impl ApiClient {
version: release.version.clone(),
date: release.date,
is_prerelease: !is_stable,
download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, release.version
)),
})
} else {
None
@@ -627,15 +641,39 @@ impl ApiClient {
"{}/repos/mullvad/mullvad-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
let response = 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?
.json::<Vec<GithubRelease>>()
.await?;
if !response.status().is_success() {
return Err(format!("GitHub API returned status: {}", response.status()).into());
}
// Get the response text first for better error reporting
let response_text = response.text().await?;
// Try to parse the JSON with better error handling
let releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
Ok(releases) => releases,
Err(e) => {
eprintln!("Failed to parse GitHub API response for Mullvad releases:");
eprintln!("Error: {e}");
eprintln!(
"Response text (first 500 chars): {}",
if response_text.len() > 500 {
&response_text[..500]
} else {
&response_text
}
);
return Err(format!("Failed to parse GitHub API response: {e}").into());
}
};
let mut releases: Vec<GithubRelease> = releases
.into_iter()
.map(|mut release| {
@@ -673,19 +711,44 @@ impl ApiClient {
"{}/repos/zen-browser/desktop/releases?per_page=100",
self.github_api_base
);
let mut releases = self
let response = 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?
.json::<Vec<GithubRelease>>()
.await?;
if !response.status().is_success() {
return Err(format!("GitHub API returned status: {}", response.status()).into());
}
// Get the response text first for better error reporting
let response_text = response.text().await?;
// Try to parse the JSON with better error handling
let mut releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
Ok(releases) => releases,
Err(e) => {
eprintln!("Failed to parse GitHub API response for Zen releases:");
eprintln!("Error: {e}");
eprintln!(
"Response text (first 500 chars): {}",
if response_text.len() > 500 {
&response_text[..500]
} else {
&response_text
}
);
return Err(format!("Failed to parse GitHub API response: {e}").into());
}
};
// Check for twilight updates and mark alpha releases
for release in &mut releases {
// Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly
release.is_nightly = is_zen_nightly_version(&release.tag_name);
release.is_nightly =
is_browser_version_nightly("zen", &release.tag_name, Some(&release.name));
// Check for twilight update if this is a twilight release
if release.tag_name.to_lowercase() == "twilight" {
@@ -729,15 +792,39 @@ impl ApiClient {
"{}/repos/brave/brave-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
let response = 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?
.json::<Vec<GithubRelease>>()
.await?;
if !response.status().is_success() {
return Err(format!("GitHub API returned status: {}", response.status()).into());
}
// Get the response text first for better error reporting
let response_text = response.text().await?;
// Try to parse the JSON with better error handling
let releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
Ok(releases) => releases,
Err(e) => {
eprintln!("Failed to parse GitHub API response for Brave releases:");
eprintln!("Error: {e}");
eprintln!(
"Response text (first 500 chars): {}",
if response_text.len() > 500 {
&response_text[..500]
} else {
&response_text
}
);
return Err(format!("Failed to parse GitHub API response: {e}").into());
}
};
// Get platform info to filter appropriate releases
let (os, arch) = Self::get_platform_info();
@@ -749,9 +836,9 @@ impl ApiClient {
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
if has_compatible_asset {
// Set is_nightly based on the release name
// Stable releases start with "Release", everything else is nightly
release.is_nightly = !release.name.starts_with("Release");
// Use the centralized nightly detection function
release.is_nightly =
is_browser_version_nightly("brave", &release.tag_name, Some(&release.name));
Some(release)
} else {
None
@@ -773,6 +860,31 @@ impl ApiClient {
}
/// Check if a Brave release has compatible assets for the given platform and architecture
fn has_compatible_camoufox_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> bool {
let (os_name, arch_name) = match (os, arch) {
("windows", "x64") => ("win", "x86_64"),
("windows", "arm64") => ("win", "arm64"),
("linux", "x64") => ("lin", "x86_64"),
("linux", "arm64") => ("lin", "arm64"),
("macos", "x64") => ("mac", "x86_64"),
("macos", "arm64") => ("mac", "arm64"),
_ => return false,
};
// Look for assets matching the pattern: camoufox-{version}-{release}-{os}.{arch}.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")
})
}
fn has_compatible_brave_asset(
assets: &[crate::browser::GithubAsset],
os: &str,
@@ -794,22 +906,17 @@ impl ApiClient {
}) || assets.iter().any(|asset| asset.name.ends_with(".dmg"))
}
"linux" => {
// For Linux, check for architecture-specific packages (prefer ZIP for stable releases)
// For Linux, be strict about architecture matching - only allow assets that explicitly match the current architecture
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets.iter().any(|asset| {
if assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains(arch_pattern) && (name.ends_with(".deb") || name.ends_with(".rpm"))
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.ends_with(".zip")
}) || assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.ends_with(".deb") || name.ends_with(".rpm")
})
}) {
return true;
}
false
}
_ => false,
}
@@ -877,7 +984,6 @@ impl ApiClient {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: false, // Chromium versions are generally stable builds
download_url: None,
}
})
.collect(),
@@ -914,12 +1020,133 @@ impl ApiClient {
version: version.clone(),
date: "".to_string(),
is_prerelease: false,
download_url: None,
})
.collect(),
)
}
pub async fn fetch_camoufox_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("camoufox") {
println!(
"Using cached Camoufox releases, count: {}",
cached_releases.len()
);
return Ok(cached_releases);
}
}
println!("Fetching Camoufox releases from GitHub API...");
let url = format!(
"{}/repos/daijro/camoufox/releases?per_page=100",
self.github_api_base
);
let response = 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?;
if !response.status().is_success() {
return Err(format!("GitHub API returned status: {}", response.status()).into());
}
// Get the response text first for better error reporting
let response_text = response.text().await?;
// Try to parse the JSON with better error handling
let releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
Ok(releases) => releases,
Err(e) => {
eprintln!("Failed to parse GitHub API response for Camoufox releases:");
eprintln!("Error: {e}");
eprintln!(
"Response text (first 500 chars): {}",
if response_text.len() > 500 {
&response_text[..500]
} else {
&response_text
}
);
return Err(format!("Failed to parse GitHub API response: {e}").into());
}
};
println!(
"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}");
// Filter releases that have assets compatible with the current platform
let mut compatible_releases: Vec<GithubRelease> = releases
.into_iter()
.enumerate()
.filter_map(|(i, release)| {
let has_compatible = self.has_compatible_camoufox_asset(&release.assets, &os, &arch);
if !has_compatible {
println!(
"Release {} ({}) has no compatible assets for {}/{}",
i, release.tag_name, os, arch
);
println!(
" Available assets: {:?}",
release.assets.iter().map(|a| &a.name).collect::<Vec<_>>()
);
}
if has_compatible {
Some(release)
} else {
None
}
})
.collect();
println!(
"After platform filtering: {} compatible releases",
compatible_releases.len()
);
// Sort by version (latest first) with debugging
println!(
"Before sorting: {:?}",
compatible_releases
.iter()
.map(|r| &r.tag_name)
.take(10)
.collect::<Vec<_>>()
);
sort_github_releases(&mut compatible_releases);
println!(
"After sorting: {:?}",
compatible_releases
.iter()
.map(|r| &r.tag_name)
.take(10)
.collect::<Vec<_>>()
);
// 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}");
} else {
println!("Cached {} Camoufox releases", compatible_releases.len());
}
}
Ok(compatible_releases)
}
pub async fn fetch_tor_releases_with_caching(
&self,
no_caching: bool,
@@ -934,11 +1161,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/{version}/tor-browser-macos-{version}.dmg",
self.tor_archive_base
)),
is_prerelease: is_browser_version_nightly("tor-browser", &version, None),
}
})
.collect(),
@@ -1013,10 +1236,6 @@ impl ApiClient {
version: version.clone(),
date: "".to_string(), // TOR archive doesn't provide structured dates
is_prerelease: false, // Assume all archived versions are stable
download_url: Some(format!(
"{}/{version}/tor-browser-macos-{version}.dmg",
self.tor_archive_base
)),
}
})
.collect(),
@@ -1065,13 +1284,11 @@ impl ApiClient {
struct TwilightInfo {
file_size: u64,
last_updated: u64,
download_url: String,
}
let current_info = TwilightInfo {
file_size: asset.size,
last_updated: Self::get_current_timestamp(),
download_url: asset.browser_download_url.clone(),
};
if !twilight_cache_file.exists() {
@@ -1137,7 +1354,6 @@ mod tests {
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
@@ -1317,12 +1533,6 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "139.0");
assert!(releases[0].download_url.is_some());
assert!(releases[0]
.download_url
.as_ref()
.unwrap()
.contains(&server.uri()));
}
#[tokio::test]
@@ -1365,12 +1575,6 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "140.0b1");
assert!(releases[0].download_url.is_some());
assert!(releases[0]
.download_url
.as_ref()
.unwrap()
.contains(&server.uri()));
}
#[tokio::test]
@@ -1462,7 +1666,7 @@ mod tests {
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"name": "Release v1.81.9 (Chromium 137.0.7151.104)",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
@@ -1495,7 +1699,7 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "v1.81.9");
assert!(releases[0].is_nightly);
assert!(!releases[0].is_nightly); // "Release v1.81.9 (Chromium 137.0.7151.104)" starts with "Release" so it should be stable
}
#[tokio::test]
@@ -1615,12 +1819,6 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "14.0.4");
assert!(releases[0].download_url.is_some());
assert!(releases[0]
.download_url
.as_ref()
.unwrap()
.contains(&server.uri()));
}
#[tokio::test]
@@ -1693,13 +1891,13 @@ mod tests {
#[test]
fn test_is_zen_nightly_version() {
// Only "twilight" should be considered nightly for Zen Browser
assert!(is_zen_nightly_version("twilight"));
assert!(is_zen_nightly_version("TWILIGHT")); // Case insensitive
assert!(is_browser_version_nightly("zen", "twilight", None));
assert!(is_browser_version_nightly("zen", "TWILIGHT", None)); // Case insensitive
// Versions with "b" should NOT be considered nightly for Zen Browser
assert!(!is_zen_nightly_version("1.12.8b"));
assert!(!is_zen_nightly_version("1.0.0b1"));
assert!(!is_zen_nightly_version("2.0.0"));
assert!(!is_browser_version_nightly("zen", "1.12.8b", None));
assert!(!is_browser_version_nightly("zen", "1.0.0b1", None));
assert!(!is_browser_version_nightly("zen", "2.0.0", None));
}
#[tokio::test]
@@ -1751,4 +1949,115 @@ mod tests {
let result = client.fetch_zen_releases_with_caching(true).await;
assert!(result.is_err());
}
#[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:?}");
// v24 should be greater than v22
assert!(
v24 > v22,
"135.0.5beta24 should be greater than 135.0.5beta22"
);
// Test other beta version combinations
let v1 = VersionComponent::parse("135.0.5beta1");
let v2 = VersionComponent::parse("135.0.5beta2");
assert!(v2 > v1, "135.0.5beta2 should be greater than 135.0.5beta1");
// Test sorting of multiple versions
let mut versions = vec![
"135.0.5beta22".to_string(),
"135.0.5beta24".to_string(),
"135.0.5beta23".to_string(),
"135.0.5beta21".to_string(),
];
sort_versions(&mut versions);
println!("Sorted versions: {versions:?}");
// Should be sorted from newest to oldest
assert_eq!(versions[0], "135.0.5beta24");
assert_eq!(versions[1], "135.0.5beta23");
assert_eq!(versions[2], "135.0.5beta22");
assert_eq!(versions[3], "135.0.5beta21");
}
#[test]
fn test_camoufox_user_reported_versions() {
// Test the exact versions reported by the user: 135.0.1beta24 vs 135.0beta22
let v22 = VersionComponent::parse("135.0beta22");
let v24 = VersionComponent::parse("135.0.1beta24");
println!("User reported v22: {v22:?}");
println!("User reported v24: {v24:?}");
// 135.0.1beta24 should be greater than 135.0beta22 (newer patch version)
assert!(
v24 > v22,
"135.0.1beta24 should be greater than 135.0beta22, but got: v24={v24:?} vs v22={v22:?}"
);
// Test sorting of the exact user-reported versions
let mut versions = vec!["135.0beta22".to_string(), "135.0.1beta24".to_string()];
sort_versions(&mut versions);
println!("User reported sorted versions: {versions:?}");
// Should be sorted from newest to oldest
assert_eq!(
versions[0], "135.0.1beta24",
"135.0.1beta24 should be first (newest)"
);
assert_eq!(
versions[1], "135.0beta22",
"135.0beta22 should be second (older)"
);
}
#[test]
fn test_camoufox_version_classification() {
// Test that Camoufox beta versions are now correctly classified as stable (not nightly)
assert!(
!is_browser_version_nightly("camoufox", "135.0beta22", None),
"135.0beta22 should be classified as stable for Camoufox"
);
assert!(
!is_browser_version_nightly("camoufox", "135.0.1beta24", None),
"135.0.1beta24 should be classified as stable for Camoufox"
);
// Test with release names too - beta releases should be stable
assert!(
!is_browser_version_nightly("camoufox", "135.0beta22", Some("Release Beta 22")),
"Release with 'Beta' in name should be classified as stable for Camoufox"
);
// Test that stable versions are not classified as nightly
assert!(
!is_browser_version_nightly("camoufox", "135.0", None),
"135.0 should be classified as stable"
);
assert!(
!is_browser_version_nightly("camoufox", "135.0.1", None),
"135.0.1 should be classified as stable"
);
// Test alpha and RC versions are still considered nightly
assert!(
!is_browser_version_nightly("camoufox", "136.0alpha1", None),
"136.0alpha1 should not be classified as nightly/prerelease"
);
assert!(
!is_browser_version_nightly("camoufox", "136.0rc1", None),
"136.0rc1 should not be classified as nightly/prerelease"
);
}
}
+523 -75
View File
@@ -35,6 +35,15 @@ pub struct AppUpdateInfo {
pub published_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppUpdateProgress {
pub stage: String, // "downloading", "extracting", "installing", "completed"
pub percentage: Option<f64>,
pub speed: Option<String>, // MB/s
pub eta: Option<String>, // estimated time remaining
pub message: String,
}
pub struct AppAutoUpdater {
client: Client,
}
@@ -98,9 +107,7 @@ impl AppAutoUpdater {
// For stable builds, look for stable releases (semver format)
let stable_releases: Vec<&AppRelease> = releases
.iter()
.filter(|release| {
release.tag_name.starts_with('v') && !release.tag_name.starts_with("nightly-")
})
.filter(|release| release.tag_name.starts_with('v'))
.collect();
println!("Found {} stable releases", stable_releases.len());
stable_releases
@@ -152,7 +159,7 @@ impl AppAutoUpdater {
async fn fetch_app_releases(
&self,
) -> Result<Vec<AppRelease>, Box<dyn std::error::Error + Send + Sync>> {
let url = "https://api.github.com/repos/zhom/donutbrowser/releases";
let url = "https://api.github.com/repos/zhom/donutbrowser/releases?per_page=100";
let response = self
.client
.get(url)
@@ -311,21 +318,48 @@ impl AppAutoUpdater {
.to_string();
// Emit download start event
let _ = app_handle.emit("app-update-progress", "Downloading update...");
let _ = app_handle.emit(
"app-update-progress",
AppUpdateProgress {
stage: "downloading".to_string(),
percentage: Some(0.0),
speed: None,
eta: None,
message: "Starting download...".to_string(),
},
);
// Download the update
// Download the update with progress tracking
let download_path = self
.download_update(&update_info.download_url, &temp_dir, &filename)
.download_update_with_progress(&update_info.download_url, &temp_dir, &filename, app_handle)
.await?;
// Emit extraction start event
let _ = app_handle.emit("app-update-progress", "Preparing update...");
let _ = app_handle.emit(
"app-update-progress",
AppUpdateProgress {
stage: "extracting".to_string(),
percentage: None,
speed: None,
eta: None,
message: "Preparing update...".to_string(),
},
);
// Extract the update
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
// Emit installation start event
let _ = app_handle.emit("app-update-progress", "Installing update...");
let _ = app_handle.emit(
"app-update-progress",
AppUpdateProgress {
stage: "installing".to_string(),
percentage: None,
speed: None,
eta: None,
message: "Installing update...".to_string(),
},
);
// Install the update (overwrite current app)
self.install_update(&extracted_app_path).await?;
@@ -334,7 +368,16 @@ impl AppAutoUpdater {
let _ = fs::remove_dir_all(&temp_dir);
// Emit completion event
let _ = app_handle.emit("app-update-progress", "Update completed. Restarting...");
let _ = app_handle.emit(
"app-update-progress",
AppUpdateProgress {
stage: "completed".to_string(),
percentage: Some(100.0),
speed: None,
eta: None,
message: "Update completed. Restarting...".to_string(),
},
);
// Restart the application
self.restart_application().await?;
@@ -342,12 +385,13 @@ impl AppAutoUpdater {
Ok(())
}
/// Download the update file
async fn download_update(
/// Download the update file with progress tracking
async fn download_update_with_progress(
&self,
download_url: &str,
dest_dir: &Path,
filename: &str,
app_handle: &tauri::AppHandle,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_dir.join(filename);
@@ -362,15 +406,75 @@ impl AppAutoUpdater {
return Err(format!("Download failed with status: {}", response.status()).into());
}
let total_size = response.content_length().unwrap_or(0);
let mut file = fs::File::create(&file_path)?;
let mut stream = response.bytes_stream();
let mut downloaded = 0u64;
let start_time = std::time::Instant::now();
let mut last_update = std::time::Instant::now();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk)?;
downloaded += chunk.len() as u64;
// Update progress every 100ms to avoid overwhelming the UI
if last_update.elapsed().as_millis() > 100 {
let elapsed = start_time.elapsed().as_secs_f64();
let percentage = if total_size > 0 {
(downloaded as f64 / total_size as f64) * 100.0
} else {
0.0
};
let speed = if elapsed > 0.0 {
downloaded as f64 / elapsed / 1024.0 / 1024.0 // MB/s
} else {
0.0
};
let eta = if total_size > 0 && speed > 0.0 {
let remaining_bytes = total_size - downloaded;
let remaining_seconds = (remaining_bytes as f64 / 1024.0 / 1024.0) / speed;
if remaining_seconds < 60.0 {
format!("{}s", remaining_seconds as u32)
} else {
let minutes = remaining_seconds as u32 / 60;
let seconds = remaining_seconds as u32 % 60;
format!("{minutes}m {seconds}s")
}
} else {
"Unknown".to_string()
};
let _ = app_handle.emit(
"app-update-progress",
AppUpdateProgress {
stage: "downloading".to_string(),
percentage: Some(percentage),
speed: Some(format!("{speed:.1}")),
eta: Some(eta),
message: "Downloading update...".to_string(),
},
);
last_update = std::time::Instant::now();
}
}
// Emit final download completion
let _ = app_handle.emit(
"app-update-progress",
AppUpdateProgress {
stage: "downloading".to_string(),
percentage: Some(100.0),
speed: None,
eta: None,
message: "Download completed".to_string(),
},
);
Ok(file_path)
}
@@ -398,6 +502,30 @@ impl AppAutoUpdater {
Err("DMG extraction is only supported on macOS".into())
}
}
"msi" => {
#[cfg(target_os = "windows")]
{
// For MSI files on Windows, we need to run the installer
// MSI files can't be extracted like archives, they need to be executed
// Return the path to the MSI file itself for installation
Ok(archive_path.to_path_buf())
}
#[cfg(not(target_os = "windows"))]
{
Err("MSI installation is only supported on Windows".into())
}
}
"exe" => {
#[cfg(target_os = "windows")]
{
// For exe installers on Windows, return the path for execution
Ok(archive_path.to_path_buf())
}
#[cfg(not(target_os = "windows"))]
{
Err("EXE installation is only supported on Windows".into())
}
}
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
}
@@ -406,71 +534,282 @@ impl AppAutoUpdater {
/// Install the update by replacing the current app
async fn install_update(
&self,
new_app_path: &Path,
installer_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Get the current application bundle path
let current_app_path = self.get_current_app_path()?;
#[cfg(target_os = "macos")]
{
// Get the current application bundle path
let current_app_path = self.get_current_app_path()?;
// Create a backup of the current app
let backup_path = current_app_path.with_extension("app.backup");
if backup_path.exists() {
fs::remove_dir_all(&backup_path)?;
// Create a backup of the current app
let backup_path = current_app_path.with_extension("app.backup");
if backup_path.exists() {
fs::remove_dir_all(&backup_path)?;
}
// Move current app to backup
fs::rename(&current_app_path, &backup_path)?;
// Move new app to current location
fs::rename(installer_path, &current_app_path)?;
// Remove quarantine attributes from the new app
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
let _ = Command::new("xattr")
.args(["-cr", current_app_path.to_str().unwrap()])
.output();
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
Ok(())
}
// Move current app to backup
fs::rename(&current_app_path, &backup_path)?;
#[cfg(target_os = "windows")]
{
let extension = installer_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
// Move new app to current location
fs::rename(new_app_path, &current_app_path)?;
println!("Installing Windows update with extension: {extension}");
// Remove quarantine attributes from the new app
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
match extension {
"msi" => {
// Install MSI silently with enhanced error handling
println!("Running MSI installer: {}", installer_path.display());
let _ = Command::new("xattr")
.args(["-cr", current_app_path.to_str().unwrap()])
.output();
let mut cmd = Command::new("msiexec");
cmd.args([
"/i",
installer_path.to_str().unwrap(),
"/quiet",
"/norestart",
"REBOOT=ReallySuppress",
"/l*v", // Enable verbose logging
&format!("{}.log", installer_path.to_str().unwrap()),
]);
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
let output = cmd.output()?;
Ok(())
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(-1);
// Try to read the log file for more details
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}");
if !log_content.is_empty() {
println!(
"Log file content (last 500 chars): {}",
&log_content
.chars()
.rev()
.take(500)
.collect::<String>()
.chars()
.rev()
.collect::<String>()
);
}
return Err(
format!("MSI installation failed (exit code {exit_code}): {error_msg}").into(),
);
}
println!("MSI installation completed successfully");
}
"exe" => {
// Run exe installer silently with multiple fallback options
println!("Running EXE installer: {}", installer_path.display());
// Try NSIS silent flag first (most common for Tauri)
let mut success = false;
let mut last_error = String::new();
// NSIS installer flags (used by Tauri)
let nsis_args = vec![
vec!["/S"], // Standard NSIS silent flag
vec!["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], // Inno Setup flags
vec!["/quiet"], // Generic quiet flag
vec!["/silent"], // Alternative silent flag
];
for args in nsis_args {
println!("Trying installer with args: {:?}", args);
let output = Command::new(installer_path).args(&args).output();
match output {
Ok(output) if output.status.success() => {
println!(
"EXE installation completed successfully with args: {:?}",
args
);
success = true;
break;
}
Ok(output) => {
let error_msg = String::from_utf8_lossy(&output.stderr);
last_error = format!(
"Exit code {}: {}",
output.status.code().unwrap_or(-1),
error_msg
);
println!("Installer failed with args {:?}: {}", args, last_error);
}
Err(e) => {
last_error = format!("Failed to execute installer: {e}");
println!(
"Failed to execute installer with args {:?}: {}",
args, last_error
);
}
}
}
if !success {
return Err(
format!(
"EXE installation failed after trying multiple methods. Last error: {last_error}"
)
.into(),
);
}
}
"zip" => {
// Handle ZIP files by extracting and replacing the current executable
println!("Handling ZIP update: {}", installer_path.display());
let temp_extract_dir = installer_path.parent().unwrap().join("extracted");
fs::create_dir_all(&temp_extract_dir)?;
// Extract ZIP file
let extractor = crate::extraction::Extractor::new();
let extracted_path = extractor
.extract_zip(installer_path, &temp_extract_dir)
.await?;
// Find the executable in the extracted files
let current_exe = self.get_current_app_path()?;
let current_exe_name = current_exe.file_name().unwrap();
// Look for the new executable
let new_exe_path =
if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) {
extracted_path
} else {
// Search in extracted directory
let mut found_exe = None;
if let Ok(entries) = fs::read_dir(&extracted_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.file_name() == Some(current_exe_name) {
found_exe = Some(path);
break;
}
}
}
found_exe.ok_or("Could not find executable in ZIP file")?
};
// Create backup of current executable
let backup_path = current_exe.with_extension("exe.backup");
if backup_path.exists() {
fs::remove_file(&backup_path)?;
}
fs::copy(&current_exe, &backup_path)?;
// Replace current executable
fs::copy(&new_exe_path, &current_exe)?;
// Clean up
let _ = fs::remove_dir_all(&temp_extract_dir);
println!("ZIP update completed successfully");
}
_ => {
return Err(format!("Unsupported installer format: {extension}").into());
}
}
Ok(())
}
#[cfg(target_os = "linux")]
{
// For Linux, we would handle different package formats here
// This implementation would depend on the specific package type
Err("Linux auto-update installation not yet implemented".into())
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Err("Auto-update installation not supported on this platform".into())
}
}
/// Get the current application bundle path
fn get_current_app_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Get the current executable path
let exe_path = std::env::current_exe()?;
#[cfg(target_os = "macos")]
{
// Get the current executable path
let exe_path = std::env::current_exe()?;
// Navigate up to find the .app bundle
let mut current = exe_path.as_path();
while let Some(parent) = current.parent() {
if parent.extension().is_some_and(|ext| ext == "app") {
return Ok(parent.to_path_buf());
// Navigate up to find the .app bundle
let mut current = exe_path.as_path();
while let Some(parent) = current.parent() {
if parent.extension().is_some_and(|ext| ext == "app") {
return Ok(parent.to_path_buf());
}
current = parent;
}
current = parent;
Err("Could not find application bundle".into())
}
Err("Could not find application bundle".into())
#[cfg(target_os = "windows")]
{
// On Windows, just return the current executable path
std::env::current_exe().map_err(|e| e.into())
}
#[cfg(target_os = "linux")]
{
// On Linux, return the current executable path
std::env::current_exe().map_err(|e| e.into())
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Err("Platform not supported".into())
}
}
/// Restart the application
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
#[cfg(target_os = "macos")]
{
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
// Create a temporary restart script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.sh");
// Create a temporary restart script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.sh");
// Create the restart script content
let script_content = format!(
r#"#!/bin/bash
// Create the restart script content
let script_content = format!(
r#"#!/bin/bash
# Wait for the current process to exit
while kill -0 {} 2>/dev/null; do
sleep 0.5
@@ -485,37 +824,146 @@ open "{}"
# Clean up this script
rm "{}"
"#,
current_pid,
app_path.to_str().unwrap(),
script_path.to_str().unwrap()
);
current_pid,
app_path.to_str().unwrap(),
script_path.to_str().unwrap()
);
// Write the script to file
fs::write(&script_path, script_content)?;
// Write the script to file
fs::write(&script_path, script_content)?;
// Make the script executable
let _ = Command::new("chmod")
.args(["+x", script_path.to_str().unwrap()])
.output();
// Make the script executable
let _ = Command::new("chmod")
.args(["+x", script_path.to_str().unwrap()])
.output();
// Execute the restart script in the background
let mut cmd = Command::new("bash");
cmd.arg(script_path.to_str().unwrap());
// Execute the restart script in the background
let mut cmd = Command::new("bash");
cmd.arg(script_path.to_str().unwrap());
// Detach the process completely
#[cfg(unix)]
{
// Detach the process completely
use std::os::unix::process::CommandExt;
cmd.process_group(0);
let _child = cmd.spawn()?;
// Give the script a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Exit the current process
std::process::exit(0);
}
let _child = cmd.spawn()?;
#[cfg(target_os = "windows")]
{
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
// Give the script a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Create a temporary restart batch script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.bat");
// Exit the current process
std::process::exit(0);
// Create the restart script content
let script_content = format!(
r#"@echo off
rem Wait for the current process to exit
:wait_loop
tasklist /fi "PID eq {}" >nul 2>&1
if %errorlevel% equ 0 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
rem Wait a bit more to ensure clean exit
timeout /t 2 /nobreak >nul
rem Start the new application
start "" "{}"
rem Clean up this script
del "%~f0"
"#,
current_pid,
app_path.to_str().unwrap()
);
// Write the script to file
fs::write(&script_path, script_content)?;
// Execute the restart script in the background
let mut cmd = Command::new("cmd");
cmd.args(["/C", script_path.to_str().unwrap()]);
// Start the process detached
let _child = cmd.spawn()?;
// Give the script a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Exit the current process
std::process::exit(0);
}
#[cfg(target_os = "linux")]
{
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
// Create a temporary restart script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.sh");
// Create the restart script content
let script_content = format!(
r#"#!/bin/bash
# Wait for the current process to exit
while kill -0 {} 2>/dev/null; do
sleep 0.5
done
# Wait a bit more to ensure clean exit
sleep 1
# Start the new application
"{}" &
# Clean up this script
rm "{}"
"#,
current_pid,
app_path.to_str().unwrap(),
script_path.to_str().unwrap()
);
// Write the script to file
fs::write(&script_path, script_content)?;
// Make the script executable
let _ = Command::new("chmod")
.args(["+x", script_path.to_str().unwrap()])
.output();
// Execute the restart script in the background
let mut cmd = Command::new("bash");
cmd.arg(script_path.to_str().unwrap());
// Detach the process completely
use std::os::unix::process::CommandExt;
cmd.process_group(0);
let _child = cmd.spawn()?;
// Give the script a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Exit the current process
std::process::exit(0);
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Err("Application restart not supported on this platform".into())
}
}
}
+204 -137
View File
@@ -1,3 +1,4 @@
use crate::api_client::is_browser_version_nightly;
use crate::browser_runner::{BrowserProfile, BrowserRunner};
use crate::browser_version_service::{BrowserVersionInfo, BrowserVersionService};
use crate::settings_manager::SettingsManager;
@@ -5,6 +6,7 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use tauri::Emitter;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UpdateNotification {
@@ -45,32 +47,32 @@ impl AutoUpdater {
pub async fn check_for_updates(
&self,
) -> Result<Vec<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
// Check if auto-updates are enabled
let settings = self
.settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if !settings.auto_updates_enabled {
return Ok(Vec::new());
}
let mut notifications = Vec::new();
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
// Group profiles by browser
let profiles = self
.browser_runner
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let mut notifications = Vec::new();
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
// Group profiles by browser type
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
for profile in profiles {
// Only check supported browsers
if !self
.version_service
.is_browser_supported(&profile.browser)
.unwrap_or(false)
{
continue;
}
browser_profiles
.entry(profile.browser.clone())
.or_default()
.push(profile);
}
// Check each browser type
for (browser, profiles) in browser_profiles {
// Get cached versions first, then try to fetch if needed
let versions = if let Some(cached) = self
@@ -97,7 +99,26 @@ impl AutoUpdater {
// Check each profile for updates
for profile in profiles {
if let Some(update) = self.check_profile_update(&profile, &versions)? {
notifications.push(update);
// Apply chromium threshold logic
if browser == "chromium" {
// For chromium, only show notifications if there are 200+ new versions
let current_version = &profile.version.parse::<u32>().unwrap();
let new_version = &update.new_version.parse::<u32>().unwrap();
let result = new_version - current_version;
println!(
"Current version: {current_version}, New version: {new_version}, Result: {result}"
);
if result > 200 {
notifications.push(update);
} else {
println!(
"Skipping chromium update notification: only {result} new versions (need 50+)"
);
}
} else {
notifications.push(update);
}
}
}
}
@@ -105,6 +126,93 @@ impl AutoUpdater {
Ok(notifications)
}
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
println!("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!(
"Found {} browser updates to auto-download",
update_notifications.len()
);
// Trigger automatic downloads for each update
for notification in update_notifications {
println!(
"Auto-downloading {} version {}",
notification.browser, notification.new_version
);
// Clone app_handle for the async task
let app_handle_clone = app_handle.clone();
let browser = notification.browser.clone();
let new_version = notification.new_version.clone();
let notification_id = notification.id.clone();
let affected_profiles = notification.affected_profiles.clone();
// Spawn async task to handle the download and auto-update
tokio::spawn(async move {
// First, check if browser already exists
match crate::browser_runner::is_browser_downloaded(
browser.clone(),
new_version.clone(),
) {
true => {
println!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
// Browser already exists, go straight to profile update
match crate::auto_updater::complete_browser_update_with_auto_update(
browser.clone(),
new_version.clone(),
)
.await
{
Ok(updated_profiles) => {
println!(
"Auto-update completed for {} profiles: {:?}",
updated_profiles.len(),
updated_profiles
);
}
Err(e) => {
eprintln!("Failed to complete auto-update for {browser}: {e}");
}
}
}
false => {
println!("Downloading browser {browser} version {new_version}...");
// Emit the auto-update event to trigger frontend handling
let auto_update_event = serde_json::json!({
"browser": browser,
"new_version": new_version,
"notification_id": notification_id,
"affected_profiles": affected_profiles
});
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}");
} else {
println!("Emitted auto-update event for {browser}");
}
}
}
});
}
} else {
println!("No browser updates needed");
}
}
Err(e) => {
eprintln!("Failed to check for browser updates: {e}");
}
}
}
/// Check if a specific profile has an available update
fn check_profile_update(
&self,
@@ -112,16 +220,15 @@ impl AutoUpdater {
available_versions: &[BrowserVersionInfo],
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = &profile.version;
let is_current_stable = !self.is_nightly_version(current_version);
let is_current_nightly = is_browser_version_nightly(&profile.browser, current_version, None);
// Find the best available update
let best_update = available_versions
.iter()
.filter(|v| {
// Only consider versions newer than current
self.is_version_newer(&v.version, current_version) &&
// Respect version type preference
is_current_stable != v.is_prerelease
self.is_version_newer(&v.version, current_version)
&& is_browser_version_nightly(&profile.browser, &v.version, None) == is_current_nightly
})
.max_by(|a, b| self.compare_versions(&a.version, &b.version));
@@ -181,43 +288,6 @@ impl AutoUpdater {
result
}
/// Mark download as auto-update
pub fn mark_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut state = self.load_auto_update_state()?;
let download_key = format!("{browser}-{version}");
state.auto_update_downloads.insert(download_key);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Remove auto-update download tracking
pub fn remove_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut state = self.load_auto_update_state()?;
let download_key = format!("{browser}-{version}");
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Check if download is marked as auto-update
pub fn is_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let state = self.load_auto_update_state()?;
let download_key = format!("{browser}-{version}");
Ok(state.auto_update_downloads.contains(&download_key))
}
/// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions(
&self,
@@ -278,16 +348,9 @@ impl AutoUpdater {
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
// Check if auto-delete of unused binaries is enabled and perform cleanup
let settings = self
.settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background - don't fail the update if cleanup fails
if let Err(e) = self.cleanup_unused_binaries_internal() {
eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}");
}
// Always perform cleanup after auto-update - don't fail the update if cleanup fails
if let Err(e) = self.cleanup_unused_binaries_internal() {
eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}");
}
Ok(updated_profiles)
@@ -343,34 +406,18 @@ impl AutoUpdater {
Ok(())
}
// Helper methods
fn is_nightly_version(&self, version: &str) -> bool {
version.contains("alpha")
|| version.contains("beta")
|| version.contains("rc")
|| version.contains("a")
|| version.contains("b")
|| version.contains("dev")
}
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
self.compare_versions(version1, version2) == std::cmp::Ordering::Greater
// Use the proper VersionComponent comparison from api_client.rs
let version_a = crate::api_client::VersionComponent::parse(version1);
let version_b = crate::api_client::VersionComponent::parse(version2);
version_a > version_b
}
fn compare_versions(&self, version1: &str, version2: &str) -> std::cmp::Ordering {
// Basic semantic version comparison
let v1_parts = self.parse_version(version1);
let v2_parts = self.parse_version(version2);
v1_parts.cmp(&v2_parts)
}
fn parse_version(&self, version: &str) -> Vec<u32> {
version
.split(&['.', 'a', 'b', '-', '_'][..])
.filter_map(|part| part.parse::<u32>().ok())
.collect()
// Use the proper VersionComponent comparison from api_client.rs
let version_a = crate::api_client::VersionComponent::parse(version1);
let version_b = crate::api_client::VersionComponent::parse(version2);
version_a.cmp(&version_b)
}
fn get_auto_update_state_file(&self) -> PathBuf {
@@ -451,27 +498,9 @@ pub async fn complete_browser_update_with_auto_update(
}
#[tauri::command]
pub async fn mark_auto_update_download(browser: String, version: String) -> Result<(), String> {
pub async fn check_for_updates_with_progress(app_handle: tauri::AppHandle) {
let updater = AutoUpdater::new();
updater
.mark_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to mark auto-update download: {e}"))
}
#[tauri::command]
pub async fn remove_auto_update_download(browser: String, version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.remove_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to remove auto-update download: {e}"))
}
#[tauri::command]
pub async fn is_auto_update_download(browser: String, version: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
updater
.is_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to check auto-update download: {e}"))
updater.check_for_updates_with_progress(&app_handle).await;
}
#[cfg(test)]
@@ -480,13 +509,15 @@ mod tests {
fn create_test_profile(name: &str, browser: &str, version: &str) -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::new_v4(),
name: name.to_string(),
browser: browser.to_string(),
version: version.to_string(),
profile_path: format!("/tmp/{name}"),
process_id: None,
proxy: None,
proxy_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
}
}
@@ -498,21 +529,6 @@ mod tests {
}
}
#[test]
fn test_is_nightly_version() {
let updater = AutoUpdater::new();
assert!(updater.is_nightly_version("1.0.0-alpha"));
assert!(updater.is_nightly_version("1.0.0-beta"));
assert!(updater.is_nightly_version("1.0.0-rc"));
assert!(updater.is_nightly_version("1.0.0a1"));
assert!(updater.is_nightly_version("1.0.0b1"));
assert!(updater.is_nightly_version("1.0.0-dev"));
assert!(!updater.is_nightly_version("1.0.0"));
assert!(!updater.is_nightly_version("1.2.3"));
}
#[test]
fn test_compare_versions() {
let updater = AutoUpdater::new();
@@ -549,6 +565,68 @@ mod tests {
assert!(!updater.is_version_newer("1.0.0", "1.0.0"));
}
#[test]
fn test_camoufox_beta_version_comparison() {
let updater = AutoUpdater::new();
// Test the exact user-reported scenario: 135.0.1beta24 vs 135.0beta22
assert!(
updater.is_version_newer("135.0.1beta24", "135.0beta22"),
"135.0.1beta24 should be newer than 135.0beta22"
);
assert_eq!(
updater.compare_versions("135.0.1beta24", "135.0beta22"),
std::cmp::Ordering::Greater,
"135.0.1beta24 should compare as greater than 135.0beta22"
);
// Test other camoufox beta version combinations
assert!(
updater.is_version_newer("135.0.5beta24", "135.0.5beta22"),
"135.0.5beta24 should be newer than 135.0.5beta22"
);
assert!(
updater.is_version_newer("135.0.1beta1", "135.0beta1"),
"135.0.1beta1 should be newer than 135.0beta1 due to patch version"
);
// Test that older versions are not considered newer
assert!(
!updater.is_version_newer("135.0beta22", "135.0.1beta24"),
"135.0beta22 should NOT be newer than 135.0.1beta24"
);
}
#[test]
fn test_beta_version_ordering_comprehensive() {
let updater = AutoUpdater::new();
// Test various beta version patterns that could appear in camoufox
let test_cases = vec![
("135.0.1beta24", "135.0beta22", true), // User reported case
("135.0.5beta24", "135.0.5beta22", true), // Same patch, different beta
("135.1beta1", "135.0beta99", true), // Higher minor beats beta number
("136.0beta1", "135.9.9beta99", true), // Higher major beats everything
("135.0.1beta1", "135.0beta1", true), // Patch version matters
("135.0beta22", "135.0.1beta24", false), // Reverse of user case
];
for (newer, older, should_be_newer) in test_cases {
let result = updater.is_version_newer(newer, older);
assert_eq!(
result,
should_be_newer,
"Expected {} {} {} but got {}",
newer,
if should_be_newer { ">" } else { "<=" },
older,
if result { "true" } else { "false" }
);
}
}
#[test]
fn test_check_profile_update_stable_to_stable() {
let updater = AutoUpdater::new();
@@ -833,15 +911,4 @@ mod tests {
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
assert_eq!(loaded_state.pending_updates.len(), 0);
}
#[test]
fn test_parse_version() {
let updater = AutoUpdater::new();
assert_eq!(updater.parse_version("1.2.3"), vec![1, 2, 3]);
assert_eq!(updater.parse_version("1.2.3-alpha"), vec![1, 2, 3]);
assert_eq!(updater.parse_version("1.2.3a1"), vec![1, 2, 3, 1]);
assert_eq!(updater.parse_version("1.2.3b2"), vec![1, 2, 3, 2]);
assert_eq!(updater.parse_version("10.0.0"), vec![10, 0, 0]);
}
}
+163 -43
View File
@@ -1,14 +1,13 @@
use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxySettings {
pub enabled: bool,
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -20,6 +19,7 @@ pub enum BrowserType {
Brave,
Zen,
TorBrowser,
Camoufox,
}
impl BrowserType {
@@ -32,6 +32,7 @@ impl BrowserType {
BrowserType::Brave => "brave",
BrowserType::Zen => "zen",
BrowserType::TorBrowser => "tor-browser",
BrowserType::Camoufox => "camoufox",
}
}
@@ -44,6 +45,7 @@ impl BrowserType {
"brave" => Ok(BrowserType::Brave),
"zen" => Ok(BrowserType::Zen),
"tor-browser" => Ok(BrowserType::TorBrowser),
"camoufox" => Ok(BrowserType::Camoufox),
_ => Err(format!("Unknown browser type: {s}")),
}
}
@@ -54,7 +56,7 @@ pub trait Browser: Send + Sync {
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
@@ -90,6 +92,7 @@ mod macos {
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("Browser")
})
.map(|entry| entry.path())
@@ -193,6 +196,12 @@ mod linux {
browser_subdir.join("firefox-bin"),
]
}
BrowserType::Camoufox => {
vec![
browser_subdir.join("camoufox"),
browser_subdir.join("camoufox-bin"),
]
}
_ => vec![],
};
@@ -216,17 +225,13 @@ mod linux {
install_dir: &Path,
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
browser_subdir.join("chromium"),
browser_subdir.join("chrome"),
],
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
BrowserType::Brave => vec![
browser_subdir.join("brave"),
browser_subdir.join("brave-browser"),
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
],
_ => vec![],
};
@@ -279,6 +284,12 @@ mod linux {
browser_subdir.join("firefox"),
]
}
BrowserType::Camoufox => {
vec![
browser_subdir.join("camoufox-bin"),
browser_subdir.join("camoufox"),
]
}
_ => vec![],
};
@@ -292,21 +303,13 @@ mod linux {
}
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// Expected structure: install_dir/<browser>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
if !browser_subdir.exists() || !browser_subdir.is_dir() {
return false;
}
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
browser_subdir.join("chromium"),
browser_subdir.join("chrome"),
],
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
BrowserType::Brave => vec![
browser_subdir.join("brave"),
browser_subdir.join("brave-browser"),
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
],
_ => vec![],
};
@@ -371,6 +374,7 @@ mod windows {
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("browser")
{
return Ok(path);
@@ -449,6 +453,7 @@ mod windows {
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("browser")
{
return true;
@@ -545,7 +550,10 @@ impl Browser for FirefoxBrowser {
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
args.push("-no-remote".to_string());
}
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
BrowserType::Firefox
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::Camoufox => {
// Don't use -no-remote so we can communicate with existing instances
}
_ => {}
@@ -643,20 +651,16 @@ impl Browser for ChromiumBrowser {
"--disable-component-update".to_string(),
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
];
// Add proxy configuration if provided
if let Some(proxy) = proxy_settings {
if proxy.enabled {
let pac_path = Path::new(profile_path).join("proxy.pac");
if pac_path.exists() {
let pac_content = fs::read(&pac_path)?;
let pac_base64 = general_purpose::STANDARD.encode(&pac_content);
args.push(format!(
"--proxy-pac-url=data:application/x-javascript-config;base64,{pac_base64}"
));
}
}
// Apply proxy settings
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
));
}
if let Some(url) = url {
@@ -710,6 +714,81 @@ impl Browser for ChromiumBrowser {
}
}
pub struct CamoufoxBrowser;
impl CamoufoxBrowser {
pub fn new() -> Self {
Self
}
}
impl Browser for CamoufoxBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_firefox_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_firefox_executable_path(install_dir, &BrowserType::Camoufox);
#[cfg(target_os = "windows")]
return windows::get_firefox_executable_path(install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// For Camoufox, we handle launching through the camoufox launcher
// This method won't be used directly, but we provide basic Firefox args as fallback
let mut args = vec![
"-profile".to_string(),
profile_path.to_string(),
"-no-remote".to_string(),
];
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let install_dir = binaries_dir.join("camoufox").join(version);
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&install_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&install_dir, &BrowserType::Camoufox);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
false
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
// Factory function to create browser instances
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
match browser_type {
@@ -719,6 +798,7 @@ pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
| BrowserType::Zen
| BrowserType::TorBrowser => Box::new(FirefoxBrowser::new(browser_type)),
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
}
}
@@ -735,6 +815,24 @@ pub struct GithubRelease {
pub is_nightly: bool,
#[serde(default)]
pub prerelease: bool,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub html_url: Option<String>,
#[serde(default)]
pub id: Option<u64>,
#[serde(default)]
pub node_id: Option<String>,
#[serde(default)]
pub target_commitish: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub tarball_url: Option<String>,
#[serde(default)]
pub zipball_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -743,6 +841,22 @@ pub struct GithubAsset {
pub browser_download_url: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub download_count: Option<u64>,
#[serde(default)]
pub id: Option<u64>,
#[serde(default)]
pub node_id: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[cfg(test)]
@@ -761,6 +875,7 @@ mod tests {
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
assert_eq!(
@@ -785,6 +900,10 @@ mod tests {
BrowserType::from_str("tor-browser").unwrap(),
BrowserType::TorBrowser
);
assert_eq!(
BrowserType::from_str("camoufox").unwrap(),
BrowserType::Camoufox
);
// Test invalid browser type
assert!(BrowserType::from_str("invalid").is_err());
@@ -868,23 +987,24 @@ mod tests {
#[test]
fn test_proxy_settings_creation() {
let proxy = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: None,
password: None,
};
assert!(proxy.enabled);
assert_eq!(proxy.proxy_type, "http");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 8080);
// Test different proxy types
let socks_proxy = ProxySettings {
enabled: true,
proxy_type: "socks5".to_string(),
host: "proxy.example.com".to_string(),
port: 1080,
username: None,
password: None,
};
assert_eq!(socks_proxy.proxy_type, "socks5");
@@ -957,10 +1077,11 @@ mod tests {
#[test]
fn test_proxy_settings_serialization() {
let proxy = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: None,
password: None,
};
// Test that it can be serialized (implements Serialize)
@@ -971,7 +1092,6 @@ mod tests {
// Test that it can be deserialized (implements Deserialize)
let deserialized: ProxySettings = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.enabled, proxy.enabled);
assert_eq!(deserialized.proxy_type, proxy.proxy_type);
assert_eq!(deserialized.host, proxy.host);
assert_eq!(deserialized.port, proxy.port);
File diff suppressed because it is too large Load Diff
+205 -44
View File
@@ -17,6 +17,12 @@ pub struct BrowserVersionsResult {
pub total_versions_count: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrowserReleaseTypes {
pub stable: Option<String>,
pub nightly: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadInfo {
pub url: String,
@@ -81,6 +87,10 @@ impl BrowserVersionService {
Ok(true)
}
}
"camoufox" => {
// Camoufox supports all platforms and architectures according to the JS code
Ok(true)
}
_ => Err(format!("Unknown browser: {browser}").into()),
}
}
@@ -95,6 +105,7 @@ impl BrowserVersionService {
"brave",
"chromium",
"tor-browser",
"camoufox",
];
all_browsers
@@ -122,7 +133,7 @@ impl BrowserVersionService {
.map(|version| {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(browser, &version, None),
date: "".to_string(), // Cache doesn't store dates
}
})
@@ -136,6 +147,67 @@ impl BrowserVersionService {
self.api_client.is_cache_expired(browser)
}
/// Get latest stable and nightly versions for a browser (cached first)
pub async fn get_browser_release_types(
&self,
browser: &str,
) -> Result<BrowserReleaseTypes, Box<dyn std::error::Error + Send + Sync>> {
// Try to get from cache first
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
// For Chromium, only return stable since all releases are stable
if browser == "chromium" {
let latest_stable = cached_versions.first().map(|v| v.version.clone());
return Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: None,
});
}
let latest_stable = cached_versions
.iter()
.find(|v| !v.is_prerelease)
.map(|v| v.version.clone());
let latest_nightly = cached_versions
.iter()
.find(|v| v.is_prerelease)
.map(|v| v.version.clone());
return Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: latest_nightly,
});
}
// Fallback to fetching if not cached
// For Chromium, only return stable since all releases are stable
if browser == "chromium" {
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
let latest_stable = detailed_versions.first().map(|v| v.version.clone());
return Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: None,
});
}
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
let latest_stable = detailed_versions
.iter()
.find(|v| !v.is_prerelease)
.map(|v| v.version.clone());
let latest_nightly = detailed_versions
.iter()
.find(|v| v.is_prerelease)
.map(|v| v.version.clone());
Ok(BrowserReleaseTypes {
stable: latest_stable,
nightly: latest_nightly,
})
}
/// Fetch browser versions with optional caching
pub async fn fetch_browser_versions(
&self,
@@ -170,6 +242,7 @@ impl BrowserVersionService {
"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()),
};
@@ -240,7 +313,9 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"firefox", &version, None,
),
date: "".to_string(),
}
}
@@ -261,7 +336,11 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"firefox-developer",
&version,
None,
),
date: "".to_string(),
}
}
@@ -293,6 +372,8 @@ impl BrowserVersionService {
let releases = self.fetch_zen_releases_detailed(true).await?;
merged_versions
.into_iter()
// Filter out twilight releases at the detailed level too
.filter(|version| version.to_lowercase() != "twilight")
.map(|version| {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
@@ -303,7 +384,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Zen Browser releases are usually stable
is_prerelease: crate::api_client::is_browser_version_nightly("zen", &version, None),
date: "".to_string(),
}
}
@@ -324,7 +405,9 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("beta") || version.contains("dev"),
is_prerelease: crate::api_client::is_browser_version_nightly(
"brave", &version, None,
),
date: "".to_string(),
}
}
@@ -360,7 +443,11 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: crate::api_client::is_nightly_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"tor-browser",
&release.version,
None,
),
date: release.date.clone(),
}
} else {
@@ -373,7 +460,30 @@ impl BrowserVersionService {
})
.collect()
}
_ => return Err(format!("Unsupported browser: {browser}").into()),
"camoufox" => {
let releases = self.fetch_camoufox_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, // Camoufox usually stable releases
date: "".to_string(),
}
}
})
.collect()
}
_ => {
return Err(format!("Unsupported browser: {browser}").into());
}
};
Ok(detailed_info)
@@ -423,11 +533,16 @@ impl BrowserVersionService {
match browser {
"firefox" => {
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
("windows", "arm64") => (
"win64-aarch64",
format!("Firefox Setup {version}.exe"),
false,
),
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(),
@@ -435,27 +550,25 @@ impl BrowserVersionService {
}
};
let (filename, is_archive) = match os.as_str() {
"windows" => (format!("firefox-{version}.exe"), false),
"linux" => (format!("firefox-{version}.tar.xz"), true),
"macos" => (format!("firefox-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for Firefox: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://download.mozilla.org/?product=firefox-{version}&os={os_param}&lang=en-US"
"https://download-installer.cdn.mozilla.net/pub/firefox/releases/{version}/{platform_path}/en-US/{filename}"
),
filename,
is_archive,
})
}
"firefox-developer" => {
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
("windows", "arm64") => (
"win64-aarch64",
format!("Firefox Setup {version}.exe"),
false,
),
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox Developer: {os}/{arch}")
@@ -464,16 +577,9 @@ impl BrowserVersionService {
}
};
let (filename, is_archive) = match os.as_str() {
"windows" => (format!("firefox-developer-{version}.exe"), false),
"linux" => (format!("firefox-developer-{version}.tar.xz"), true),
"macos" => (format!("firefox-developer-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for Firefox Developer: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://download.mozilla.org/?product=firefox-devedition-{version}&os={os_param}&lang=en-US"
"https://download-installer.cdn.mozilla.net/pub/devedition/releases/{version}/{platform_path}/en-US/{filename}"
),
filename,
is_archive,
@@ -560,8 +666,6 @@ impl BrowserVersionService {
})
}
"brave" => {
// Brave uses different asset naming conventions
// The actual URL will be resolved dynamically in the download service
let (filename, is_archive) = match (&os[..], &arch[..]) {
("windows", _) => (format!("brave-{version}.exe"), false),
("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true),
@@ -574,7 +678,7 @@ impl BrowserVersionService {
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/brave-placeholder"
"https://github.com/brave/brave-browser/releases/download/{version}/{filename}"
),
filename,
is_archive,
@@ -650,6 +754,32 @@ impl BrowserVersionService {
is_archive,
})
}
"camoufox" => {
// Camoufox downloads from GitHub releases with pattern: camoufox-{version}-{release}-{os}.{arch}.zip
let (os_name, arch_name) = match (&os[..], &arch[..]) {
("windows", "x64") => ("win", "x86_64"),
("windows", "arm64") => ("win", "arm64"),
("linux", "x64") => ("lin", "x86_64"),
("linux", "arm64") => ("lin", "arm64"),
("macos", "x64") => ("mac", "x86_64"),
("macos", "arm64") => ("mac", "arm64"),
_ => {
return Err(
format!("Unsupported platform/architecture for Camoufox: {os}/{arch}").into(),
)
}
};
// Note: We provide a placeholder URL here since Camoufox requires dynamic resolution
// The actual URL will be resolved in download.rs resolve_download_url
Ok(DownloadInfo {
url: format!(
"https://github.com/daijro/camoufox/releases/download/{version}/camoufox-{{version}}-{{release}}-{os_name}.{arch_name}.zip"
),
filename: format!("camoufox-{version}-{os_name}.{arch_name}.zip"),
is_archive: true,
})
}
_ => Err(format!("Unsupported browser: {browser}").into()),
}
}
@@ -740,7 +870,13 @@ impl BrowserVersionService {
no_caching: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_zen_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
Ok(
releases
.into_iter()
.filter(|r| r.tag_name.to_lowercase() != "twilight")
.map(|r| r.tag_name)
.collect(),
)
}
async fn fetch_zen_releases_detailed(
@@ -806,6 +942,24 @@ impl BrowserVersionService {
.fetch_tor_releases_with_caching(no_caching)
.await
}
async fn fetch_camoufox_versions(
&self,
no_caching: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_camoufox_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
}
async fn fetch_camoufox_releases_detailed(
&self,
no_caching: bool,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self
.api_client
.fetch_camoufox_releases_with_caching(no_caching)
.await
}
}
#[cfg(test)]
@@ -826,7 +980,6 @@ mod tests {
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
@@ -1468,16 +1621,24 @@ mod tests {
// Test Firefox
let firefox_info = service.get_download_info("firefox", "139.0").unwrap();
assert_eq!(firefox_info.filename, "firefox-139.0.dmg");
assert!(firefox_info.url.contains("firefox-139.0"));
assert_eq!(firefox_info.filename, "Firefox 139.0.dmg");
assert!(firefox_info
.url
.contains("download-installer.cdn.mozilla.net"));
assert!(firefox_info.url.contains("/pub/firefox/releases/139.0/"));
assert!(firefox_info.is_archive);
// Test Firefox Developer
let firefox_dev_info = service
.get_download_info("firefox-developer", "139.0b1")
.unwrap();
assert_eq!(firefox_dev_info.filename, "firefox-developer-139.0b1.dmg");
assert!(firefox_dev_info.url.contains("devedition-139.0b1"));
assert_eq!(firefox_dev_info.filename, "Firefox 139.0b1.dmg");
assert!(firefox_dev_info
.url
.contains("download-installer.cdn.mozilla.net"));
assert!(firefox_dev_info
.url
.contains("/pub/devedition/releases/139.0b1/"));
assert!(firefox_dev_info.is_archive);
// Test Mullvad Browser
@@ -1506,10 +1667,10 @@ mod tests {
assert!(chromium_info.url.contains("chrome-mac.zip"));
assert!(chromium_info.is_archive);
// Test Brave
// Test Brave - Note: Brave uses dynamic URL resolution, so get_download_info provides a template URL
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
assert!(brave_info.url.contains("brave-placeholder"));
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/Brave-Browser-universal.dmg");
assert!(brave_info.is_archive);
// Test unsupported browser
+607
View File
@@ -0,0 +1,607 @@
use crate::browser_runner::BrowserProfile;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CamoufoxConfig {
pub os: Option<Vec<String>>,
pub block_images: Option<bool>,
pub block_webrtc: Option<bool>,
pub block_webgl: Option<bool>,
pub disable_coop: Option<bool>,
pub geoip: Option<serde_json::Value>, // Can be String or bool
pub country: Option<String>,
pub timezone: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub humanize: Option<bool>,
pub humanize_duration: Option<f64>,
pub headless: Option<bool>,
pub locale: Option<Vec<String>>,
pub addons: Option<Vec<String>>,
pub fonts: Option<Vec<String>>,
pub custom_fonts_only: Option<bool>,
pub exclude_addons: Option<Vec<String>>,
pub screen_min_width: Option<u32>,
pub screen_max_width: Option<u32>,
pub screen_min_height: Option<u32>,
pub screen_max_height: Option<u32>,
pub window_width: Option<u32>,
pub window_height: Option<u32>,
pub ff_version: Option<u32>,
pub main_world_eval: Option<bool>,
pub webgl_vendor: Option<String>,
pub webgl_renderer: Option<String>,
pub proxy: Option<String>,
pub enable_cache: Option<bool>,
pub virtual_display: Option<String>,
pub debug: Option<bool>,
pub additional_args: Option<Vec<String>>,
pub env_vars: Option<HashMap<String, String>>,
pub firefox_prefs: Option<HashMap<String, serde_json::Value>>,
}
impl Default for CamoufoxConfig {
fn default() -> Self {
Self {
os: None,
block_images: None,
block_webrtc: None,
block_webgl: None,
disable_coop: None,
geoip: None,
country: None,
timezone: None,
latitude: None,
longitude: None,
humanize: None,
humanize_duration: None,
headless: None,
locale: None,
addons: None,
fonts: None,
custom_fonts_only: None,
exclude_addons: None,
screen_min_width: None,
screen_max_width: None,
screen_min_height: None,
screen_max_height: None,
window_width: None,
window_height: None,
ff_version: None,
main_world_eval: None,
webgl_vendor: None,
webgl_renderer: None,
proxy: None,
enable_cache: Some(true), // Cache enabled by default
virtual_display: None,
debug: None,
additional_args: None,
env_vars: None,
firefox_prefs: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(non_snake_case)]
pub struct CamoufoxLaunchResult {
pub id: String,
pub pid: Option<u32>,
#[serde(alias = "executable_path")]
pub executablePath: String,
#[serde(alias = "profile_path")]
pub profilePath: String,
pub url: Option<String>,
}
pub struct CamoufoxLauncher {
app_handle: AppHandle,
}
impl CamoufoxLauncher {
pub fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
/// Launch Camoufox browser with the specified configuration
pub async fn launch_camoufox(
&self,
executable_path: &str,
profile_path: &str,
config: &CamoufoxConfig,
url: Option<&str>,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
println!("Launching Camoufox with executable: {executable_path}");
println!("Profile path: {profile_path}");
println!("URL: {url:?}");
// Use Tauri's sidecar to call nodecar
let mut sidecar = self
.app_handle
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("camoufox")
.arg("launch")
.arg("--executable-path")
.arg(executable_path)
.arg("--profile-path")
.arg(profile_path);
// Add URL if provided
if let Some(url) = url {
sidecar = sidecar.arg("--url").arg(url);
}
// Add configuration options
if let Some(os_list) = &config.os {
sidecar = sidecar.arg("--os").arg(os_list.join(","));
}
if config.block_images.unwrap_or(false) {
sidecar = sidecar.arg("--block-images");
}
if config.block_webrtc.unwrap_or(false) {
sidecar = sidecar.arg("--block-webrtc");
}
if config.block_webgl.unwrap_or(false) {
sidecar = sidecar.arg("--block-webgl");
}
if config.disable_coop.unwrap_or(false) {
sidecar = sidecar.arg("--disable-coop");
}
if let Some(geoip) = &config.geoip {
match geoip {
serde_json::Value::String(s) => {
sidecar = sidecar.arg("--geoip").arg(s);
}
serde_json::Value::Bool(b) => {
sidecar = sidecar
.arg("--geoip")
.arg(if *b { "auto" } else { "false" });
}
_ => {
sidecar = sidecar.arg("--geoip").arg(geoip.to_string());
}
}
}
if let Some(country) = &config.country {
sidecar = sidecar.arg("--country").arg(country);
}
if let Some(timezone) = &config.timezone {
sidecar = sidecar.arg("--timezone").arg(timezone);
}
if let Some(latitude) = config.latitude {
if let Some(longitude) = config.longitude {
sidecar = sidecar.arg("--latitude").arg(latitude.to_string());
sidecar = sidecar.arg("--longitude").arg(longitude.to_string());
}
}
if let Some(humanize) = config.humanize {
if humanize {
if let Some(duration) = config.humanize_duration {
sidecar = sidecar.arg("--humanize").arg(duration.to_string());
} else {
sidecar = sidecar.arg("--humanize");
}
}
}
if config.headless.unwrap_or(false) {
sidecar = sidecar.arg("--headless");
}
if let Some(locale_list) = &config.locale {
sidecar = sidecar.arg("--locale").arg(locale_list.join(","));
}
if let Some(addons_list) = &config.addons {
sidecar = sidecar.arg("--addons").arg(addons_list.join(","));
}
if let Some(fonts_list) = &config.fonts {
sidecar = sidecar.arg("--fonts").arg(fonts_list.join(","));
}
if config.custom_fonts_only.unwrap_or(false) {
sidecar = sidecar.arg("--custom-fonts-only");
}
if let Some(exclude_addons_list) = &config.exclude_addons {
sidecar = sidecar
.arg("--exclude-addons")
.arg(exclude_addons_list.join(","));
}
// Screen size configuration
if let Some(width) = config.screen_min_width {
sidecar = sidecar.arg("--screen-min-width").arg(width.to_string());
}
if let Some(width) = config.screen_max_width {
sidecar = sidecar.arg("--screen-max-width").arg(width.to_string());
}
if let Some(height) = config.screen_min_height {
sidecar = sidecar.arg("--screen-min-height").arg(height.to_string());
}
if let Some(height) = config.screen_max_height {
sidecar = sidecar.arg("--screen-max-height").arg(height.to_string());
}
if let Some(width) = config.window_width {
sidecar = sidecar.arg("--window-width").arg(width.to_string());
}
if let Some(height) = config.window_height {
sidecar = sidecar.arg("--window-height").arg(height.to_string());
}
// Advanced options
if let Some(ff_version) = config.ff_version {
sidecar = sidecar.arg("--ff-version").arg(ff_version.to_string());
}
if config.main_world_eval.unwrap_or(false) {
sidecar = sidecar.arg("--main-world-eval");
}
if let Some(vendor) = &config.webgl_vendor {
if let Some(renderer) = &config.webgl_renderer {
sidecar = sidecar.arg("--webgl-vendor").arg(vendor);
sidecar = sidecar.arg("--webgl-renderer").arg(renderer);
}
}
if let Some(proxy) = &config.proxy {
sidecar = sidecar.arg("--proxy").arg(proxy);
}
// Cache is enabled by default, only add flag if disabled
if !config.enable_cache.unwrap_or(true) {
sidecar = sidecar.arg("--disable-cache");
}
if let Some(virtual_display) = &config.virtual_display {
sidecar = sidecar.arg("--virtual-display").arg(virtual_display);
}
if config.debug.unwrap_or(false) {
sidecar = sidecar.arg("--debug");
}
if let Some(args) = &config.additional_args {
sidecar = sidecar.arg("--args").arg(args.join(","));
}
if let Some(env_vars) = &config.env_vars {
let env_json = serde_json::to_string(env_vars)
.map_err(|e| format!("Failed to serialize environment variables: {e}"))?;
sidecar = sidecar.arg("--env").arg(env_json);
}
if let Some(firefox_prefs) = &config.firefox_prefs {
let prefs_json = serde_json::to_string(firefox_prefs)
.map_err(|e| format!("Failed to serialize Firefox preferences: {e}"))?;
sidecar = sidecar.arg("--firefox-prefs").arg(prefs_json);
}
// Execute the command
println!("Executing nodecar command...");
let output = sidecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar command: {e}"))?;
// Check the command status first
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
let stdout_msg = String::from_utf8_lossy(&output.stdout);
return Err(
format!(
"Failed to launch Camoufox: Command failed with status {:?}\nstderr: {}\nstdout: {}",
output.status, error_msg, stdout_msg
)
.into(),
);
}
// Parse the JSON response
let stdout = String::from_utf8_lossy(&output.stdout);
println!("Nodecar stdout: {stdout}");
// Try to parse the JSON response
let result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
.map_err(|e| format!("Failed to parse nodecar response as JSON: {e}\nResponse: {stdout}"))?;
println!("Successfully launched Camoufox with ID: {}", result.id);
Ok(result)
}
/// Stop a Camoufox process by ID
pub async fn stop_camoufox(
&self,
id: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
println!("Stopping Camoufox process with ID: {id}");
// First, we need to find the process to get its executable and profile paths
let processes = self.list_camoufox_processes().await?;
let target_process = processes.iter().find(|p| p.id == id);
if let Some(process) = target_process {
println!(
"Found process to stop: executable={}, profile={}",
process.executablePath, process.profilePath
);
let sidecar = self
.app_handle
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("camoufox")
.arg("stop")
.arg("--executable-path")
.arg(&process.executablePath)
.arg("--profile-path")
.arg(&process.profilePath)
.arg("--id")
.arg(id);
let output = sidecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar stop command: {e}"))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
let stdout_msg = String::from_utf8_lossy(&output.stdout);
println!("Failed to stop Camoufox process - stderr: {error_msg}, stdout: {stdout_msg}");
return Err(format!("Failed to stop Camoufox process: {error_msg}").into());
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("Stop command result: {stdout}");
// Parse the JSON response which contains a "success" field
let response: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| format!("Failed to parse stop response as JSON: {e}\nResponse: {stdout}"))?;
let success = response
.get("success")
.and_then(|v| v.as_bool())
.ok_or_else(|| {
format!("Invalid response format - missing or invalid 'success' field: {stdout}")
})?;
if success {
println!("Successfully stopped Camoufox process: {id}");
} else {
println!("Failed to stop Camoufox process: {id} (process may not exist)");
}
Ok(success)
} else {
println!("Camoufox process with ID {id} not found in running processes");
// If we can't find the process, it might already be stopped
Ok(false)
}
}
/// List all Camoufox processes
pub async fn list_camoufox_processes(
&self,
) -> Result<Vec<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
println!("Listing Camoufox processes...");
// For the list command, we need to provide dummy executable-path and profile-path
// even though they're not used by the list action
let sidecar = self
.app_handle
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("camoufox")
.arg("list")
.arg("--executable-path")
.arg("/dummy/path") // Dummy path since list doesn't use it
.arg("--profile-path")
.arg("/dummy/profile"); // Dummy path since list doesn't use it
let output = sidecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar list command: {e}"))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to list Camoufox processes: {error_msg}").into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
println!("List command result: {stdout}");
// Parse the response as an array of process info
let processes: Vec<serde_json::Value> =
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse list response: {e}"))?;
// Convert to CamoufoxLaunchResult format
let mut results = Vec::new();
for process in processes {
// Handle both camelCase and snake_case formats from nodecar
let id = process.get("id").and_then(|v| v.as_str());
// Try both formats for executable path
let executable_path = process
.get("executable_path")
.and_then(|v| v.as_str())
.or_else(|| process.get("executablePath").and_then(|v| v.as_str()));
// Try both formats for profile path
let profile_path = process
.get("profile_path")
.and_then(|v| v.as_str())
.or_else(|| process.get("profilePath").and_then(|v| v.as_str()));
if let (Some(id), Some(executable_path), Some(profile_path)) =
(id, executable_path, profile_path)
{
let pid = process
.get("pid")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let url = process
.get("url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
results.push(CamoufoxLaunchResult {
id: id.to_string(),
pid,
executablePath: executable_path.to_string(),
profilePath: profile_path.to_string(),
url,
});
} else {
println!("Skipping malformed process entry: {process:?}");
}
}
println!("Parsed {} valid Camoufox processes", results.len());
Ok(results)
}
/// Find Camoufox process by profile path (for integration with browser_runner)
pub async fn find_camoufox_by_profile(
&self,
profile_path: &str,
) -> Result<Option<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
println!("Looking for Camoufox process with profile path: {profile_path}");
let processes = self.list_camoufox_processes().await?;
println!("Found {} running Camoufox processes", processes.len());
for process in &processes {
println!(
"Checking process with profile path: {}",
process.profilePath
);
}
// Convert both paths to canonical form for comparison
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for process in &processes {
println!(
"Comparing target path: {} with process path: {}",
target_path.display(),
process.profilePath
);
// Try multiple comparison methods
let process_path = std::path::Path::new(&process.profilePath)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(&process.profilePath).to_path_buf());
// Method 1: Canonical path comparison
if process_path == target_path {
println!("Found match using canonical path comparison");
return Ok(Some(process.clone()));
}
// Method 2: Direct string comparison
if process.profilePath == profile_path {
println!("Found match using direct string comparison");
return Ok(Some(process.clone()));
}
// Method 3: Compare as strings after canonicalization
if process_path.to_string_lossy() == target_path.to_string_lossy() {
println!("Found match using canonical string comparison");
return Ok(Some(process.clone()));
}
// Method 4: Compare file names if full paths don't match
if let (Some(process_file), Some(target_file)) =
(process_path.file_name(), target_path.file_name())
{
if process_file == target_file {
// If the parent directories also match, it's likely the same profile
if let (Some(process_parent), Some(target_parent)) =
(process_path.parent(), target_path.parent())
{
if process_parent == target_parent {
println!("Found match using parent directory and file name comparison");
return Ok(Some(process.clone()));
}
}
}
}
// Method 5: Check if either path contains the other (for symlinks or different representations)
let process_path_str = process_path.to_string_lossy();
let target_path_str = target_path.to_string_lossy();
if process_path_str.contains(target_path_str.as_ref())
|| target_path_str.contains(process_path_str.as_ref())
{
println!("Found match using path containment check");
return Ok(Some(process.clone()));
}
}
println!("No matching Camoufox process found for profile path: {profile_path}");
Ok(None)
}
}
pub async fn launch_camoufox_profile(
app_handle: AppHandle,
profile: BrowserProfile,
config: CamoufoxConfig,
url: Option<String>,
) -> Result<CamoufoxLaunchResult, String> {
let launcher = CamoufoxLauncher::new(app_handle);
// Get the executable path for Camoufox
let browser_runner = crate::browser_runner::BrowserRunner::new();
let binaries_dir = browser_runner.get_binaries_dir();
let browser_dir = binaries_dir.join("camoufox").join(&profile.version);
// Get executable path
let browser = crate::browser::create_browser(crate::browser::BrowserType::Camoufox);
let executable_path = browser
.get_executable_path(&browser_dir)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Get profile path
let profiles_dir = browser_runner.get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
launcher
.launch_camoufox(
&executable_path.to_string_lossy(),
&profile_path.to_string_lossy(),
&config,
url.as_deref(),
)
.await
.map_err(|e| format!("Failed to launch Camoufox: {e}"))
}
+292 -7
View File
@@ -65,13 +65,282 @@ mod macos {
#[cfg(target_os = "windows")]
mod windows {
use std::path::Path;
use winreg::enums::*;
use winreg::RegKey;
const APP_NAME: &str = "DonutBrowser";
const PROG_ID: &str = "DonutBrowser.HTML";
pub fn is_default_browser() -> Result<bool, String> {
// Windows implementation would go here
Err("Windows support not implemented yet".to_string())
let schemes = ["http", "https"];
for scheme in schemes {
// Check if our browser is set as the default handler for this scheme
if !is_default_for_scheme(scheme)? {
return Ok(false);
}
}
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
Err("Windows support not implemented yet".to_string())
// Get the current executable path
let exe_path = std::env::current_exe()
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
let exe_path_str = exe_path
.to_str()
.ok_or("Failed to convert executable path to string")?;
// Verify the executable exists
if !Path::new(exe_path_str).exists() {
return Err(format!("Executable not found at: {}", exe_path_str));
}
// Register the application
register_application(exe_path_str)?;
// Set as default for HTTP and HTTPS
set_default_for_scheme("http")?;
set_default_for_scheme("https")?;
// Register file associations for HTML files
register_html_file_association(exe_path_str)?;
// Notify the system of changes
notify_system_of_changes();
Ok(())
}
fn is_default_for_scheme(scheme: &str) -> Result<bool, String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Check Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice
let path = format!(
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
scheme
);
match hkcu.open_subkey(&path) {
Ok(key) => match key.get_value::<String, _>("ProgId") {
Ok(prog_id) => Ok(prog_id == PROG_ID),
Err(_) => Ok(false),
},
Err(_) => Ok(false),
}
}
fn register_application(exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Register in Software\RegisteredApplications
let (registered_apps, _) = hkcu
.create_subkey("Software\\RegisteredApplications")
.map_err(|e| format!("Failed to create RegisteredApplications key: {}", e))?;
registered_apps
.set_value(APP_NAME, &format!("Software\\{}", APP_NAME))
.map_err(|e| format!("Failed to set registered application: {}", e))?;
// Create application key
let (app_key, _) = hkcu
.create_subkey(&format!("Software\\{}", APP_NAME))
.map_err(|e| format!("Failed to create application key: {}", e))?;
// Set application properties
app_key
.set_value("ApplicationName", &APP_NAME)
.map_err(|e| format!("Failed to set ApplicationName: {}", e))?;
app_key
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
)
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
app_key
.set_value("ApplicationIcon", &format!("{},0", exe_path))
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
// Create Capabilities key
let (capabilities, _) = app_key
.create_subkey("Capabilities")
.map_err(|e| format!("Failed to create Capabilities key: {}", e))?;
capabilities
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
)
.map_err(|e| format!("Failed to set Capabilities description: {}", e))?;
// Set URL associations
let (url_assoc, _) = capabilities
.create_subkey("URLAssociations")
.map_err(|e| format!("Failed to create URLAssociations key: {}", e))?;
url_assoc
.set_value("http", &PROG_ID)
.map_err(|e| format!("Failed to set http association: {}", e))?;
url_assoc
.set_value("https", &PROG_ID)
.map_err(|e| format!("Failed to set https association: {}", e))?;
// Set file associations
let (file_assoc, _) = capabilities
.create_subkey("FileAssociations")
.map_err(|e| format!("Failed to create FileAssociations key: {}", e))?;
file_assoc
.set_value(".html", &PROG_ID)
.map_err(|e| format!("Failed to set .html association: {}", e))?;
file_assoc
.set_value(".htm", &PROG_ID)
.map_err(|e| format!("Failed to set .htm association: {}", e))?;
// Register the ProgID
register_prog_id(exe_path)?;
Ok(())
}
fn register_prog_id(exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Create ProgID key
let (prog_id_key, _) = hkcu
.create_subkey(&format!("Software\\Classes\\{}", PROG_ID))
.map_err(|e| format!("Failed to create ProgID key: {}", e))?;
prog_id_key
.set_value("", &"Donut Browser Document")
.map_err(|e| format!("Failed to set ProgID default value: {}", e))?;
prog_id_key
.set_value("FriendlyTypeName", &"Donut Browser Document")
.map_err(|e| format!("Failed to set FriendlyTypeName: {}", e))?;
// Create DefaultIcon key
let (icon_key, _) = prog_id_key
.create_subkey("DefaultIcon")
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
icon_key
.set_value("", &format!("{},0", exe_path))
.map_err(|e| format!("Failed to set default icon: {}", e))?;
// Create shell\open\command key
let (command_key, _) = prog_id_key
.create_subkey("shell\\open\\command")
.map_err(|e| format!("Failed to create command key: {}", e))?;
command_key
.set_value("", &format!("\"{}\" \"%1\"", exe_path))
.map_err(|e| format!("Failed to set command: {}", e))?;
Ok(())
}
fn set_default_for_scheme(scheme: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Set in Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.html\UserChoice
// Note: On Windows 10+, this might require elevated permissions or user interaction
// through the Settings app due to security restrictions
// Try to set the association in the user's choice
let user_choice_path = format!(
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
scheme
);
// Note: Setting UserChoice directly may not work on Windows 10+ due to hash verification
// The user may need to manually set the default browser through Windows Settings
match hkcu.create_subkey(&user_choice_path) {
Ok((user_choice, _)) => {
// Attempt to set the ProgId
if let Err(_) = user_choice.set_value("ProgId", &PROG_ID) {
// If we can't set UserChoice, that's expected on newer Windows versions
// The registration is still valuable for the "Open with" menu
}
}
Err(_) => {
// Expected on newer Windows versions - user must set manually
}
}
Ok(())
}
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Register .html and .htm file associations
for ext in &[".html", ".htm"] {
let ext_path = format!("Software\\Classes\\{}", ext);
match hkcu.create_subkey(&ext_path) {
Ok((ext_key, _)) => {
// Set the default value to our ProgID
let _ = ext_key.set_value("", &PROG_ID);
}
Err(_) => {
// Continue if we can't set the file association
}
}
}
Ok(())
}
fn notify_system_of_changes() {
// Use Windows API to notify the system of association changes
// This helps refresh the system's understanding of the changes
unsafe {
use std::ffi::c_void;
// Declare the Windows API functions
type UINT = u32;
type DWORD = u32;
type LPARAM = isize;
type WPARAM = usize;
const HWND_BROADCAST: *mut c_void = 0xffff as *mut c_void;
const WM_SETTINGCHANGE: UINT = 0x001A;
const SMTO_ABORTIFHUNG: UINT = 0x0002;
// Link to user32.dll functions
extern "system" {
fn SendMessageTimeoutA(
hWnd: *mut c_void,
Msg: UINT,
wParam: WPARAM,
lParam: LPARAM,
fuFlags: UINT,
uTimeout: UINT,
lpdwResult: *mut DWORD,
) -> isize;
}
let mut result: DWORD = 0;
// Notify about file associations change
SendMessageTimeoutA(
HWND_BROADCAST,
WM_SETTINGCHANGE,
0,
"Software\\Classes\0".as_ptr() as LPARAM,
SMTO_ABORTIFHUNG,
1000,
&mut result,
);
}
}
}
@@ -266,7 +535,7 @@ pub async fn open_url_with_profile(
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
runner
.launch_or_open_url(app_handle, &profile, Some(url.clone()))
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
.await
.map_err(|e| {
println!("Failed to open URL with profile '{profile_name}': {e}");
@@ -336,14 +605,30 @@ pub async fn smart_open_url(
}
}
// For Mullvad browser: skip if running (can't open URLs in running Mullvad)
// For Mullvad browser: Check if any other Mullvad browser is running
if profile.browser == "mullvad-browser" {
continue;
let mut other_mullvad_running = false;
for p in &profiles {
if p.browser == "mullvad-browser"
&& p.name != profile.name
&& runner
.check_browser_status(app_handle.clone(), p)
.await
.unwrap_or(false)
{
other_mullvad_running = true;
break;
}
}
if other_mullvad_running {
continue; // Skip this one, can't have multiple Mullvad instances
}
}
// Try to open the URL with this running profile
match runner
.launch_or_open_url(app_handle.clone(), profile, Some(url.clone()))
.launch_or_open_url(app_handle.clone(), profile, Some(url.clone()), None)
.await
{
Ok(_) => {
+88 -41
View File
@@ -79,15 +79,29 @@ impl Downloader {
}
BrowserType::Zen => {
// For Zen, verify the asset exists and handle different naming patterns
let releases = self
.api_client
.fetch_zen_releases_with_caching(true)
.await?;
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}");
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());
}
};
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or(format!("Zen version {version} not found"))?;
.ok_or_else(|| {
format!(
"Zen version {} not found. Available versions: {}",
version,
releases
.iter()
.take(5)
.map(|r| r.tag_name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
@@ -95,9 +109,17 @@ impl Downloader {
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No compatible asset found for Zen version {version} on {os}/{arch}"
))?;
.ok_or_else(|| {
let available_assets: Vec<&str> =
release.assets.iter().map(|a| a.name.as_str()).collect();
format!(
"No compatible asset found for Zen version {} on {}/{}. Available assets: {}",
version,
os,
arch,
available_assets.join(", ")
)
})?;
Ok(asset_url)
}
@@ -125,6 +147,30 @@ impl Downloader {
Ok(asset_url)
}
BrowserType::Camoufox => {
// For Camoufox, verify the asset exists and find the correct download URL
let releases = self
.api_client
.fetch_camoufox_releases_with_caching(true)
.await?;
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or(format!("Camoufox 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_camoufox_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No compatible asset found for Camoufox version {version} on {os}/{arch}"
))?;
Ok(asset_url)
}
_ => {
// For other browsers, use the provided URL
Ok(download_info.url.clone())
@@ -195,40 +241,13 @@ impl Downloader {
})
}
"linux" => {
// For Linux, prefer ZIP files matching architecture (new format for stable releases)
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
.or_else(|| {
// Fallback to DEB packages
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains(arch_pattern) && name.ends_with(".deb")
})
})
.or_else(|| {
// Fallback to any ZIP
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.ends_with(".zip")
})
})
.or_else(|| {
// Fallback to any DEB
assets.iter().find(|asset| asset.name.ends_with(".deb"))
})
.or_else(|| {
// Last fallback to RPM if no ZIP or DEB found
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("x86_64") && name.ends_with(".rpm")
})
})
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
}
_ => None,
};
@@ -326,6 +345,35 @@ impl Downloader {
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Camoufox asset for the current platform and architecture
fn find_camoufox_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Camoufox asset naming pattern: camoufox-{version}-{release}-{os}.{arch}.zip
let (os_name, arch_name) = match (os, arch) {
("windows", "x64") => ("win", "x86_64"),
("windows", "arm64") => ("win", "arm64"),
("linux", "x64") => ("lin", "x86_64"),
("linux", "arm64") => ("lin", "arm64"),
("macos", "x64") => ("mac", "x86_64"),
("macos", "arm64") => ("mac", "arm64"),
_ => return None,
};
// Look for assets matching the pattern
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")
});
asset.map(|a| a.browser_download_url.clone())
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
app_handle: &tauri::AppHandle<R>,
@@ -459,7 +507,6 @@ mod tests {
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
base_url.clone(), // mozilla_download_base
)
}
+66 -6
View File
@@ -27,7 +27,7 @@ impl DownloadedBrowsersRegistry {
Self::default()
}
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
pub fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let registry_path = Self::get_registry_path()?;
if !registry_path.exists() {
@@ -39,7 +39,7 @@ impl DownloadedBrowsersRegistry {
Ok(registry)
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let registry_path = Self::get_registry_path()?;
// Ensure parent directory exists
@@ -52,7 +52,7 @@ impl DownloadedBrowsersRegistry {
Ok(())
}
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
@@ -146,7 +146,7 @@ impl DownloadedBrowsersRegistry {
&mut self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(info) = self.remove_browser(browser, version) {
// Clean up any files that might have been left behind
if info.file_path.exists() {
@@ -180,7 +180,7 @@ impl DownloadedBrowsersRegistry {
pub fn cleanup_unused_binaries(
&mut self,
active_profiles: &[(String, String)], // (browser, version) pairs
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let active_set: std::collections::HashSet<(String, String)> =
active_profiles.iter().cloned().collect();
let mut cleaned_up = Vec::new();
@@ -189,8 +189,22 @@ impl DownloadedBrowsersRegistry {
let mut to_remove = Vec::new();
for (browser, versions) in &self.browsers {
for (version, info) in versions {
// Only remove verified downloads that are not used by any active profile
if info.verified && !active_set.contains(&(browser.clone(), version.clone())) {
to_remove.push((browser.clone(), version.clone()));
// Double-check that this browser+version is truly not in use
// by looking for exact matches in the active profiles
let is_in_use = active_profiles
.iter()
.any(|(active_browser, active_version)| {
active_browser == browser && active_version == version
});
if !is_in_use {
to_remove.push((browser.clone(), version.clone()));
println!("Marking for removal: {browser} {version} (not used by any profile)");
} else {
println!("Keeping: {browser} {version} (in use by profile)");
}
}
}
}
@@ -201,9 +215,16 @@ impl DownloadedBrowsersRegistry {
eprintln!("Failed to cleanup unused binary {browser}:{version}: {e}");
} else {
cleaned_up.push(format!("{browser} {version}"));
println!("Successfully removed unused binary: {browser} {version}");
}
}
if cleaned_up.is_empty() {
println!("No unused binaries found to clean up");
} else {
println!("Cleaned up {} unused binaries", cleaned_up.len());
}
Ok(cleaned_up)
}
@@ -217,6 +238,45 @@ impl DownloadedBrowsersRegistry {
.map(|profile| (profile.browser.clone(), profile.version.clone()))
.collect()
}
/// Verify that all registered browsers actually exist on disk and clean up stale entries
pub fn verify_and_cleanup_stale_entries(
&mut self,
browser_runner: &crate::browser_runner::BrowserRunner,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
use crate::browser::{create_browser, BrowserType};
let mut cleaned_up = Vec::new();
let binaries_dir = browser_runner.get_binaries_dir();
let browsers_to_check: Vec<(String, String)> = self
.browsers
.iter()
.flat_map(|(browser, versions)| {
versions
.keys()
.map(|version| (browser.clone(), version.clone()))
})
.collect();
for (browser_str, version) in browsers_to_check {
if let Ok(browser_type) = BrowserType::from_str(&browser_str) {
let browser = create_browser(browser_type);
if !browser.is_version_downloaded(&version, &binaries_dir) {
// Files don't exist, remove from registry
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
cleaned_up.push(format!("{browser_str} {version}"));
println!("Removed stale registry entry for {browser_str} {version}");
}
}
}
}
if !cleaned_up.is_empty() {
self.save()?;
}
Ok(cleaned_up)
}
}
#[cfg(test)]
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
use crate::browser::GithubRelease;
use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::Emitter;
use tokio::fs;
use tokio::io::AsyncWriteExt;
const MMDB_REPO: &str = "P3TERX/GeoLite.mmdb";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoIPDownloadProgress {
pub stage: String, // "downloading", "extracting", "completed"
pub percentage: f64,
pub message: String,
}
pub struct GeoIPDownloader {
client: Client,
}
impl GeoIPDownloader {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to determine base directories")?;
#[cfg(target_os = "windows")]
let cache_dir = base_dirs
.data_local_dir()
.join("camoufox")
.join("camoufox")
.join("Cache");
#[cfg(target_os = "macos")]
let cache_dir = base_dirs.cache_dir().join("camoufox");
#[cfg(target_os = "linux")]
let cache_dir = base_dirs.cache_dir().join("camoufox");
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
let cache_dir = base_dirs.cache_dir().join("camoufox");
Ok(cache_dir)
}
fn get_mmdb_file_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
Ok(Self::get_cache_dir()?.join("GeoLite2-City.mmdb"))
}
pub fn is_geoip_database_available() -> bool {
if let Ok(mmdb_path) = Self::get_mmdb_file_path() {
mmdb_path.exists()
} else {
false
}
}
fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option<String> {
for asset in &release.assets {
if asset.name.ends_with("-City.mmdb") {
return Some(asset.browser_download_url.clone());
}
}
None
}
pub async fn download_geoip_database(
&self,
app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Emit initial progress
let _ = app_handle.emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "downloading".to_string(),
percentage: 0.0,
message: "Starting GeoIP database download".to_string(),
},
);
// Fetch latest release from GitHub
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
let download_url = self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?;
// Create cache directory
let cache_dir = Self::get_cache_dir()?;
fs::create_dir_all(&cache_dir).await?;
let mmdb_path = Self::get_mmdb_file_path()?;
// Download the file
let response = self.client.get(&download_url).send().await?;
if !response.status().is_success() {
return Err(
format!(
"Failed to download GeoIP database: HTTP {}",
response.status()
)
.into(),
);
}
let total_size = response.content_length().unwrap_or(0);
let mut downloaded = 0;
let mut file = fs::File::create(&mmdb_path).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
downloaded += chunk.len() as u64;
file.write_all(&chunk).await?;
if total_size > 0 {
let percentage = (downloaded as f64 / total_size as f64) * 100.0;
let _ = app_handle.emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "downloading".to_string(),
percentage,
message: format!("Downloaded {downloaded} / {total_size} bytes"),
},
);
}
}
file.flush().await?;
// Emit completion
let _ = app_handle.emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "completed".to_string(),
percentage: 100.0,
message: "GeoIP database download completed".to_string(),
},
);
Ok(())
}
async fn fetch_geoip_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("https://api.github.com/repos/{MMDB_REPO}/releases");
let response = self
.client
.get(&url)
.header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)")
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Failed to fetch releases: HTTP {}", response.status()).into());
}
let releases: Vec<GithubRelease> = response.json().await?;
Ok(releases)
}
}
+178 -39
View File
@@ -1,4 +1,5 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env;
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
@@ -12,25 +13,29 @@ mod auto_updater;
mod browser;
mod browser_runner;
mod browser_version_service;
mod camoufox;
mod default_browser;
mod download;
mod downloaded_browsers;
mod extraction;
mod geoip_downloader;
mod profile_importer;
mod proxy_manager;
mod settings_manager;
mod system_utils;
mod theme_detector;
mod version_updater;
extern crate lazy_static;
use browser_runner::{
check_browser_exists, check_browser_status, create_browser_profile_new, delete_profile,
download_browser, fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_downloaded_browser_versions,
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
launch_browser_profile, list_browser_profiles, rename_profile, update_profile_proxy,
update_profile_version,
check_browser_exists, check_browser_status, check_missing_binaries, create_browser_profile_new,
delete_profile, download_browser, ensure_all_binaries_exist, fetch_browser_versions_cached_first,
fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first,
get_browser_release_types, get_downloaded_browser_versions, get_supported_browsers,
is_browser_supported_on_platform, kill_browser_profile, launch_browser_profile,
list_browser_profiles, rename_profile, update_profile_proxy, update_profile_version,
};
use settings_manager::{
@@ -48,8 +53,7 @@ use version_updater::{
use auto_updater::{
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
is_auto_update_download, is_browser_disabled_for_update, mark_auto_update_download,
remove_auto_update_download,
is_browser_disabled_for_update,
};
use app_auto_updater::{
@@ -60,6 +64,8 @@ use profile_importer::{detect_existing_profiles, import_browser_profile};
use theme_detector::get_system_theme;
use system_utils::{get_system_locale, get_system_timezone};
// Trait to extend WebviewWindow with transparent titlebar functionality
pub trait WindowExt {
#[cfg(target_os = "macos")]
@@ -111,20 +117,16 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
// Check if the main window exists and is ready
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
// Window is visible, emit event directly
println!("Main window is visible, emitting show-profile-selector event");
app
.emit("show-profile-selector", url.clone())
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
let _ = window.show();
let _ = window.set_focus();
} else {
// Window not visible yet - add to pending URLs
println!("Main window not visible, adding URL to pending list");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url);
}
println!("Main window exists");
// Try to show and focus the window first
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
app
.emit("show-profile-selector", url.clone())
.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");
@@ -137,6 +139,8 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
#[tauri::command]
async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bool, String> {
println!("check_and_handle_startup_url called");
let pending_urls = {
let mut pending = PENDING_URLS.lock().unwrap();
let urls = pending.clone();
@@ -144,12 +148,24 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
urls
};
println!("Found {} pending URLs", pending_urls.len());
if !pending_urls.is_empty() {
println!(
"Handling {} pending URLs from frontend request",
pending_urls.len()
);
// Ensure the main window is visible and focused
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
// Give the window a moment to become visible
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
for url in pending_urls {
println!("Emitting show-profile-selector event for URL: {url}");
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
@@ -164,14 +180,71 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
Ok(false)
}
#[tauri::command]
async fn create_stored_proxy(
name: String,
proxy_settings: crate::browser::ProxySettings,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(name, proxy_settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
}
#[tauri::command]
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
}
#[tauri::command]
async fn update_stored_proxy(
proxy_id: String,
name: Option<String>,
proxy_settings: Option<crate::browser::ProxySettings>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
}
#[tauri::command]
async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
crate::proxy_manager::PROXY_MANAGER
.delete_stored_proxy(&proxy_id)
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
}
#[tauri::command]
async fn update_camoufox_config(
profile_name: String,
config: crate::camoufox::CamoufoxConfig,
) -> Result<(), String> {
let browser_runner = browser_runner::BrowserRunner::new();
browser_runner
.update_camoufox_config(&profile_name, config)
.map_err(|e| format!("Failed to update Camoufox config: {e}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
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}");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url.clone());
}
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|_, args, _cwd| {
println!("Single instance triggered with args: {args:?}");
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.setup(|app| {
// Create the main window programmatically
#[allow(unused_variables)]
@@ -179,7 +252,10 @@ pub fn run() {
.title("Donut Browser")
.inner_size(900.0, 600.0)
.resizable(false)
.fullscreen(false);
.fullscreen(false)
.center()
.focused(true)
.visible(true);
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
@@ -192,22 +268,53 @@ pub fn run() {
}
}
// Migrate profiles to UUID format if needed (async)
println!("Checking for profile migration...");
let browser_runner = browser_runner::BrowserRunner::new();
tauri::async_runtime::spawn(async move {
match browser_runner.migrate_profiles_to_uuid().await {
Ok(migrated) => {
if !migrated.is_empty() {
println!(
"Successfully migrated {} profiles: {:?}",
migrated.len(),
migrated
);
}
}
Err(e) => {
eprintln!("Warning: Failed to migrate profiles: {e}");
}
}
});
// Set up deep link handler
let handle = app.handle().clone();
#[cfg(any(windows, target_os = "linux"))]
{
// For Windows and Linux, register all deep links at runtime for development
app.deep_link().register_all()?;
if let Err(e) = app.deep_link().register_all() {
eprintln!("Failed to register deep links: {e}");
}
}
#[cfg(target_os = "macos")]
{
// On macOS, try to register deep links for development builds
if let Err(e) = app.deep_link().register_all() {
eprintln!(
"Note: Deep link registration failed on macOS (this is normal for production): {e}"
);
}
}
// Handle deep links - this works for both scenarios:
// 1. App is running and URL is opened
// 2. App is not running and URL causes app to launch
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
println!("Deep link event received with {} URLs", urls.len());
for url in urls {
let url_string = url.to_string();
println!("Deep link received: {url_string}");
@@ -225,25 +332,50 @@ 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}");
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
eprintln!("Failed to handle startup URL: {e}");
}
});
}
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
let mut updater_guard = version_updater.lock().await;
// Set the app handle
updater_guard.set_app_handle(app_handle).await;
{
let mut updater_guard = version_updater.lock().await;
updater_guard.set_app_handle(app_handle);
}
// Start the background updates
updater_guard.start_background_updates().await;
// Run startup check without holding the lock
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
eprintln!("Failed to start background updates: {e}");
}
}
});
// Start the background update task separately
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
// Check for app updates at startup
let app_handle_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Add a small delay to ensure the app is fully loaded
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
println!("Starting app update check at startup...");
let updater = app_auto_updater::AppAutoUpdater::new();
match updater.check_for_updates().await {
@@ -283,6 +415,7 @@ pub fn run() {
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_downloaded_browser_versions,
get_browser_release_types,
update_profile_proxy,
update_profile_version,
check_browser_status,
@@ -305,15 +438,21 @@ pub fn run() {
is_browser_disabled_for_update,
dismiss_update_notification,
complete_browser_update_with_auto_update,
mark_auto_update_download,
remove_auto_update_download,
is_auto_update_download,
check_for_app_updates,
check_for_app_updates_manual,
download_and_install_app_update,
get_system_theme,
detect_existing_profiles,
import_browser_profile,
check_missing_binaries,
ensure_all_binaries_exist,
create_stored_proxy,
get_stored_proxies,
update_stored_proxy,
delete_stored_proxy,
update_camoufox_config,
get_system_locale,
get_system_timezone,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+160 -48
View File
@@ -55,6 +55,9 @@ impl ProfileImporter {
// Detect Zen Browser profiles
detected_profiles.extend(self.detect_zen_browser_profiles()?);
// Detect TOR Browser profiles
detected_profiles.extend(self.detect_tor_browser_profiles()?);
// Remove duplicates based on path
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
@@ -80,9 +83,16 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(app_data) = self.base_dirs.data_dir() {
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
// Primary location in AppData\Roaming
let app_data = self.base_dirs.data_dir();
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
// Also check AppData\Local for portable installations
let local_app_data = self.base_dirs.data_local_dir();
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
if firefox_local_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
}
}
@@ -117,12 +127,11 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(app_data) = self.base_dirs.data_dir() {
// Firefox Developer Edition on Windows typically uses separate directories
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
let app_data = self.base_dirs.data_dir();
// Firefox Developer Edition on Windows typically uses separate directories
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
}
@@ -156,10 +165,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
let local_app_data = self.base_dirs.data_local_dir();
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
#[cfg(target_os = "linux")]
@@ -186,10 +194,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
let chromium_dir = local_app_data.join("Chromium/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
let local_app_data = self.base_dirs.data_local_dir();
let chromium_dir = local_app_data.join("Chromium/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
#[cfg(target_os = "linux")]
@@ -216,10 +223,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
let local_app_data = self.base_dirs.data_local_dir();
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
#[cfg(target_os = "linux")]
@@ -251,9 +257,16 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(app_data) = self.base_dirs.data_dir() {
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
// Primary location in AppData\Roaming
let app_data = self.base_dirs.data_dir();
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
// Also check common installation locations
let local_app_data = self.base_dirs.data_local_dir();
let mullvad_local_dir = local_app_data.join("MullvadBrowser/Profiles");
if mullvad_local_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_local_dir, "mullvad-browser")?);
}
}
@@ -283,10 +296,9 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
if let Some(app_data) = self.base_dirs.data_dir() {
let zen_dir = app_data.join("Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
let app_data = self.base_dirs.data_dir();
let zen_dir = app_data.join("Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[cfg(target_os = "linux")]
@@ -298,6 +310,107 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect TOR Browser profiles
fn detect_tor_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
// TOR Browser on macOS is typically in Applications
let tor_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/TorBrowser-Data/Browser/profile.default");
if tor_dir.exists() {
profiles.push(DetectedProfile {
browser: "tor-browser".to_string(),
name: "TOR Browser - Default Profile".to_string(),
path: tor_dir.to_string_lossy().to_string(),
description: "Default TOR Browser profile".to_string(),
});
}
}
#[cfg(target_os = "windows")]
{
// Check common TOR Browser installation locations on Windows
let possible_paths = [
// Default installation in user directory
(
"Desktop",
"Desktop/Tor Browser/Browser/TorBrowser/Data/Browser/profile.default",
),
// AppData locations
(
"AppData/Roaming",
"TorBrowser/Browser/TorBrowser/Data/Browser/profile.default",
),
(
"AppData/Local",
"TorBrowser/Browser/TorBrowser/Data/Browser/profile.default",
),
];
let home_dir = self.base_dirs.home_dir();
for (location_name, relative_path) in &possible_paths {
let tor_dir = home_dir.join(relative_path);
if tor_dir.exists() {
profiles.push(DetectedProfile {
browser: "tor-browser".to_string(),
name: format!("TOR Browser - {} Profile", location_name),
path: tor_dir.to_string_lossy().to_string(),
description: format!("TOR Browser profile from {}", location_name),
});
}
}
// Also check AppData directories if available
let app_data = self.base_dirs.data_dir();
let tor_app_data =
app_data.join("TorBrowser/Browser/TorBrowser/Data/Browser/profile.default");
if tor_app_data.exists() {
profiles.push(DetectedProfile {
browser: "tor-browser".to_string(),
name: "TOR Browser - AppData Profile".to_string(),
path: tor_app_data.to_string_lossy().to_string(),
description: "TOR Browser profile from AppData".to_string(),
});
}
}
#[cfg(target_os = "linux")]
{
// Common TOR Browser locations on Linux
let possible_paths = [
".local/share/torbrowser/tbb/x86_64/tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
"tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
".tor-browser/Browser/TorBrowser/Data/Browser/profile.default",
"Downloads/tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
];
let home_dir = self.base_dirs.home_dir();
for relative_path in &possible_paths {
let tor_dir = home_dir.join(relative_path);
if tor_dir.exists() {
profiles.push(DetectedProfile {
browser: "tor-browser".to_string(),
name: "TOR Browser - Default Profile".to_string(),
path: tor_dir.to_string_lossy().to_string(),
description: "TOR Browser profile".to_string(),
});
break; // Only add the first one found to avoid duplicates
}
}
}
Ok(profiles)
}
/// Scan Firefox-style profiles directory
fn scan_firefox_profiles_dir(
&self,
@@ -551,28 +664,32 @@ impl ProfileImporter {
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
}
// Create the new profile directory
let snake_case_name = new_profile_name.to_lowercase().replace(' ', "_");
// Generate UUID for new profile and create the directory structure
let profile_id = uuid::Uuid::new_v4();
let profiles_dir = self.browser_runner.get_profiles_dir();
let new_profile_path = profiles_dir.join(&snake_case_name);
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
let new_profile_data_dir = new_profile_uuid_dir.join("profile");
create_dir_all(&new_profile_path)?;
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Copy all files from source to destination
Self::copy_directory_recursive(source_path, &new_profile_path)?;
// Copy all files from source to destination profile subdirectory
Self::copy_directory_recursive(source_path, &new_profile_data_dir)?;
// Create the profile metadata without overwriting the imported data
// We need to find a suitable version for this browser type
let available_versions = self.get_default_version_for_browser(browser_type)?;
let profile = crate::browser_runner::BrowserProfile {
id: profile_id,
name: new_profile_name.to_string(),
browser: browser_type.to_string(),
version: available_versions,
profile_path: new_profile_path.to_string_lossy().to_string(),
proxy: None,
proxy_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
};
// Save the profile metadata
@@ -592,7 +709,7 @@ impl ProfileImporter {
&self,
browser_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Try to get a downloaded version first, fallback to a reasonable default
// Check if any version of the browser is downloaded
let registry =
crate::downloaded_browsers::DownloadedBrowsersRegistry::load().unwrap_or_default();
let downloaded_versions = registry.get_downloaded_versions(browser_type);
@@ -601,17 +718,12 @@ impl ProfileImporter {
return Ok(version.clone());
}
// If no downloaded versions, return a sensible default
match browser_type {
"firefox" => Ok("latest".to_string()),
"firefox-developer" => Ok("latest".to_string()),
"chromium" => Ok("latest".to_string()),
"brave" => Ok("latest".to_string()),
"zen" => Ok("latest".to_string()),
"mullvad-browser" => Ok("13.5.16".to_string()), // Mullvad Browser common version
"tor-browser" => Ok("latest".to_string()),
_ => Ok("latest".to_string()),
}
// If no downloaded versions found, return an error
Err(format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
).into())
}
/// Recursively copy directory contents
+807 -27
View File
@@ -1,6 +1,9 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri_plugin_shell::ShellExt;
@@ -11,30 +14,245 @@ use crate::browser::ProxySettings;
pub struct ProxyInfo {
pub id: String,
pub local_url: String,
pub upstream_url: String,
pub upstream_host: String,
pub upstream_port: u16,
pub upstream_type: String,
pub local_port: u16,
}
// Global proxy manager to track active proxies
// Stored proxy configuration with name and ID for reuse
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProxy {
pub id: String,
pub name: String,
pub proxy_settings: ProxySettings,
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
proxy_settings,
}
}
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
self.proxy_settings = proxy_settings;
}
pub fn update_name(&mut self, name: String) {
self.name = name;
}
}
// Global proxy manager to track active proxies and stored proxy configurations
pub struct ProxyManager {
active_proxies: Mutex<HashMap<u32, ProxyInfo>>, // Maps browser process ID to proxy info
// Store proxy info by profile name for persistence across browser restarts
profile_proxies: Mutex<HashMap<String, (String, u16)>>, // Maps profile name to (upstream_url, port)
profile_proxies: Mutex<HashMap<String, ProxySettings>>, // Maps profile name to proxy settings
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
base_dirs: BaseDirs,
}
impl ProxyManager {
pub fn new() -> Self {
Self {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let manager = Self {
active_proxies: Mutex::new(HashMap::new()),
profile_proxies: Mutex::new(HashMap::new()),
stored_proxies: Mutex::new(HashMap::new()),
base_dirs,
};
// Load stored proxies on initialization
if let Err(e) = manager.load_stored_proxies() {
eprintln!("Warning: Failed to load stored proxies: {e}");
}
manager
}
// Start a proxy for a given upstream URL and associate it with a browser process ID
// Get the path to the proxies directory
fn get_proxies_dir(&self) -> PathBuf {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("proxies");
path
}
// Get the path to a specific proxy file
fn get_proxy_file_path(&self, proxy_id: &str) -> PathBuf {
self.get_proxies_dir().join(format!("{proxy_id}.json"))
}
// Load stored proxies from disk
fn load_stored_proxies(&self) -> Result<(), Box<dyn std::error::Error>> {
let proxies_dir = self.get_proxies_dir();
if !proxies_dir.exists() {
return Ok(()); // No proxies directory yet
}
let mut stored_proxies = self.stored_proxies.lock().unwrap();
// Read all JSON files from the proxies directory
for entry in fs::read_dir(&proxies_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let content = fs::read_to_string(&path)?;
let proxy: StoredProxy = serde_json::from_str(&content)?;
stored_proxies.insert(proxy.id.clone(), proxy);
}
}
Ok(())
}
// Save a single proxy to disk
fn save_proxy(&self, proxy: &StoredProxy) -> Result<(), Box<dyn std::error::Error>> {
let proxies_dir = self.get_proxies_dir();
// Ensure directory exists
fs::create_dir_all(&proxies_dir)?;
let proxy_file = self.get_proxy_file_path(&proxy.id);
let content = serde_json::to_string_pretty(proxy)?;
fs::write(&proxy_file, content)?;
Ok(())
}
// Delete a proxy file from disk
fn delete_proxy_file(&self, proxy_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let proxy_file = self.get_proxy_file_path(proxy_id);
if proxy_file.exists() {
fs::remove_file(proxy_file)?;
}
Ok(())
}
// Create a new stored proxy
pub fn create_stored_proxy(
&self,
name: String,
proxy_settings: ProxySettings,
) -> Result<StoredProxy, String> {
// Check if name already exists
{
let stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.values().any(|p| p.name == name) {
return Err(format!("Proxy with name '{name}' already exists"));
}
}
let stored_proxy = StoredProxy::new(name, proxy_settings);
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
}
if let Err(e) = self.save_proxy(&stored_proxy) {
eprintln!("Warning: Failed to save proxy: {e}");
}
Ok(stored_proxy)
}
// Get all stored proxies
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.values().cloned().collect()
}
// Get a stored proxy by ID
// Update a stored proxy
pub fn update_stored_proxy(
&self,
proxy_id: &str,
name: Option<String>,
proxy_settings: Option<ProxySettings>,
) -> Result<StoredProxy, String> {
// First, check for conflicts without holding a mutable reference
{
let stored_proxies = self.stored_proxies.lock().unwrap();
// Check if proxy exists
if !stored_proxies.contains_key(proxy_id) {
return Err(format!("Proxy with ID '{proxy_id}' not found"));
}
// Check if new name conflicts with existing proxies
if let Some(ref new_name) = name {
if stored_proxies
.values()
.any(|p| p.id != proxy_id && p.name == *new_name)
{
return Err(format!("Proxy with name '{new_name}' already exists"));
}
}
} // Release the lock here
// Now get mutable access for updates
let updated_proxy = {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap(); // Safe because we checked above
if let Some(new_name) = name {
stored_proxy.update_name(new_name);
}
if let Some(new_settings) = proxy_settings {
stored_proxy.update_settings(new_settings);
}
stored_proxy.clone()
};
if let Err(e) = self.save_proxy(&updated_proxy) {
eprintln!("Warning: Failed to save proxy: {e}");
}
Ok(updated_proxy)
}
// Delete a stored proxy
pub fn delete_stored_proxy(&self, proxy_id: &str) -> Result<(), String> {
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.remove(proxy_id).is_none() {
return Err(format!("Proxy with ID '{proxy_id}' not found"));
}
}
if let Err(e) = self.delete_proxy_file(proxy_id) {
eprintln!("Warning: Failed to delete proxy file: {e}");
}
Ok(())
}
// Get proxy settings for a stored proxy ID
pub fn get_proxy_settings_by_id(&self, proxy_id: &str) -> Option<ProxySettings> {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies
.get(proxy_id)
.map(|p| p.proxy_settings.clone())
}
// Start a proxy for given proxy settings and associate it with a browser process ID
pub async fn start_proxy(
&self,
app_handle: tauri::AppHandle,
upstream_url: &str,
proxy_settings: &ProxySettings,
browser_pid: u32,
profile_name: Option<&str>,
) -> Result<ProxySettings, String> {
@@ -43,10 +261,11 @@ impl ProxyManager {
let proxies = self.active_proxies.lock().unwrap();
if let Some(proxy) = proxies.get(&browser_pid) {
return Ok(ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy.local_port,
username: None,
password: None,
});
}
}
@@ -54,31 +273,62 @@ impl ProxyManager {
// Check if we have a preferred port for this profile
let preferred_port = if let Some(name) = profile_name {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(name).map(|(_, port)| *port)
profile_proxies.get(name).and_then(|settings| {
// Find existing proxy with same settings to reuse port
let active_proxies = self.active_proxies.lock().unwrap();
active_proxies
.values()
.find(|p| {
p.upstream_host == settings.host
&& p.upstream_port == settings.port
&& p.upstream_type == settings.proxy_type
})
.map(|p| p.local_port)
})
} else {
None
};
// Start a new proxy using the nodecar binary
// Start a new proxy using the nodecar binary with the correct CLI interface
let mut nodecar = app_handle
.shell()
.sidecar("nodecar")
.unwrap()
.map_err(|e| format!("Failed to create sidecar: {e}"))?
.arg("proxy")
.arg("start")
.arg("-u")
.arg(upstream_url);
.arg("--host")
.arg(&proxy_settings.host)
.arg("--proxy-port")
.arg(proxy_settings.port.to_string())
.arg("--type")
.arg(&proxy_settings.proxy_type);
// Add credentials if provided
if let Some(username) = &proxy_settings.username {
nodecar = nodecar.arg("--username").arg(username);
}
if let Some(password) = &proxy_settings.password {
nodecar = nodecar.arg("--password").arg(password);
}
// If we have a preferred port, use it
if let Some(port) = preferred_port {
nodecar = nodecar.arg("-p").arg(port.to_string());
nodecar = nodecar.arg("--port").arg(port.to_string());
}
let output = nodecar.output().await.unwrap();
// Execute the command and wait for it to complete
// The nodecar binary should start the worker and then exit
let output = nodecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Proxy start failed: {stderr}"));
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!(
"Proxy start failed - stdout: {stdout}, stderr: {stderr}"
));
}
let json_string =
@@ -95,15 +345,13 @@ impl ProxyManager {
.as_str()
.ok_or("Missing local URL")?
.to_string();
let upstream_url_str = json["upstreamUrl"]
.as_str()
.ok_or("Missing upstream URL")?
.to_string();
let proxy_info = ProxyInfo {
id: id.to_string(),
local_url,
upstream_url: upstream_url_str.clone(),
upstream_host: proxy_settings.host.clone(),
upstream_port: proxy_settings.port,
upstream_type: proxy_settings.proxy_type.clone(),
local_port,
};
@@ -116,15 +364,16 @@ impl ProxyManager {
// Store the profile proxy info for persistence
if let Some(name) = profile_name {
let mut profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.insert(name.to_string(), (upstream_url_str, local_port));
profile_proxies.insert(name.to_string(), proxy_settings.clone());
}
// Return proxy settings for the browser
Ok(ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy_info.local_port,
username: None,
password: None,
})
}
@@ -164,24 +413,555 @@ impl ProxyManager {
}
// Get proxy settings for a browser process ID
#[allow(dead_code)]
pub fn get_proxy_settings(&self, browser_pid: u32) -> Option<ProxySettings> {
let proxies = self.active_proxies.lock().unwrap();
proxies.get(&browser_pid).map(|proxy| ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy.local_port,
username: None,
password: None,
})
}
// Get stored proxy info for a profile
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<(String, u16)> {
#[allow(dead_code)]
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<ProxySettings> {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(profile_name).cloned()
}
// Update the PID mapping for an existing proxy
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
let mut proxies = self.active_proxies.lock().unwrap();
if let Some(proxy_info) = proxies.remove(&old_pid) {
proxies.insert(new_pid, proxy_info);
Ok(())
} else {
Err(format!("No proxy found for PID {old_pid}"))
}
}
}
// Create a singleton instance of the proxy manager
lazy_static::lazy_static! {
pub static ref PROXY_MANAGER: ProxyManager = ProxyManager::new();
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use tokio::time::sleep;
// Mock HTTP server for testing
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::Response;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
// Helper function to build nodecar binary for testing
async fn ensure_nodecar_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
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_dist = nodecar_dir.join("dist");
let nodecar_binary = nodecar_dist.join("nodecar");
// Check if binary already exists
if nodecar_binary.exists() {
return Ok(nodecar_binary);
}
// Build the nodecar binary
println!("Building nodecar binary for 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());
}
// Determine the target architecture
let target = if cfg!(target_arch = "aarch64") && cfg!(target_os = "macos") {
"build:mac-aarch64"
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "macos") {
"build:mac-x86_64"
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "linux") {
"build:linux-x64"
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "linux") {
"build:linux-arm64"
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "windows") {
"build:win-x64"
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "windows") {
"build:win-arm64"
} else {
return Err("Unsupported target architecture for nodecar build".into());
};
// Build the binary
let build_status = Command::new("pnpm")
.args(["run", target])
.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)
}
#[tokio::test]
async fn test_proxy_manager_profile_persistence() {
let proxy_manager = ProxyManager::new();
let proxy_settings = ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
username: None,
password: None,
};
// Test profile proxy info storage
{
let mut profile_proxies = proxy_manager.profile_proxies.lock().unwrap();
profile_proxies.insert("test_profile".to_string(), proxy_settings.clone());
}
// Test retrieval
let retrieved = proxy_manager.get_profile_proxy_info("test_profile");
assert!(retrieved.is_some());
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.proxy_type, "socks5");
assert_eq!(retrieved.host, "127.0.0.1");
assert_eq!(retrieved.port, 1080);
// Test non-existent profile
let non_existent = proxy_manager.get_profile_proxy_info("non_existent");
assert!(non_existent.is_none());
}
#[tokio::test]
async fn test_proxy_manager_active_proxy_tracking() {
let proxy_manager = ProxyManager::new();
let proxy_info = ProxyInfo {
id: "test_proxy_123".to_string(),
local_url: "http://localhost:8080".to_string(),
upstream_host: "proxy.example.com".to_string(),
upstream_port: 3128,
upstream_type: "http".to_string(),
local_port: 8080,
};
let browser_pid = 54321u32;
// Add active proxy
{
let mut active_proxies = proxy_manager.active_proxies.lock().unwrap();
active_proxies.insert(browser_pid, proxy_info.clone());
}
// Test retrieval of proxy settings
let proxy_settings = proxy_manager.get_proxy_settings(browser_pid);
assert!(proxy_settings.is_some());
let settings = proxy_settings.unwrap();
assert!(settings.host == "127.0.0.1");
assert!(settings.port == 8080);
// Test non-existent browser PID
let non_existent = proxy_manager.get_proxy_settings(99999);
assert!(non_existent.is_none());
}
#[test]
fn test_proxy_settings_validation() {
// Test valid proxy settings
let valid_settings = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
};
assert!(!valid_settings.host.is_empty());
assert!(valid_settings.port > 0);
// Test proxy settings with empty values
let empty_settings = ProxySettings {
proxy_type: "http".to_string(),
host: "".to_string(),
port: 0,
username: None,
password: None,
};
assert!(empty_settings.host.is_empty());
}
#[tokio::test]
async fn test_proxy_manager_concurrent_access() {
use std::sync::Arc;
let proxy_manager = Arc::new(ProxyManager::new());
let mut handles = vec![];
// Spawn multiple tasks that access the proxy manager concurrently
for i in 0..10 {
let pm = proxy_manager.clone();
let handle = tokio::spawn(async move {
let browser_pid = (1000 + i) as u32;
let proxy_info = ProxyInfo {
id: format!("proxy_{i}"),
local_url: format!("http://127.0.0.1:{}", 8000 + i),
upstream_host: "127.0.0.1".to_string(),
upstream_port: 3128,
upstream_type: "http".to_string(),
local_port: (8000 + i) as u16,
};
// Add proxy
{
let mut active_proxies = pm.active_proxies.lock().unwrap();
active_proxies.insert(browser_pid, proxy_info);
}
// Read proxy
let settings = pm.get_proxy_settings(browser_pid);
assert!(settings.is_some());
browser_pid
});
handles.push(handle);
}
// Wait for all tasks to complete
let results: Vec<u32> = futures_util::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
// Verify all browser PIDs were processed
assert_eq!(results.len(), 10);
for (i, &browser_pid) in results.iter().enumerate() {
assert_eq!(browser_pid, (1000 + i) as u32);
}
}
// Integration test that actually builds and uses nodecar binary
#[tokio::test]
async fn test_proxy_integration_with_real_nodecar() -> Result<(), Box<dyn std::error::Error>> {
// This test requires nodecar to be built and available
let nodecar_path = ensure_nodecar_binary().await?;
// Start a mock upstream HTTP server
let upstream_listener = TcpListener::bind("127.0.0.1:0").await?;
let upstream_addr = upstream_listener.local_addr()?;
// Spawn upstream server
let server_handle = tokio::spawn(async move {
while let Ok((stream, _)) = upstream_listener.accept().await {
let io = TokioIo::new(stream);
tokio::task::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
io,
service_fn(|_req| async {
Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from("Upstream OK"))))
}),
)
.await;
});
}
});
// Wait for server to start
sleep(Duration::from_millis(100)).await;
// Test nodecar proxy start command directly (using the binary itself, not node)
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg(upstream_addr.ip().to_string())
.arg("--proxy-port")
.arg(upstream_addr.port().to_string())
.arg("--type")
.arg("http");
// Set a timeout for the command
let output = tokio::time::timeout(Duration::from_secs(10), async { cmd.output() }).await??;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
// Verify proxy configuration
assert!(config["id"].is_string());
assert!(config["localPort"].is_number());
assert!(config["localUrl"].is_string());
let proxy_id = config["id"].as_str().unwrap();
let local_port = config["localPort"].as_u64().unwrap();
// Wait for proxy worker to start
println!("Waiting for proxy worker to start...");
tokio::time::sleep(Duration::from_secs(3)).await;
// Test that the local port is listening
let mut port_test = Command::new("nc");
port_test
.arg("-z")
.arg("127.0.0.1")
.arg(local_port.to_string());
let port_output = port_test.output()?;
if port_output.status.success() {
println!("Proxy is listening on port {local_port}");
} else {
println!("Warning: Proxy port {local_port} is not listening");
}
// Test stopping the proxy
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let stop_output =
tokio::time::timeout(Duration::from_secs(5), async { stop_cmd.output() }).await??;
assert!(stop_output.status.success());
println!("Integration test passed: nodecar proxy start/stop works correctly");
} else {
let stderr = String::from_utf8(output.stderr)?;
eprintln!("Nodecar failed: {stderr}");
return Err(format!("Nodecar command failed: {stderr}").into());
}
// Clean up server
server_handle.abort();
Ok(())
}
// Test that validates the command line arguments are constructed correctly
#[test]
fn test_proxy_command_construction() {
let proxy_settings = ProxySettings {
proxy_type: "http".to_string(),
host: "proxy.example.com".to_string(),
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
};
// Test command arguments match expected format
let _expected_args = [
"proxy",
"start",
"--host",
"proxy.example.com",
"--proxy-port",
"8080",
"--type",
"http",
"--username",
"user",
"--password",
"pass",
];
// This test verifies the argument structure without actually running the command
assert_eq!(proxy_settings.host, "proxy.example.com");
assert_eq!(proxy_settings.port, 8080);
assert_eq!(proxy_settings.proxy_type, "http");
assert_eq!(proxy_settings.username.as_ref().unwrap(), "user");
assert_eq!(proxy_settings.password.as_ref().unwrap(), "pass");
}
// Test the CLI detachment specifically - ensure the CLI exits properly
#[tokio::test]
async fn test_cli_exits_after_proxy_start() -> Result<(), Box<dyn std::error::Error>> {
let nodecar_path = ensure_nodecar_binary().await?;
// Test that the CLI exits quickly with a mock upstream
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("httpbin.org")
.arg("--proxy-port")
.arg("80")
.arg("--type")
.arg("http");
let start_time = std::time::Instant::now();
let output = tokio::time::timeout(Duration::from_secs(3), async { cmd.output() }).await;
match output {
Ok(Ok(cmd_output)) => {
let execution_time = start_time.elapsed();
println!("CLI completed in {execution_time:?}");
// Should complete very quickly if properly detached
assert!(
execution_time < Duration::from_secs(3),
"CLI took too long ({execution_time:?}), should exit immediately after starting worker"
);
if cmd_output.status.success() {
let stdout = String::from_utf8(cmd_output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
// Clean up - try to stop the proxy
if let Some(proxy_id) = config["id"].as_str() {
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output();
}
}
println!("CLI detachment test passed - CLI exited in {execution_time:?}");
}
Ok(Err(e)) => {
return Err(format!("Command execution failed: {e}").into());
}
Err(_) => {
return Err("CLI command timed out - this indicates improper detachment".into());
}
}
Ok(())
}
// Test that validates proper CLI detachment behavior
#[tokio::test]
async fn test_cli_detachment_behavior() -> Result<(), Box<dyn std::error::Error>> {
let nodecar_path = ensure_nodecar_binary().await?;
// Test that the CLI command exits quickly even with a real upstream
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("httpbin.org") // Use a known good endpoint
.arg("--proxy-port")
.arg("80")
.arg("--type")
.arg("http");
let start_time = std::time::Instant::now();
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
let execution_time = start_time.elapsed();
// Command should complete very quickly if properly detached
assert!(
execution_time < Duration::from_secs(5),
"CLI command took {execution_time:?}, should complete in under 5 seconds for proper detachment"
);
println!("CLI detachment test: command completed in {execution_time:?}");
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
let proxy_id = config["id"].as_str().unwrap();
// Clean up
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output();
println!("CLI detachment test passed");
} else {
// Even if the upstream fails, the CLI should still exit quickly
println!("CLI command failed but exited quickly as expected");
}
Ok(())
}
// Test that validates URL encoding for special characters in credentials
#[tokio::test]
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
let nodecar_path = ensure_nodecar_binary().await?;
// Test with credentials that include special characters
let mut cmd = Command::new(&nodecar_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("test.example.com")
.arg("--proxy-port")
.arg("8080")
.arg("--type")
.arg("http")
.arg("--username")
.arg("user@domain.com") // Contains @ symbol
.arg("--password")
.arg("pass word!"); // Contains space and special character
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
let upstream_url = config["upstreamUrl"].as_str().unwrap();
println!("Generated upstream URL: {upstream_url}");
// Verify that special characters are properly encoded
assert!(upstream_url.contains("user%40domain.com"));
// The password may be encoded as "pass%20word!" or "pass%20word%21" depending on implementation
assert!(upstream_url.contains("pass%20word"));
println!("URL encoding test passed - special characters handled correctly");
// Clean up
let proxy_id = config["id"].as_str().unwrap();
let mut stop_cmd = Command::new(&nodecar_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output();
} else {
// This test might fail if the upstream doesn't exist, but we mainly care about URL construction
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8(output.stderr)?;
println!("Command failed (expected for non-existent upstream):");
println!("Stdout: {stdout}");
println!("Stderr: {stderr}");
// The important thing is that the command completed quickly
println!("URL encoding test completed - credentials should be properly encoded");
}
Ok(())
}
}
+13 -39
View File
@@ -4,7 +4,7 @@ use std::fs::{self, create_dir_all};
use std::path::PathBuf;
use crate::api_client::ApiClient;
use crate::browser_version_service::BrowserVersionService;
use crate::version_updater;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
@@ -25,40 +25,22 @@ impl Default for TableSortingSettings {
pub struct AppSettings {
#[serde(default)]
pub set_as_default_browser: bool,
#[serde(default = "default_show_settings_on_startup")]
#[serde(default)]
pub show_settings_on_startup: bool,
#[serde(default = "default_theme")]
pub theme: String, // "light", "dark", or "system"
#[serde(default = "default_auto_updates_enabled")]
pub auto_updates_enabled: bool,
#[serde(default = "default_auto_delete_unused_binaries")]
pub auto_delete_unused_binaries: bool,
}
fn default_show_settings_on_startup() -> bool {
true
}
fn default_theme() -> String {
"system".to_string()
}
fn default_auto_updates_enabled() -> bool {
true
}
fn default_auto_delete_unused_binaries() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
set_as_default_browser: false,
show_settings_on_startup: default_show_settings_on_startup(),
theme: default_theme(),
auto_updates_enabled: default_auto_updates_enabled(),
auto_delete_unused_binaries: default_auto_delete_unused_binaries(),
show_settings_on_startup: true,
theme: "system".to_string(),
}
}
}
@@ -216,7 +198,9 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
}
#[tauri::command]
pub async fn clear_all_version_cache_and_refetch() -> Result<(), String> {
pub async fn clear_all_version_cache_and_refetch(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let api_client = ApiClient::new();
// Clear all cache first
@@ -224,23 +208,13 @@ pub async fn clear_all_version_cache_and_refetch() -> Result<(), String> {
.clear_all_cache()
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
// Trigger auto-fetch for all supported browsers
let service = BrowserVersionService::new();
let supported_browsers = service.get_supported_browsers();
let updater = version_updater::get_version_updater();
let updater_guard = updater.lock().await;
for browser in supported_browsers {
// Start background fetch for each browser (don't wait for completion)
let service_clone = BrowserVersionService::new();
let browser_clone = browser.clone();
tokio::spawn(async move {
if let Err(e) = service_clone
.fetch_browser_versions_detailed(&browser_clone, false)
.await
{
eprintln!("Background version fetch failed for {browser_clone}: {e}");
}
});
}
updater_guard
.trigger_manual_update(&app_handle)
.await
.map_err(|e| format!("Failed to trigger version update: {e}"))?;
Ok(())
}
+331
View File
@@ -0,0 +1,331 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemLocale {
pub locale: String,
pub language: String,
pub country: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemTimezone {
pub timezone: String,
pub offset: String,
}
pub struct SystemUtils;
impl SystemUtils {
pub fn new() -> Self {
Self
}
/// Detect the system's locale settings
pub fn detect_system_locale(&self) -> SystemLocale {
#[cfg(target_os = "macos")]
return macos::detect_system_locale();
#[cfg(target_os = "linux")]
return linux::detect_system_locale();
#[cfg(target_os = "windows")]
return windows::detect_system_locale();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return SystemLocale {
locale: "en-US".to_string(),
language: "en".to_string(),
country: "US".to_string(),
};
}
/// Detect the system's timezone settings
pub fn detect_system_timezone(&self) -> SystemTimezone {
#[cfg(target_os = "macos")]
return macos::detect_system_timezone();
#[cfg(target_os = "linux")]
return linux::detect_system_timezone();
#[cfg(target_os = "windows")]
return windows::detect_system_timezone();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
};
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get the system locale from macOS
if let Ok(output) = Command::new("defaults")
.args(["read", "-g", "AppleLocale"])
.output()
{
if output.status.success() {
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
return parse_locale(&locale_str);
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to get timezone from macOS system
if let Ok(output) = Command::new("date").arg("+%Z").output() {
if output.status.success() {
let tz_abbr = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Get the full timezone name
if let Ok(tz_output) = Command::new("systemsetup").args(["-gettimezone"]).output() {
if tz_output.status.success() {
let tz_full = String::from_utf8_lossy(&tz_output.stdout);
if let Some(tz_name) = tz_full.strip_prefix("Time Zone: ") {
let tz_clean = tz_name.trim().to_string();
if !tz_clean.is_empty() {
return SystemTimezone {
timezone: tz_clean,
offset: tz_abbr,
};
}
}
}
}
}
}
// Fallback to reading /etc/localtime link
detect_timezone_from_files()
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get locale from locale command
if let Ok(output) = Command::new("locale").output() {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
if line.starts_with("LANG=") {
let locale_value = line.strip_prefix("LANG=").unwrap_or("");
let locale_clean = locale_value.trim_matches('"');
return parse_locale(locale_clean);
}
}
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to read /etc/timezone first (Debian/Ubuntu)
if let Ok(tz_content) = std::fs::read_to_string("/etc/timezone") {
let tz_name = tz_content.trim().to_string();
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name,
offset: get_timezone_offset(),
};
}
}
// Try timedatectl (systemd systems)
if let Ok(output) = Command::new("timedatectl")
.args(["show", "--property=Timezone", "--value"])
.output()
{
if output.status.success() {
let tz_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name,
offset: get_timezone_offset(),
};
}
}
}
// Fallback to reading /etc/localtime symlink
detect_timezone_from_files()
}
fn get_timezone_offset() -> String {
if let Ok(output) = Command::new("date").arg("+%z").output() {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
"+00:00".to_string()
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get locale from Windows registry/powershell
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-Culture | Select-Object -ExpandProperty Name",
])
.output()
{
if output.status.success() {
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
return parse_locale(&locale_str);
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to get timezone from Windows
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-TimeZone | Select-Object -ExpandProperty Id",
])
.output()
{
if output.status.success() {
let tz_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !tz_id.is_empty() {
return SystemTimezone {
timezone: tz_id,
offset: get_windows_timezone_offset(),
};
}
}
}
// Fallback
SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
}
}
fn get_windows_timezone_offset() -> String {
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-TimeZone | Select-Object -ExpandProperty BaseUtcOffset",
])
.output()
{
if output.status.success() {
let offset_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Convert Windows offset format to standard format
if let Some(colon_pos) = offset_str.find(':') {
let hours = &offset_str[..colon_pos];
let minutes = &offset_str[colon_pos + 1..];
if let (Ok(h), Ok(m)) = (hours.parse::<i32>(), minutes.parse::<i32>()) {
return format!("{:+03}:{:02}", h, m);
}
}
}
}
"+00:00".to_string()
}
}
// Helper functions used across platforms
fn parse_locale(locale_str: &str) -> SystemLocale {
// Remove encoding suffix if present (e.g., "en_US.UTF-8" -> "en_US")
let locale_base = locale_str.split('.').next().unwrap_or(locale_str);
// Split language and country (e.g., "en_US" -> ["en", "US"])
let parts: Vec<&str> = locale_base.split(&['_', '-']).collect();
let language = parts.first().unwrap_or(&"en").to_string();
let country = parts.get(1).unwrap_or(&"US").to_string();
// Convert to standard format (e.g., "en-US")
let standard_locale = if parts.len() >= 2 {
format!("{}-{}", language, country.to_uppercase())
} else {
format!("{language}-US")
};
SystemLocale {
locale: standard_locale,
language,
country: country.to_uppercase(),
}
}
fn detect_locale_from_env() -> SystemLocale {
// Check environment variables in order of preference
let env_vars = ["LANG", "LC_ALL", "LC_CTYPE", "LANGUAGE"];
for var in &env_vars {
if let Ok(value) = std::env::var(var) {
if !value.is_empty() {
return parse_locale(&value);
}
}
}
// Default fallback
SystemLocale {
locale: "en-US".to_string(),
language: "en".to_string(),
country: "US".to_string(),
}
}
fn detect_timezone_from_files() -> SystemTimezone {
// Try to read timezone from /etc/localtime symlink
if let Ok(link_target) = std::fs::read_link("/etc/localtime") {
if let Some(tz_path) = link_target.to_str() {
// Extract timezone name from path like /usr/share/zoneinfo/America/New_York
if let Some(zoneinfo_pos) = tz_path.find("zoneinfo/") {
let tz_name = &tz_path[zoneinfo_pos + 9..];
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name.to_string(),
offset: "+00:00".to_string(), // Could be improved with actual offset calculation
};
}
}
}
}
// Default fallback
SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
}
}
/// Tauri command to get system locale
#[tauri::command]
pub async fn get_system_locale() -> Result<SystemLocale, String> {
let utils = SystemUtils::new();
Ok(utils.detect_system_locale())
}
/// Tauri command to get system timezone
#[tauri::command]
pub async fn get_system_timezone() -> Result<SystemTimezone, String> {
let utils = SystemUtils::new();
Ok(utils.detect_system_timezone())
}
+115 -108
View File
@@ -1,4 +1,3 @@
use crate::browser_version_service::BrowserVersionService;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -8,7 +7,10 @@ use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio::time::{interval, Interval};
use tokio::time::interval;
use crate::auto_updater::AutoUpdater;
use crate::browser_version_service::BrowserVersionService;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VersionUpdateProgress {
@@ -46,22 +48,23 @@ impl Default for BackgroundUpdateState {
pub struct VersionUpdater {
version_service: BrowserVersionService,
app_handle: Arc<Mutex<Option<tauri::AppHandle>>>,
update_interval: Interval,
auto_updater: AutoUpdater,
app_handle: Option<tauri::AppHandle>,
}
impl VersionUpdater {
pub fn new() -> Self {
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
Self {
version_service: BrowserVersionService::new(),
app_handle: Arc::new(Mutex::new(None)),
update_interval,
auto_updater: AutoUpdater::new(),
app_handle: None,
}
}
pub fn set_app_handle(&mut self, app_handle: tauri::AppHandle) {
self.app_handle = Some(app_handle);
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let app_name = if cfg!(debug_assertions) {
@@ -143,11 +146,6 @@ impl VersionUpdater {
should_update
}
pub async fn set_app_handle(&self, app_handle: tauri::AppHandle) {
let mut handle = self.app_handle.lock().await;
*handle = Some(app_handle);
}
pub async fn check_and_run_startup_update(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -157,15 +155,10 @@ impl VersionUpdater {
return Ok(());
}
let app_handle = {
let handle_guard = self.app_handle.lock().await;
handle_guard.clone()
};
if let Some(handle) = app_handle {
if let Some(ref app_handle) = self.app_handle {
println!("Running startup version update...");
match self.update_all_browser_versions(&handle).await {
match self.update_all_browser_versions(app_handle).await {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
@@ -191,7 +184,9 @@ impl VersionUpdater {
Ok(())
}
pub async fn start_background_updates(&mut self) {
pub async fn start_background_updates(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!(
"Starting background version update service (checking every 5 minutes for 3-hour intervals)"
);
@@ -201,41 +196,54 @@ impl VersionUpdater {
eprintln!("Startup version update failed: {e}");
}
Ok(())
}
pub async fn run_background_task() {
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
self.update_interval.tick().await;
update_interval.tick().await;
// Check if we should run an update based on persistent state
if !Self::should_run_background_update() {
continue;
}
// Check if we have an app handle
let app_handle = {
let handle_guard = self.app_handle.lock().await;
handle_guard.clone()
};
println!("Starting background version update...");
if let Some(handle) = app_handle {
println!("Starting background version update...");
// Get the updater instance for this update cycle
let updater = get_version_updater();
let result = {
let updater_guard = updater.lock().await;
if let Some(ref app_handle) = updater_guard.app_handle {
updater_guard.update_all_browser_versions(app_handle).await
} else {
Err("App handle not available for background update".into())
}
}; // Release the lock here
match self.update_all_browser_versions(&handle).await {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
last_update_time: Self::get_current_timestamp(),
update_interval_hours: 3,
};
match result {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
last_update_time: Self::get_current_timestamp(),
update_interval_hours: 3,
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {e}");
} else {
println!("Background version update completed successfully");
}
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {e}");
} else {
println!("Background version update completed successfully");
}
Err(e) => {
eprintln!("Background version update failed: {e}");
}
Err(e) => {
eprintln!("Background version update failed: {e}");
// Emit error event
// Try to emit error event if we have an app handle
let updater_guard = updater.lock().await;
if let Some(ref app_handle) = updater_guard.app_handle {
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
total_browsers: 0,
@@ -244,11 +252,9 @@ impl VersionUpdater {
browser_new_versions: 0,
status: "error".to_string(),
};
let _ = handle.emit("version-update-progress", &progress);
let _ = app_handle.emit("version-update-progress", &progress);
}
}
} else {
println!("App handle not available, skipping background update");
}
}
}
@@ -257,107 +263,108 @@ impl VersionUpdater {
&self,
app_handle: &tauri::AppHandle,
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
println!("Starting background version update for all browsers");
let browsers = [
"firefox",
"firefox-developer",
"mullvad-browser",
"zen",
"brave",
"chromium",
"tor-browser",
];
let total_browsers = browsers.len();
let supported_browsers = self.version_service.get_supported_browsers();
let total_browsers = supported_browsers.len();
let mut results = Vec::new();
let mut total_new_versions = 0;
// Emit start event
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
// Emit initial progress
let initial_progress = VersionUpdateProgress {
current_browser: String::new(),
total_browsers,
completed_browsers: 0,
new_versions_found: 0,
browser_new_versions: 0,
status: "updating".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
for (index, browser) in browsers.iter().enumerate() {
// Check if individual browser cache is expired before updating
if !self.version_service.should_update_cache(browser) {
println!("Skipping {browser} - cache is still fresh");
if let Err(e) = app_handle.emit("version-update-progress", &initial_progress) {
eprintln!("Failed to emit initial progress: {e}");
}
let browser_result = BackgroundUpdateResult {
browser: browser.to_string(),
new_versions_count: 0,
total_versions_count: 0,
updated_successfully: true,
error: None,
};
results.push(browser_result);
continue;
}
for (index, browser) in supported_browsers.iter().enumerate() {
println!("Updating browser versions for: {browser}");
println!("Updating versions for browser: {browser}");
// Emit progress for current browser
// Emit progress update for current browser
let progress = VersionUpdateProgress {
current_browser: browser.to_string(),
current_browser: browser.clone(),
total_browsers,
completed_browsers: index,
new_versions_found: total_new_versions,
browser_new_versions: 0,
status: "updating".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
let result = self.update_browser_versions(browser).await;
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
eprintln!("Failed to emit progress for {browser}: {e}");
}
match result {
Ok(new_count) => {
total_new_versions += new_count;
let browser_result = BackgroundUpdateResult {
browser: browser.to_string(),
new_versions_count: new_count,
total_versions_count: 0, // We'll update this if needed
match self.update_browser_versions(browser).await {
Ok(new_versions_count) => {
results.push(BackgroundUpdateResult {
browser: browser.clone(),
new_versions_count,
total_versions_count: 0, // We don't track total for background updates
updated_successfully: true,
error: None,
};
results.push(browser_result);
});
println!("Found {new_count} new versions for {browser}");
total_new_versions += new_versions_count;
// Emit progress update with new versions found
let progress = VersionUpdateProgress {
current_browser: browser.clone(),
total_browsers,
completed_browsers: index,
new_versions_found: total_new_versions,
browser_new_versions: new_versions_count,
status: "updating".to_string(),
};
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
eprintln!("Failed to emit progress with versions for {browser}: {e}");
}
}
Err(e) => {
eprintln!("Failed to update versions for {browser}: {e}");
let browser_result = BackgroundUpdateResult {
browser: browser.to_string(),
results.push(BackgroundUpdateResult {
browser: browser.clone(),
new_versions_count: 0,
total_versions_count: 0,
updated_successfully: false,
error: Some(e.to_string()),
};
results.push(browser_result);
});
}
}
// Small delay between browsers to avoid overwhelming APIs
tokio::time::sleep(Duration::from_millis(500)).await;
}
// Emit completion event
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
// Emit completion
let final_progress = VersionUpdateProgress {
current_browser: String::new(),
total_browsers,
completed_browsers: total_browsers,
new_versions_found: total_new_versions,
browser_new_versions: 0,
status: "completed".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
println!("Background version update completed. Found {total_new_versions} new versions total");
if let Err(e) = app_handle.emit("version-update-progress", &final_progress) {
eprintln!("Failed to emit completion progress: {e}");
}
// After all version updates are complete, trigger auto-update check
if total_new_versions > 0 {
println!(
"Found {total_new_versions} new versions across all browsers. Checking for auto-updates..."
);
// Trigger auto-update check which will automatically download browsers
self
.auto_updater
.check_for_updates_with_progress(app_handle)
.await;
} else {
println!("No new versions found, skipping auto-update check");
}
Ok(results)
}
+4 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.3.0",
"version": "0.7.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -61,8 +61,9 @@
},
"plugins": {
"deep-link": {
"schemes": ["http", "https"],
"domains": []
"desktop": {
"schemes": ["http", "https"]
}
}
}
}
+2 -2
View File
@@ -24,12 +24,12 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
<WindowDragArea />
</CustomThemeProvider>
</body>
</html>
+359 -105
View File
@@ -1,10 +1,20 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { ChangeVersionDialog } from "@/components/change-version-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { 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 { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { Button } from "@/components/ui/button";
@@ -21,14 +31,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaDownload } from "react-icons/fa";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { sleep } from "@/lib/utils";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -37,7 +46,8 @@ type BrowserTypeString =
| "chromium"
| "brave"
| "zen"
| "tor-browser";
| "tor-browser"
| "camoufox";
interface PendingUrl {
id: string;
@@ -52,12 +62,78 @@ export default function Home() {
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
const [proxyManagementDialogOpen, setProxyManagementDialogOpen] =
useState(false);
const [camoufoxConfigDialogOpen, setCamoufoxConfigDialogOpen] =
useState(false);
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
const [currentProfileForProxy, setCurrentProfileForProxy] =
useState<BrowserProfile | null>(null);
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
useState<BrowserProfile | null>(null);
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
useState<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [currentPermissionType, setCurrentPermissionType] =
useState<PermissionType>("microphone");
const [proxyDataReloadTrigger, setProxyDataReloadTrigger] = useState(0);
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions();
// Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => {
try {
const missingBinaries = await invoke<[string, string, string][]>(
"check_missing_binaries",
);
if (missingBinaries.length > 0) {
console.log("Found missing binaries:", missingBinaries);
// Group missing binaries by browser type to avoid concurrent downloads
const browserMap = new Map<string, string[]>();
for (const [profileName, browser, version] of missingBinaries) {
if (!browserMap.has(browser)) {
browserMap.set(browser, []);
}
const versions = browserMap.get(browser);
if (versions) {
versions.push(`${version} (for ${profileName})`);
}
}
// Show a toast notification about missing binaries and auto-download them
const missingList = Array.from(browserMap.entries())
.map(([browser, versions]) => `${browser}: ${versions.join(", ")}`)
.join(", ");
console.log(`Downloading missing binaries: ${missingList}`);
try {
// Download missing binaries sequentially by browser type to prevent conflicts
const downloaded = await invoke<string[]>(
"ensure_all_binaries_exist",
);
if (downloaded.length > 0) {
console.log(
"Successfully downloaded missing binaries:",
downloaded,
);
}
} catch (downloadError) {
console.error("Failed to download missing binaries:", downloadError);
setError(
`Failed to download missing binaries: ${JSON.stringify(
downloadError,
)}`,
);
}
}
} catch (err: unknown) {
console.error("Failed to check missing binaries:", err);
}
}, []);
// Simple profiles loader without updates check (for use as callback)
const loadProfiles = useCallback(async () => {
@@ -65,14 +141,47 @@ export default function Home() {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
setProfiles(profileList);
setProfiles(
profileList.filter((profile) => profile.browser !== "camoufox"),
);
// Check for missing binaries after loading profiles
await checkMissingBinaries();
} catch (err: unknown) {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, [checkMissingBinaries]);
// Trigger proxy data reload in ProfilesDataTable
const triggerProxyDataReload = useCallback(() => {
setProxyDataReloadTrigger((prev) => prev + 1);
}, []);
// Auto-update functionality - pass loadProfiles to refresh profiles after updates
const handleUrlOpen = useCallback(async (url: string) => {
try {
// Use smart profile selection
const result = await invoke<string>("smart_open_url", {
url,
});
console.log("Smart URL opening succeeded:", result);
// URL was handled successfully, no need to show selector
} catch (error: unknown) {
console.log(
"Smart URL opening failed or requires profile selection:",
error,
);
// Show profile selector for manual selection
// Replace any existing pending URL with the new one
setPendingUrls([{ id: Date.now().toString(), url }]);
}
}, []);
// Version updater for handling version fetching progress events and auto-updates
useVersionUpdater();
// Auto-update functionality - use the existing hook for compatibility
const updateNotifications = useUpdateNotifications(loadProfiles);
const { checkForUpdates, isUpdating } = updateNotifications;
@@ -82,44 +191,47 @@ export default function Home() {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
setProfiles(profileList);
setProfiles(
profileList.filter((profile) => profile.browser !== "camoufox"),
);
// TODO: remove after a few version bumps, needed to properly display migrated profiles
setTimeout(async () => {
for (let i = 0; i < 10; i++) {
const profiles = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
setProfiles(
profiles.filter((profile) => profile.browser !== "camoufox"),
);
}
await sleep(500);
}, 0);
// Check for updates after loading profiles
await checkForUpdates();
await checkMissingBinaries();
} catch (err: unknown) {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, [checkForUpdates]);
}, [checkForUpdates, checkMissingBinaries]);
useAppUpdateNotifications();
useEffect(() => {
void loadProfilesWithUpdateCheck();
// For some reason, app.deep_link().get_current() is not working properly
const checkCurrentUrl = useCallback(async () => {
try {
const currentUrl = await getCurrent();
if (currentUrl && currentUrl.length > 0) {
void handleUrlOpen(currentUrl[0]);
}
} catch (error) {
console.error("Failed to check current URL:", error);
}
}, [handleUrlOpen]);
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events
void listenForUrlEvents();
// Check for startup URLs (when app was launched as default browser)
void checkStartupUrls();
// Set up periodic update checks (every 30 minutes)
const updateInterval = setInterval(
() => {
void checkForUpdates();
},
30 * 60 * 1000,
);
return () => {
clearInterval(updateInterval);
};
}, [loadProfilesWithUpdateCheck, checkForUpdates]);
const checkStartupPrompt = async () => {
const checkStartupPrompt = useCallback(async () => {
// Only check once during app startup to prevent reopening after dismissing notifications
if (hasCheckedStartupPrompt) return;
@@ -135,9 +247,45 @@ export default function Home() {
console.error("Failed to check startup prompt:", error);
setHasCheckedStartupPrompt(true);
}
};
}, [hasCheckedStartupPrompt]);
const checkStartupUrls = async () => {
const checkAllPermissions = useCallback(async () => {
try {
// Wait for permissions to be initialized before checking
if (!isInitialized) {
return;
}
// Check if any permissions are not granted - prioritize missing permissions
if (!isMicrophoneAccessGranted) {
setCurrentPermissionType("microphone");
setPermissionDialogOpen(true);
} else if (!isCameraAccessGranted) {
setCurrentPermissionType("camera");
setPermissionDialogOpen(true);
}
} catch (error) {
console.error("Failed to check permissions:", error);
}
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
const checkNextPermission = useCallback(() => {
try {
if (!isMicrophoneAccessGranted) {
setCurrentPermissionType("microphone");
setPermissionDialogOpen(true);
} else if (!isCameraAccessGranted) {
setCurrentPermissionType("camera");
setPermissionDialogOpen(true);
} else {
setPermissionDialogOpen(false);
}
} catch (error) {
console.error("Failed to check next permission:", error);
}
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
const checkStartupUrls = useCallback(async () => {
try {
const hasStartupUrl = await invoke<boolean>(
"check_and_handle_startup_url",
@@ -148,9 +296,9 @@ export default function Home() {
} catch (error) {
console.error("Failed to check startup URLs:", error);
}
};
}, []);
const listenForUrlEvents = async () => {
const listenForUrlEvents = useCallback(async () => {
try {
// Listen for URL open events from the deep link handler (when app is already running)
await listen<string>("url-open-request", (event) => {
@@ -178,27 +326,7 @@ export default function Home() {
} catch (error) {
console.error("Failed to setup URL listener:", error);
}
};
const handleUrlOpen = async (url: string) => {
try {
// Use smart profile selection
const result = await invoke<string>("smart_open_url", {
url,
});
console.log("Smart URL opening succeeded:", result);
// URL was handled successfully, no need to show selector
} catch (error: unknown) {
console.log(
"Smart URL opening failed or requires profile selection:",
error,
);
// Show profile selector for manual selection
// Replace any existing pending URL with the new one
setPendingUrls([{ id: Date.now().toString(), url }]);
}
};
}, [handleUrlOpen]);
const openProxyDialog = useCallback((profile: BrowserProfile | null) => {
setCurrentProfileForProxy(profile);
@@ -210,8 +338,32 @@ export default function Home() {
setChangeVersionDialogOpen(true);
}, []);
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
setCurrentProfileForCamoufoxConfig(profile);
setCamoufoxConfigDialogOpen(true);
}, []);
const handleSaveCamoufoxConfig = useCallback(
async (profile: BrowserProfile, config: CamoufoxConfig) => {
setError(null);
try {
await invoke("update_camoufox_config", {
profileName: profile.name,
config,
});
await loadProfiles();
setCamoufoxConfigDialogOpen(false);
} catch (err: unknown) {
console.error("Failed to update camoufox config:", err);
setError(`Failed to update camoufox config: ${JSON.stringify(err)}`);
throw err;
}
},
[loadProfiles],
);
const handleSaveProxy = useCallback(
async (proxySettings: ProxySettings) => {
async (proxyId: string | null) => {
setProxyDialogOpen(false);
setError(null);
@@ -219,16 +371,18 @@ export default function Home() {
if (currentProfileForProxy) {
await invoke("update_profile_proxy", {
profileName: currentProfileForProxy.name,
proxy: proxySettings,
proxyId: proxyId,
});
}
await loadProfiles();
// Trigger proxy data reload in the table
triggerProxyDataReload();
} catch (err: unknown) {
console.error("Failed to update proxy settings:", err);
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
}
},
[currentProfileForProxy, loadProfiles],
[currentProfileForProxy, loadProfiles, triggerProxyDataReload],
);
const handleCreateProfile = useCallback(
@@ -236,29 +390,28 @@ export default function Home() {
name: string;
browserStr: BrowserTypeString;
version: string;
proxy?: ProxySettings;
releaseType: string;
proxyId?: string;
camoufoxConfig?: CamoufoxConfig;
}) => {
setError(null);
try {
const profile = await invoke<BrowserProfile>(
const _profile = await invoke<BrowserProfile>(
"create_browser_profile_new",
{
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
camoufoxConfig: profileData.camoufoxConfig,
},
);
// Update proxy if provided
if (profileData.proxy) {
await invoke("update_profile_proxy", {
profileName: profile.name,
proxy: profileData.proxy,
});
}
await loadProfiles();
// Trigger proxy data reload in the table
triggerProxyDataReload();
} catch (error) {
setError(
`Failed to create profile: ${
@@ -268,7 +421,7 @@ export default function Home() {
throw error;
}
},
[loadProfiles],
[loadProfiles, triggerProxyDataReload],
);
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
@@ -340,40 +493,39 @@ export default function Home() {
[loadProfiles, checkBrowserStatus, isUpdating],
);
useEffect(() => {
if (profiles.length === 0) return;
const interval = setInterval(() => {
for (const profile of profiles) {
void checkBrowserStatus(profile);
}
}, 500);
return () => {
clearInterval(interval);
};
}, [profiles, checkBrowserStatus]);
useEffect(() => {
runningProfilesRef.current = runningProfiles;
}, [runningProfiles]);
useEffect(() => {
if (error) {
showErrorToast(error);
setError(null);
}
}, [error]);
const handleDeleteProfile = useCallback(
async (profile: BrowserProfile) => {
setError(null);
console.log("Attempting to delete profile:", profile.name);
try {
// First check if the browser is running for this profile
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
if (isRunning) {
setError(
"Cannot delete profile while browser is running. Please stop the browser first.",
);
return;
}
// Attempt to delete the profile
await invoke("delete_profile", { profileName: profile.name });
console.log("Profile deletion command completed successfully");
// Give a small delay to ensure file system operations complete
await new Promise((resolve) => setTimeout(resolve, 100));
// Reload profiles to ensure UI is updated
await loadProfiles();
console.log("Profile deleted and profiles reloaded successfully");
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
setError(`Failed to delete profile: ${JSON.stringify(err)}`);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(`Failed to delete profile: ${errorMessage}`);
}
},
[loadProfiles],
@@ -408,8 +560,73 @@ export default function Home() {
[loadProfiles],
);
useEffect(() => {
void loadProfilesWithUpdateCheck();
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events
void listenForUrlEvents();
// Check for startup URLs (when app was launched as default browser)
void checkStartupUrls();
void checkCurrentUrl();
// Set up periodic update checks (every 30 minutes)
const updateInterval = setInterval(
() => {
void checkForUpdates();
},
30 * 60 * 1000,
);
return () => {
clearInterval(updateInterval);
};
}, [
loadProfilesWithUpdateCheck,
checkForUpdates,
checkCurrentUrl,
checkStartupPrompt,
listenForUrlEvents,
checkStartupUrls,
]);
useEffect(() => {
if (profiles.length === 0) return;
const interval = setInterval(() => {
for (const profile of profiles) {
void checkBrowserStatus(profile);
}
}, 500);
return () => {
clearInterval(interval);
};
}, [profiles, checkBrowserStatus]);
useEffect(() => {
runningProfilesRef.current = runningProfiles;
}, [runningProfiles]);
useEffect(() => {
if (error) {
showErrorToast(error);
setError(null);
}
}, [error]);
// Check permissions when they are initialized
useEffect(() => {
if (isInitialized) {
void checkAllPermissions();
}
}, [isInitialized, checkAllPermissions]);
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 gap-8 sm:p-12 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
<Card className="w-full">
<CardHeader>
@@ -435,6 +652,14 @@ export default function Home() {
<GoGear className="mr-2 w-4 h-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProxyManagementDialogOpen(true);
}}
>
<FiWifi className="mr-2 w-4 h-4" />
Proxies
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setImportProfileDialogOpen(true);
@@ -462,7 +687,7 @@ export default function Home() {
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<CardContent>
<ProfilesDataTable
data={profiles}
onLaunchProfile={launchProfile}
@@ -471,8 +696,12 @@ export default function Home() {
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onChangeVersion={openChangeVersionDialog}
onConfigureCamoufox={handleConfigureCamoufox}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onReloadProxyData={
proxyDataReloadTrigger > 0 ? triggerProxyDataReload : undefined
}
/>
</CardContent>
</Card>
@@ -483,8 +712,8 @@ export default function Home() {
onClose={() => {
setProxyDialogOpen(false);
}}
onSave={(proxy: ProxySettings) => void handleSaveProxy(proxy)}
initialSettings={currentProfileForProxy?.proxy}
onSave={handleSaveProxy}
initialProxyId={currentProfileForProxy?.proxy_id}
browserType={currentProfileForProxy?.browser}
/>
@@ -520,6 +749,13 @@ export default function Home() {
onImportComplete={() => void loadProfiles()}
/>
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
@@ -533,6 +769,24 @@ export default function Home() {
runningProfiles={runningProfiles}
/>
))}
<PermissionDialog
isOpen={permissionDialogOpen}
onClose={() => {
setPermissionDialogOpen(false);
}}
permissionType={currentPermissionType}
onPermissionGranted={checkNextPermission}
/>
<CamoufoxConfigDialog
isOpen={camoufoxConfigDialogOpen}
onClose={() => {
setCamoufoxConfigDialogOpen(false);
}}
profile={currentProfileForCamoufoxConfig}
onSave={handleSaveCamoufoxConfig}
/>
</div>
);
}
+129 -48
View File
@@ -1,26 +1,57 @@
"use client";
import { FaDownload, 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";
import React from "react";
import { FaDownload, FaTimes } from "react-icons/fa";
import { LuRefreshCw } from "react-icons/lu";
interface AppUpdateInfo {
current_version: string;
new_version: string;
release_notes: string;
download_url: string;
is_nightly: boolean;
published_at: string;
}
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
onDismiss: () => void;
isUpdating?: boolean;
updateProgress?: string;
updateProgress?: AppUpdateProgress | null;
}
function getStageIcon(stage?: string, isUpdating?: boolean) {
if (!isUpdating) {
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
}
switch (stage) {
case "downloading":
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
case "extracting":
return (
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
);
case "installing":
return (
<LuCog className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
);
case "completed":
return <LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />;
default:
return (
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
);
}
}
function getStageDisplayName(stage?: string) {
switch (stage) {
case "downloading":
return "Downloading";
case "extracting":
return "Extracting";
case "installing":
return "Installing";
case "completed":
return "Completed";
default:
return "Updating";
}
}
export function AppUpdateToast({
@@ -34,22 +65,32 @@ export function AppUpdateToast({
await onUpdate(updateInfo);
};
const showDownloadProgress =
isUpdating &&
updateProgress?.stage === "downloading" &&
updateProgress.percentage !== undefined;
const showOtherStageProgress =
isUpdating &&
updateProgress &&
(updateProgress.stage === "extracting" ||
updateProgress.stage === "installing" ||
updateProgress.stage === "completed");
return (
<div className="flex items-start w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-lg max-w-md">
<div className="flex items-start p-4 w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div className="mr-3 mt-0.5">
{isUpdating ? (
<LuRefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
) : (
<FaDownload className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
{getStageIcon(updateProgress?.stage, isUpdating)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex gap-2 justify-between items-start">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground text-sm">
Donut Browser Update Available
<div className="flex gap-2 items-center">
<span className="text-sm font-semibold text-foreground">
{isUpdating
? `${getStageDisplayName(updateProgress?.stage)} Donut Browser Update`
: "Donut Browser Update Available"}
</span>
<Badge
variant={updateInfo.is_nightly ? "secondary" : "default"}
@@ -59,8 +100,14 @@ export function AppUpdateToast({
</Badge>
</div>
<div className="text-xs text-muted-foreground">
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">{updateInfo.new_version}</span>
{isUpdating ? (
updateProgress?.message || "Updating..."
) : (
<>
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">{updateInfo.new_version}</span>
</>
)}
</div>
</div>
@@ -69,27 +116,76 @@ export function AppUpdateToast({
variant="ghost"
size="sm"
onClick={onDismiss}
className="h-6 w-6 p-0 shrink-0"
className="p-0 w-6 h-6 shrink-0"
>
<FaTimes className="h-3 w-3" />
<FaTimes className="w-3 h-3" />
</Button>
)}
</div>
{isUpdating && updateProgress && (
<div className="mt-2">
<p className="text-xs text-muted-foreground">{updateProgress}</p>
{/* Download progress */}
{showDownloadProgress && updateProgress && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
{updateProgress.percentage?.toFixed(1)}%
{updateProgress.speed && `${updateProgress.speed} MB/s`}
{updateProgress.eta && `${updateProgress.eta} remaining`}
</p>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${updateProgress.percentage}%` }}
/>
</div>
</div>
)}
{/* Other stage progress (with visual indicators) */}
{showOtherStageProgress && (
<div className="mt-2 space-y-2">
<p className="text-xs text-muted-foreground">
{updateProgress.message}
</p>
{/* Progress indicator for non-downloading stages */}
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-500 ${
updateProgress.stage === "completed"
? "bg-green-500 w-full"
: "bg-blue-500 w-full animate-pulse"
}`}
/>
</div>
{updateProgress.stage === "extracting" && (
<p className="text-xs text-muted-foreground">
Preparing update files...
</p>
)}
{updateProgress.stage === "installing" && (
<p className="text-xs text-muted-foreground">
Installing new version...
</p>
)}
{updateProgress.stage === "completed" && (
<p className="text-xs text-green-600 dark:text-green-400">
Update completed! Restarting application...
</p>
)}
</div>
)}
{!isUpdating && (
<div className="flex items-center gap-2 mt-3">
<div className="flex gap-2 items-center mt-3">
<Button
onClick={() => void handleUpdateClick()}
size="sm"
className="flex items-center gap-2 text-xs"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="h-3 w-3" />
<FaDownload className="w-3 h-3" />
Update Now
</Button>
<Button
@@ -102,21 +198,6 @@ export function AppUpdateToast({
</Button>
</div>
)}
{updateInfo.release_notes && !isUpdating && (
<div className="mt-2">
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Release Notes
</summary>
<div className="mt-1 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
{updateInfo.release_notes.length > 200
? `${updateInfo.release_notes.substring(0, 200)}...`
: updateInfo.release_notes}
</div>
</details>
</div>
)}
</div>
</div>
);
+502
View File
@@ -0,0 +1,502 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
interface CamoufoxConfigDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
onSave: (profile: BrowserProfile, config: CamoufoxConfig) => Promise<void>;
}
const osOptions = [
{ value: "windows", label: "Windows" },
{ value: "macos", label: "macOS" },
{ value: "linux", label: "Linux" },
];
const timezoneOptions = [
{ value: "America/New_York", label: "America/New_York" },
{ value: "America/Los_Angeles", label: "America/Los_Angeles" },
{ value: "Europe/London", label: "Europe/London" },
{ value: "Europe/Paris", label: "Europe/Paris" },
{ value: "Asia/Tokyo", label: "Asia/Tokyo" },
{ value: "Asia/Shanghai", label: "Asia/Shanghai" },
{ value: "Australia/Sydney", label: "Australia/Sydney" },
];
const localeOptions = [
{ value: "en-US", label: "English (US)" },
{ value: "en-GB", label: "English (UK)" },
{ value: "fr-FR", label: "French" },
{ value: "de-DE", label: "German" },
{ value: "es-ES", label: "Spanish" },
{ value: "it-IT", label: "Italian" },
{ value: "ja-JP", label: "Japanese" },
{ value: "zh-CN", label: "Chinese (Simplified)" },
];
const getCurrentOS = () => {
if (typeof window !== "undefined") {
const userAgent = window.navigator.userAgent;
if (userAgent.includes("Win")) return "windows";
if (userAgent.includes("Mac")) return "macos";
if (userAgent.includes("Linux")) return "linux";
}
return "unknown";
};
export function CamoufoxConfigDialog({
isOpen,
onClose,
profile,
onSave,
}: CamoufoxConfigDialogProps) {
const [config, setConfig] = useState<CamoufoxConfig>({
enable_cache: true,
os: [getCurrentOS()],
});
const [isSaving, setIsSaving] = useState(false);
// Initialize config when profile changes
useEffect(() => {
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
},
);
}
}, [profile]);
const updateConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
const handleSave = async () => {
if (!profile) return;
setIsSaving(true);
try {
await onSave(profile, config);
onClose();
} catch (error) {
console.error("Failed to save camoufox config:", error);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
// Reset config to original when closing without saving
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
},
);
}
onClose();
};
if (!profile || profile.browser !== "camoufox") {
return null;
}
// Get the selected OS for warning
const selectedOS = config.os?.[0];
const currentOS = getCurrentOS();
const showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
Configure Camoufox Settings - {profile.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-6 h-[320px]">
<div className="py-4 space-y-6">
{/* Operating System */}
<div className="space-y-3">
<Label>Operating System Fingerprint</Label>
<Select
value={selectedOS || ""}
onValueChange={(value: string) => updateConfig("os", [value])}
>
<SelectTrigger>
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
{osOptions.map((os) => (
<SelectItem key={os.value} value={os.value}>
{os.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showOSWarning && (
<div className="p-3 bg-amber-50 rounded-md border border-amber-200">
<p className="text-sm text-amber-800">
Warning: Spoofing OS features is detectable by advanced
anti-bot systems. Some platform-specific APIs and behaviors
cannot be fully replicated.
</p>
</div>
)}
</div>
{/* Blocking Options */}
<div className="space-y-3">
<Label>Privacy & Blocking</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="block-images"
checked={config.block_images || false}
onCheckedChange={(checked) =>
updateConfig("block_images", checked)
}
/>
<Label htmlFor="block-images">Block Images</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webrtc"
checked={config.block_webrtc || false}
onCheckedChange={(checked) =>
updateConfig("block_webrtc", checked)
}
/>
<Label htmlFor="block-webrtc">Block WebRTC</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webgl"
checked={config.block_webgl || false}
onCheckedChange={(checked) =>
updateConfig("block_webgl", checked)
}
/>
<Label htmlFor="block-webgl">Block WebGL</Label>
</div>
</div>
</div>
{/* Geolocation */}
<div className="space-y-3">
<Label>Geolocation</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Input
id="country"
value={config.country || ""}
onChange={(e) =>
updateConfig("country", e.target.value || undefined)
}
placeholder="e.g., US, GB, DE"
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Select
value={config.timezone || "auto"}
onValueChange={(value) =>
updateConfig(
"timezone",
value === "auto" ? undefined : value,
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
{timezoneOptions.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
type="number"
step="any"
value={config.latitude || ""}
onChange={(e) =>
updateConfig(
"latitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 40.7128"
/>
</div>
<div className="space-y-2">
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
type="number"
step="any"
value={config.longitude || ""}
onChange={(e) =>
updateConfig(
"longitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., -74.0060"
/>
</div>
</div>
</div>
{/* Localization */}
<div className="space-y-3">
<Label>Locale</Label>
<Select
value={config.locale?.[0] || ""}
onValueChange={(value) =>
updateConfig("locale", value ? [value] : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select locale" />
</SelectTrigger>
<SelectContent>
{localeOptions.map((locale) => (
<SelectItem key={locale.value} value={locale.value}>
{locale.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Screen Resolution */}
<div className="space-y-3">
<Label>Screen Resolution</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-width">Min Width</Label>
<Input
id="screen-min-width"
type="number"
value={config.screen_min_width || ""}
onChange={(e) =>
updateConfig(
"screen_min_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1024"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-width">Max Width</Label>
<Input
id="screen-max-width"
type="number"
value={config.screen_max_width || ""}
onChange={(e) =>
updateConfig(
"screen_max_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1920"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-height">Min Height</Label>
<Input
id="screen-min-height"
type="number"
value={config.screen_min_height || ""}
onChange={(e) =>
updateConfig(
"screen_min_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-height">Max Height</Label>
<Input
id="screen-max-height"
type="number"
value={config.screen_max_height || ""}
onChange={(e) =>
updateConfig(
"screen_max_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1080"
/>
</div>
</div>
</div>
{/* Window Size */}
<div className="space-y-3">
<Label>Window Size</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="window-width">Width</Label>
<Input
id="window-width"
type="number"
value={config.window_width || ""}
onChange={(e) =>
updateConfig(
"window_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1366"
/>
</div>
<div className="space-y-2">
<Label htmlFor="window-height">Height</Label>
<Input
id="window-height"
type="number"
value={config.window_height || ""}
onChange={(e) =>
updateConfig(
"window_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
</div>
</div>
{/* Advanced Options */}
<div className="space-y-3">
<Label>Advanced Options</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-cache"
checked={config.enable_cache || false}
onCheckedChange={(checked) =>
updateConfig("enable_cache", checked)
}
/>
<Label htmlFor="enable-cache">Enable Browser Cache</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="main-world-eval"
checked={config.main_world_eval || false}
onCheckedChange={(checked) =>
updateConfig("main_world_eval", checked)
}
/>
<Label htmlFor="main-world-eval">
Enable Main World Evaluation
</Label>
</div>
</div>
</div>
{/* WebGL Settings */}
<div className="space-y-3">
<Label>WebGL Settings</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">WebGL Vendor</Label>
<Input
id="webgl-vendor"
value={config.webgl_vendor || ""}
onChange={(e) =>
updateConfig("webgl_vendor", e.target.value || undefined)
}
placeholder="e.g., Intel Inc."
/>
</div>
<div className="space-y-2">
<Label htmlFor="webgl-renderer">WebGL Renderer</Label>
<Input
id="webgl-renderer"
value={config.webgl_renderer || ""}
onChange={(e) =>
updateConfig(
"webgl_renderer",
e.target.value || undefined,
)
}
placeholder="e.g., Intel Iris OpenGL Engine"
/>
</div>
</div>
</div>
{/* Debug Options */}
<div className="space-y-3">
<Label>Debug Options</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="debug"
checked={config.debug || false}
onCheckedChange={(checked) => updateConfig("debug", checked)}
/>
<Label htmlFor="debug">Enable Debug Mode</Label>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+177 -66
View File
@@ -1,6 +1,10 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { LuTriangleAlert } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ReleaseTypeSelector } from "@/components/release-type-selector";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -12,12 +16,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import type { BrowserProfile } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { LuTriangleAlert } from "react-icons/lu";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, BrowserReleaseTypes } from "@/types";
interface ChangeVersionDialogProps {
isOpen: boolean;
@@ -32,66 +33,82 @@ export function ChangeVersionDialog({
profile,
onVersionChanged,
}: ChangeVersionDialogProps) {
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [selectedReleaseType, setSelectedReleaseType] = useState<
"stable" | "nightly" | null
>(null);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>({});
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [showDowngradeWarning, setShowDowngradeWarning] = useState(false);
const [acknowledgeDowngrade, setAcknowledgeDowngrade] = useState(false);
const {
availableVersions,
downloadedVersions,
isDownloading,
loadVersions,
loadDownloadedVersions,
downloadBrowser,
isVersionDownloaded,
} = useBrowserDownload();
useEffect(() => {
if (isOpen && profile) {
setSelectedVersion(profile.version);
setAcknowledgeDowngrade(false);
void loadVersions(profile.browser);
void loadDownloadedVersions(profile.browser);
const loadReleaseTypes = useCallback(async (browser: string) => {
setIsLoadingReleaseTypes(true);
try {
const releaseTypes = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{ browserStr: browser },
);
setReleaseTypes(releaseTypes);
} catch (error) {
console.error("Failed to load release types:", error);
} finally {
setIsLoadingReleaseTypes(false);
}
}, [isOpen, profile, loadVersions, loadDownloadedVersions]);
}, []);
useEffect(() => {
if (profile && selectedVersion) {
// Check if this is a downgrade
const currentVersionIndex = availableVersions.findIndex(
(v) => v.tag_name === profile.version,
);
const selectedVersionIndex = availableVersions.findIndex(
(v) => v.tag_name === selectedVersion,
);
// If selected version has a higher index, it's older (downgrade)
if (
profile &&
selectedReleaseType &&
selectedReleaseType !== profile.release_type
) {
// For simplicity, we'll show downgrade warning when switching from stable to nightly
// since nightly versions might be considered "downgrades" in terms of stability
const isDowngrade =
currentVersionIndex !== -1 &&
selectedVersionIndex !== -1 &&
selectedVersionIndex > currentVersionIndex;
profile.release_type === "stable" && selectedReleaseType === "nightly";
setShowDowngradeWarning(isDowngrade);
if (!isDowngrade) {
setAcknowledgeDowngrade(false);
}
}
}, [selectedVersion, profile, availableVersions]);
}, [selectedReleaseType, profile]);
const handleDownload = async () => {
if (!profile || !selectedVersion) return;
await downloadBrowser(profile.browser, selectedVersion);
};
const handleDownload = useCallback(async () => {
if (!profile || !selectedReleaseType) return;
const handleVersionChange = async () => {
if (!profile || !selectedVersion) return;
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) return;
await downloadBrowser(profile.browser, version);
}, [profile, selectedReleaseType, downloadBrowser, releaseTypes]);
const handleVersionChange = useCallback(async () => {
if (!profile || !selectedReleaseType) return;
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) return;
setIsUpdating(true);
try {
await invoke("update_profile_version", {
profileName: profile.name,
version: selectedVersion,
version,
});
onVersionChanged();
onClose();
@@ -100,66 +117,160 @@ export function ChangeVersionDialog({
} finally {
setIsUpdating(false);
}
};
}, [profile, selectedReleaseType, releaseTypes, onVersionChanged, onClose]);
const selectedVersion =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
const canUpdate =
profile &&
selectedVersion &&
selectedVersion !== profile.version &&
selectedReleaseType &&
selectedReleaseType !== profile.release_type &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
(!showDowngradeWarning || acknowledgeDowngrade);
useEffect(() => {
if (isOpen && profile) {
// Set current release type based on profile
setSelectedReleaseType(profile.release_type as "stable" | "nightly");
setAcknowledgeDowngrade(false);
void loadReleaseTypes(profile.browser);
void loadDownloadedVersions(profile.browser);
}
}, [isOpen, profile, loadDownloadedVersions, loadReleaseTypes]);
if (!profile) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Change Browser Version</DialogTitle>
<DialogTitle>Change Release Type</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Profile:</Label>
<div className="p-2 bg-muted rounded text-sm">{profile.name}</div>
<div className="p-2 text-sm rounded bg-muted">{profile.name}</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Current Version:</Label>
<div className="p-2 bg-muted rounded text-sm">
{profile.version}
<Label className="text-sm font-medium">Current Release:</Label>
<div className="p-2 text-sm capitalize rounded bg-muted">
{profile.release_type} ({profile.version})
</div>
</div>
{/* Version Selection */}
<div className="grid gap-2">
<Label>New Version</Label>
<VersionSelector
selectedVersion={selectedVersion}
onVersionSelect={setSelectedVersion}
availableVersions={availableVersions}
downloadedVersions={downloadedVersions}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select version..."
/>
</div>
{!releaseTypes.stable && !releaseTypes.nightly ? (
<Alert>
<AlertDescription>
No releases are available for{" "}
{getBrowserDisplayName(profile.browser)}.
</AlertDescription>
</Alert>
) : !releaseTypes.stable || !releaseTypes.nightly ? (
<div className="space-y-4">
<Alert>
<AlertDescription>
Only {profile.release_type} releases are available for{" "}
{getBrowserDisplayName(profile.browser)}.
</AlertDescription>
</Alert>
<div className="grid gap-2">
<Label>New Release Type</Label>
{isLoadingReleaseTypes ? (
<div className="text-sm text-muted-foreground">
Loading release types...
</div>
) : (
<div className="space-y-4">
{selectedReleaseType &&
selectedReleaseType !== profile.release_type &&
selectedVersion &&
!isVersionDownloaded(selectedVersion) && (
<Alert>
<AlertDescription>
You must download{" "}
{getBrowserDisplayName(profile.browser)}{" "}
{selectedVersion} before switching to this release
type. Use the download button above to get the
latest version.
</AlertDescription>
</Alert>
)}
<ReleaseTypeSelector
selectedReleaseType={selectedReleaseType}
onReleaseTypeSelect={setSelectedReleaseType}
availableReleaseTypes={releaseTypes}
browser={profile.browser}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select release type..."
downloadedVersions={downloadedVersions}
/>
</div>
)}
</div>
</div>
) : (
<div className="grid gap-2">
<Label>New Release Type</Label>
{isLoadingReleaseTypes ? (
<div className="text-sm text-muted-foreground">
Loading release types...
</div>
) : (
<div className="space-y-4">
{selectedReleaseType &&
selectedReleaseType !== profile.release_type &&
selectedVersion &&
!isVersionDownloaded(selectedVersion) && (
<Alert>
<AlertDescription>
You must download{" "}
{getBrowserDisplayName(profile.browser)}{" "}
{selectedVersion} before switching to this release
type. Use the download button above to get the latest
version.
</AlertDescription>
</Alert>
)}
<ReleaseTypeSelector
selectedReleaseType={selectedReleaseType}
onReleaseTypeSelect={setSelectedReleaseType}
availableReleaseTypes={releaseTypes}
browser={profile.browser}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select release type..."
downloadedVersions={downloadedVersions}
/>
</div>
)}
</div>
)}
{/* Downgrade Warning */}
{showDowngradeWarning && (
<Alert className="border-orange-700">
<LuTriangleAlert className="h-4 w-4 text-orange-700" />
<LuTriangleAlert className="w-4 h-4 text-orange-700" />
<AlertTitle className="text-orange-700">
Downgrade Warning
Stability Warning
</AlertTitle>
<AlertDescription className="text-orange-700">
You are about to downgrade from version {profile.version} to{" "}
{selectedVersion}. This may lead to compatibility issues, data
loss, or unexpected behavior.
<div className="flex items-center space-x-2 mt-3">
You are about to switch from stable to nightly releases. Nightly
versions may be less stable and could contain bugs or incomplete
features.
<div className="flex items-center mt-3 space-x-2">
<Checkbox
id="acknowledge-downgrade"
checked={acknowledgeDowngrade}
@@ -187,7 +298,7 @@ export function ChangeVersionDialog({
}}
disabled={!canUpdate}
>
{isUpdating ? "Updating..." : "Update Version"}
{isUpdating ? "Updating..." : "Update Release Type"}
</LoadingButton>
</DialogFooter>
</DialogContent>
+385 -337
View File
@@ -1,8 +1,11 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@@ -12,6 +15,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -19,19 +23,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { VersionSelector } from "@/components/version-selector";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getBrowserIcon, getCurrentOS } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -40,7 +35,8 @@ type BrowserTypeString =
| "chromium"
| "brave"
| "zen"
| "tor-browser";
| "tor-browser"
| "camoufox";
interface CreateProfileDialogProps {
isOpen: boolean;
@@ -49,168 +45,212 @@ interface CreateProfileDialogProps {
name: string;
browserStr: BrowserTypeString;
version: string;
proxy?: ProxySettings;
releaseType: string;
proxyId?: string;
camoufoxConfig?: CamoufoxConfig;
}) => Promise<void>;
}
interface BrowserOption {
value: BrowserTypeString;
label: string;
description: string;
}
const browserOptions: BrowserOption[] = [
{
value: "firefox",
label: "Firefox",
description: "Mozilla's main web browser",
},
{
value: "firefox-developer",
label: "Firefox Developer Edition",
description: "Browser for developers with cutting-edge features",
},
{
value: "chromium",
label: "Chromium",
description: "Open-source version of Chrome",
},
{
value: "brave",
label: "Brave",
description: "Privacy-focused browser with ad blocking",
},
{
value: "zen",
label: "Zen Browser",
description: "Beautiful, customizable Firefox-based browser",
},
{
value: "mullvad-browser",
label: "Mullvad Browser",
description: "Privacy browser by Mullvad VPN",
},
{
value: "tor-browser",
label: "Tor Browser",
description: "Browse anonymously through the Tor network",
},
];
const IS_ANTI_DETECT_SUPPORTED = false;
export function CreateProfileDialog({
isOpen,
onClose,
onCreateProfile,
}: CreateProfileDialogProps) {
const [profileName, setProfileName] = useState("");
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser");
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("regular");
// Regular browser states
const [selectedBrowser, setSelectedBrowser] = useState<BrowserTypeString>();
const [selectedProxyId, setSelectedProxyId] = useState<string>();
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
enable_cache: true, // Cache enabled by default
os: [getCurrentOS()], // Default to current OS
});
// Common states
const [availableReleaseTypes, setAvailableReleaseTypes] =
useState<BrowserReleaseTypes>({});
const [camoufoxReleaseTypes, setCamoufoxReleaseTypes] =
useState<BrowserReleaseTypes>({});
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[],
);
// Proxy settings
const [proxyEnabled, setProxyEnabled] = useState(false);
const [proxyType, setProxyType] = useState("http");
const [proxyHost, setProxyHost] = useState("");
const [proxyPort, setProxyPort] = useState(8080);
// Use the browser download hook
const {
availableVersions,
downloadedVersions,
isDownloading,
loadVersions,
loadDownloadedVersions,
isBrowserDownloading,
downloadBrowser,
loadDownloadedVersions,
isVersionDownloaded,
} = useBrowserDownload();
const {
supportedBrowsers,
isLoading: isLoadingSupport,
isBrowserSupported,
} = useBrowserSupport();
const loadSupportedBrowsers = useCallback(async () => {
try {
const browsers = await invoke<string[]>("get_supported_browsers");
setSupportedBrowsers(browsers);
} catch (error) {
console.error("Failed to load supported browsers:", error);
}
}, []);
const loadStoredProxies = useCallback(async () => {
try {
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxies);
} catch (error) {
console.error("Failed to load stored proxies:", error);
}
}, []);
const loadReleaseTypes = useCallback(
async (browser: string) => {
try {
const releaseTypes = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{ browserStr: browser },
);
if (browser === "camoufox") {
setCamoufoxReleaseTypes(releaseTypes);
} else {
setAvailableReleaseTypes(releaseTypes);
}
// Load downloaded versions for this browser
await loadDownloadedVersions(browser);
} catch (error) {
console.error(`Failed to load release types for ${browser}:`, error);
}
},
[loadDownloadedVersions],
);
// Load data when dialog opens
useEffect(() => {
if (isOpen) {
void loadExistingProfiles();
void loadSupportedBrowsers();
void loadStoredProxies();
// Load camoufox release types when dialog opens
void loadReleaseTypes("camoufox");
}
}, [isOpen]);
}, [isOpen, loadSupportedBrowsers, loadStoredProxies, loadReleaseTypes]);
// Load release types when browser selection changes
useEffect(() => {
if (supportedBrowsers.length > 0) {
// Set default browser to first supported browser
if (supportedBrowsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (supportedBrowsers.length > 0) {
setSelectedBrowser(supportedBrowsers[0] as BrowserTypeString);
}
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
}
}, [supportedBrowsers]);
}, [selectedBrowser, loadReleaseTypes]);
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected version when browser changes
setSelectedVersion(null);
void loadVersions(selectedBrowser);
void loadDownloadedVersions(selectedBrowser);
}
}, [isOpen, selectedBrowser, loadVersions, loadDownloadedVersions]);
const handleDownload = async (browserStr: string) => {
const releaseTypes =
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
const latestStableVersion = releaseTypes.stable;
// Set default version when versions are loaded and no version is selected
useEffect(() => {
if (availableVersions.length > 0 && selectedBrowser) {
// Always reset version when browser changes or versions are loaded
// Find the latest stable version (not alpha/beta)
const stableVersions = availableVersions.filter((v) => !v.is_nightly);
if (stableVersions.length > 0) {
// Select the first stable version (they're already sorted newest first)
setSelectedVersion(stableVersions[0].tag_name);
} else if (availableVersions.length > 0) {
// If no stable version found, select the first available version
setSelectedVersion(availableVersions[0].tag_name);
}
}
}, [availableVersions, selectedBrowser]);
const loadExistingProfiles = async () => {
try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
setExistingProfiles(profiles);
} catch (error) {
console.error("Failed to load existing profiles:", error);
}
};
const handleDownload = async () => {
if (!selectedBrowser || !selectedVersion) return;
await downloadBrowser(selectedBrowser, selectedVersion);
};
const validateProfileName = (name: string): string | null => {
const trimmedName = name.trim();
if (!trimmedName) {
return "Profile name cannot be empty";
}
// Check for duplicate names (case insensitive)
const isDuplicate = existingProfiles.some(
(profile) => profile.name.toLowerCase() === trimmedName.toLowerCase(),
);
if (isDuplicate) {
return "A profile with this name already exists";
}
return null;
};
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = selectedBrowser === "tor-browser";
// Update proxy enabled state when browser changes to tor-browser
useEffect(() => {
if (selectedBrowser === "tor-browser" && proxyEnabled) {
setProxyEnabled(false);
}
}, [selectedBrowser, proxyEnabled]);
const handleCreate = async () => {
if (!profileName.trim() || !selectedBrowser || !selectedVersion) return;
// Validate profile name
const nameError = validateProfileName(profileName);
if (nameError) {
toast.error(nameError);
if (!latestStableVersion) {
console.error("No stable version available for download");
return;
}
try {
await downloadBrowser(browserStr, latestStableVersion);
} catch (error) {
console.error("Failed to download browser:", error);
}
};
const handleCreate = async () => {
if (!profileName.trim()) return;
setIsCreating(true);
try {
const proxy =
proxyEnabled && !isProxyDisabled
? {
enabled: true,
proxy_type: proxyType,
host: proxyHost,
port: proxyPort,
}
: undefined;
if (activeTab === "regular") {
if (!selectedBrowser) {
console.error("Missing required browser selection");
return;
}
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version: selectedVersion,
proxy,
});
// Use the latest stable version by default
const latestStableVersion = availableReleaseTypes.stable;
if (!latestStableVersion) {
console.error("No stable version available");
return;
}
// Reset form
setProfileName("");
setSelectedVersion(null);
setProxyEnabled(false);
setProxyHost("");
setProxyPort(8080);
onClose();
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version: latestStableVersion,
releaseType: "stable",
proxyId: selectedProxyId,
});
} else {
// Anti-detect tab - always use Camoufox with latest version
const latestCamoufoxVersion = camoufoxReleaseTypes.stable;
if (!latestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
}
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: latestCamoufoxVersion,
releaseType: "stable",
proxyId: selectedProxyId,
camoufoxConfig,
});
}
handleClose();
} catch (error) {
console.error("Failed to create profile:", error);
} finally {
@@ -218,219 +258,227 @@ export function CreateProfileDialog({
}
};
const nameError = profileName.trim()
? validateProfileName(profileName)
: null;
const canCreate =
profileName.trim() &&
selectedBrowser &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
(!proxyEnabled || isProxyDisabled || (proxyHost && proxyPort)) &&
!nameError;
const handleClose = () => {
// Reset all states
setProfileName("");
setSelectedBrowser(undefined);
setSelectedProxyId(undefined);
setCamoufoxConfig({
enable_cache: true,
os: [getCurrentOS()], // Reset to current OS
});
setActiveTab("regular");
onClose();
};
const isCreateDisabled = () => {
if (!profileName.trim()) return true;
if (activeTab === "regular") {
return !selectedBrowser || !availableReleaseTypes.stable;
} else {
// For anti-detect, we need camoufox to be available
return !camoufoxReleaseTypes.stable;
}
};
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
};
// Check if browser version is downloaded and available
const isBrowserVersionAvailable = (browserStr: string) => {
const releaseTypes =
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
const latestStableVersion = releaseTypes.stable;
return latestStableVersion && isVersionDownloaded(latestStableVersion);
};
// Get the selected OS for warning
const selectedOS = camoufoxConfig.os?.[0];
const currentOS = getCurrentOS();
const _showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Profile Name */}
<div className="grid gap-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => {
setProfileName(e.target.value);
}}
placeholder="Enter profile name"
className={nameError ? "border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 w-full min-h-0"
>
<TabsList className="grid flex-shrink-0 grid-cols-2 w-full">
<TabsTrigger value="regular">Regular Browsers</TabsTrigger>
<TabsTrigger value="anti-detect">Anti-Detect</TabsTrigger>
</TabsList>
{/* Browser Selection */}
<div className="grid gap-2">
<Label>Browser</Label>
<Select
value={selectedBrowser ?? undefined}
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
disabled={isLoadingSupport}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSupport ? "Loading browsers..." : "Select browser"
}
<ScrollArea className="flex-1 pr-6 h-[320px]">
<div className="py-4 space-y-6">
{/* Profile Name - Common to both tabs */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="Enter profile name"
/>
</SelectTrigger>
<SelectContent>
{(
[
"mullvad-browser",
"firefox",
"firefox-developer",
"chromium",
"brave",
"zen",
"tor-browser",
] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
</div>
if (!isSupported) {
return (
<Tooltip key={browser}>
<TooltipTrigger asChild>
<SelectItem
value={browser}
disabled={true}
className="opacity-50"
>
{displayName} (Not supported on this platform)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
<p>
{displayName} is not supported on your current
platform or architecture.
</p>
</TooltipContent>
</Tooltip>
);
}
<TabsContent value="regular" className="mt-0 space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>Browser</Label>
<Combobox
options={browserOptions
.filter((browser) =>
supportedBrowsers.includes(browser.value),
)
.map((browser) => {
const IconComponent = getBrowserIcon(browser.value);
return {
value: browser.value,
label: browser.label,
icon: IconComponent,
};
})}
value={selectedBrowser || ""}
onValueChange={(value) =>
setSelectedBrowser(value as BrowserTypeString)
}
placeholder="Select a browser..."
searchPlaceholder="Search browsers..."
/>
</div>
return (
<SelectItem key={browser} value={browser}>
{displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Version Selection */}
<div className="grid gap-2">
<Label>Version</Label>
<VersionSelector
selectedVersion={selectedVersion}
onVersionSelect={setSelectedVersion}
availableVersions={availableVersions}
downloadedVersions={downloadedVersions}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select version..."
/>
</div>
{/* Proxy Settings */}
<div className="grid gap-4 pt-4 border-t">
<div className="flex items-center space-x-2">
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 opacity-50">
<Checkbox
id="proxy-enabled"
checked={false}
disabled={true}
/>
<Label htmlFor="proxy-enabled" className="text-gray-500">
Enable Proxy
</Label>
{selectedBrowser && (
<div className="space-y-3">
{!isBrowserVersionAvailable(selectedBrowser) &&
availableReleaseTypes.stable && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
Latest stable version (
{availableReleaseTypes.stable}) needs to be
downloaded
</p>
<LoadingButton
onClick={() => handleDownload(selectedBrowser)}
isLoading={isBrowserDownloading(selectedBrowser)}
size="sm"
disabled={isBrowserDownloading(selectedBrowser)}
>
Download
</LoadingButton>
</div>
)}
{isBrowserVersionAvailable(selectedBrowser) && (
<div className="text-sm text-green-600">
Latest stable version (
{availableReleaseTypes.stable}) is available
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser has its own built-in proxy system and
doesn&apos;t support additional proxy configuration
</p>
</TooltipContent>
</Tooltip>
) : (
<>
<Checkbox
id="proxy-enabled"
checked={proxyEnabled}
onCheckedChange={(checked) => {
setProxyEnabled(checked as boolean);
}}
/>
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
</>
)}
</div>
)}
</div>
</TabsContent>
{proxyEnabled && !isProxyDisabled && (
<>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select value={proxyType} onValueChange={setProxyType}>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
<div className="p-3 text-center bg-blue-50 rounded-md border border-blue-200 dark:bg-blue-950 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
Anti-Detect support is coming soon!
</p>
</div>
</TabsContent>
{IS_ANTI_DETECT_SUPPORTED && (
<TabsContent value="anti-detect" className="mt-0 space-y-6">
{/* Anti-Detect Description */}
<div className="p-3 text-center bg-blue-50 rounded-md border border-blue-200 dark:bg-blue-950 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
Powered by Camoufox
</p>
</div>
<div className="space-y-6">
{/* Camoufox Download Status */}
{!isBrowserVersionAvailable("camoufox") &&
camoufoxReleaseTypes.stable && (
<div className="flex gap-3 items-center p-3 bg-amber-50 rounded-md border border-amber-200">
<p className="text-sm text-amber-800">
Camoufox version ({camoufoxReleaseTypes.stable})
needs to be downloaded
</p>
<LoadingButton
onClick={() => handleDownload("camoufox")}
isLoading={isBrowserDownloading("camoufox")}
size="sm"
disabled={isBrowserDownloading("camoufox")}
>
Download
</LoadingButton>
</div>
)}
{isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm text-green-600 bg-green-50 rounded-md border border-green-200">
Camoufox version ({camoufoxReleaseTypes.stable}) is
available
</div>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
/>
</div>
</TabsContent>
)}
{/* Proxy Selection - Common to both tabs - Compact without card */}
{storedProxies.length > 0 && (
<div className="space-y-3">
<Label>Proxy</Label>
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(value === "none" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue />
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name} ({proxy.proxy_settings.proxy_type}://
{proxy.proxy_settings.host}:
{proxy.proxy_settings.port})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</ScrollArea>
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={proxyHost}
onChange={(e) => {
setProxyHost(e.target.value);
}}
placeholder="e.g. 127.0.0.1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={proxyPort}
onChange={(e) => {
setProxyPort(Number.parseInt(e.target.value, 10) || 0);
}}
placeholder="e.g. 8080"
min="1"
max="65535"
/>
</div>
</>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<LoadingButton
isLoading={isCreating}
onClick={() => void handleCreate()}
disabled={!canCreate}
>
Create Profile
</LoadingButton>
</DialogFooter>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<LoadingButton
onClick={handleCreate}
isLoading={isCreating}
disabled={isCreateDisabled()}
>
Create Profile
</LoadingButton>
</DialogFooter>
</Tabs>
</DialogContent>
</Dialog>
);
+163 -32
View File
@@ -10,6 +10,7 @@
* - Progress bars for downloads/updates
* - Success/error states
* - Customizable icons and content
* - Auto-update notifications
*
* Usage Examples:
*
@@ -23,6 +24,11 @@
* });
* ```
*
* Auto-update toast:
* ```
* showAutoUpdateToast("Firefox", "125.0.1");
* ```
*
* Download progress toast:
* ```
* showToast({
@@ -42,11 +48,11 @@
* ```
*/
import React from "react";
import {
LuCheckCheck,
LuDownload,
LuRefreshCw,
LuRocket,
LuTriangleAlert,
} from "react-icons/lu";
@@ -90,6 +96,7 @@ interface VersionUpdateToastProps extends BaseToastProps {
current: number;
total: number;
found: number;
current_browser?: string;
};
}
@@ -104,6 +111,16 @@ interface TwilightUpdateToastProps extends BaseToastProps {
hasUpdate?: boolean;
}
interface AppUpdateToastProps extends BaseToastProps {
type: "app-update";
stage?: "downloading" | "extracting" | "installing" | "completed";
progress?: {
percentage: number;
speed?: string;
eta?: string;
};
}
type ToastProps =
| LoadingToastProps
| SuccessToastProps
@@ -111,36 +128,56 @@ type ToastProps =
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps
| TwilightUpdateToastProps;
| TwilightUpdateToastProps
| AppUpdateToastProps;
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />;
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />;
case "error":
return <LuTriangleAlert className="h-4 w-4 text-red-500 flex-shrink-0" />;
return <LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-red-500" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
);
}
return <LuDownload className="h-4 w-4 text-blue-500 flex-shrink-0" />;
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
case "app-update":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
);
} else if (stage === "downloading") {
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
} else if (stage === "installing") {
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
);
}
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
);
case "version-update":
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
);
case "fetching":
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
);
case "twilight-update":
return (
<LuRefreshCw className="h-4 w-4 text-purple-500 animate-spin flex-shrink-0" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
);
default:
return (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
);
}
}
@@ -150,11 +187,33 @@ export function UnifiedToast(props: ToastProps) {
const stage = "stage" in props ? props.stage : undefined;
const progress = "progress" in props ? props.progress : undefined;
// Check if this is an auto-update toast
const isAutoUpdate = title.includes("update started");
return (
<div className="flex items-start w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 shadow-lg">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div
className={`flex items-start p-3 w-96 rounded-lg border shadow-lg ${
isAutoUpdate
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800"
: "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700"
}`}
data-toast-type={isAutoUpdate ? "auto-update" : "default"}
>
<div className="mr-3 mt-0.5">
{isAutoUpdate ? (
<LuRocket className="flex-shrink-0 w-4 h-4 text-emerald-500" />
) : (
getToastIcon(type, stage)
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white leading-tight">
<p
className={`text-sm font-medium leading-tight ${
isAutoUpdate
? "text-emerald-900 dark:text-emerald-100"
: "text-gray-900 dark:text-white"
}`}
>
{title}
</p>
@@ -165,7 +224,7 @@ export function UnifiedToast(props: ToastProps) {
stage === "downloading" && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="text-xs text-gray-600 dark:text-gray-300 min-w-0 flex-1">
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
@@ -180,28 +239,73 @@ export function UnifiedToast(props: ToastProps) {
</div>
)}
{/* Version update progress */}
{type === "version-update" && progress && "found" in progress && (
{/* App update progress */}
{type === "app-update" && (
<div className="mt-2 space-y-1">
<p className="text-xs text-gray-600 dark:text-gray-300">
{progress.found} new versions found so far
</p>
<div className="flex items-center space-x-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 min-w-0">
{/* Download progress with percentage */}
{progress &&
"percentage" in progress &&
stage === "downloading" && (
<>
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
</p>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</>
)}
{/* Progress indicator for other stages */}
{(stage === "extracting" ||
stage === "installing" ||
stage === "completed") && (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
className={`h-1.5 rounded-full transition-all duration-500 ${
stage === "completed"
? "bg-green-500 w-full"
: "bg-blue-500 w-full animate-pulse"
}`}
/>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap shrink-0 w-8 text-right">
{progress.current}/{progress.total}
</span>
</div>
)}
</div>
)}
{/* Version update progress */}
{type === "version-update" &&
progress &&
"current_browser" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-gray-600 dark:text-gray-300">
{progress.current_browser && (
<>Looking for updates for {progress.current_browser}</>
)}
</p>
<div className="flex items-center space-x-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 min-w-0">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
/>
</div>
<span className="w-8 text-xs text-right text-gray-500 whitespace-nowrap dark:text-gray-400 shrink-0">
{progress.current}/{progress.total}
</span>
</div>
</div>
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
@@ -211,7 +315,7 @@ export function UnifiedToast(props: ToastProps) {
: "Checking for twilight updates..."}
</p>
{props.browserName && (
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
{props.browserName} Rolling Release
</p>
)}
@@ -220,7 +324,13 @@ export function UnifiedToast(props: ToastProps) {
{/* Description */}
{description && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300 leading-tight">
<p
className={`mt-1 text-xs leading-tight ${
isAutoUpdate
? "text-emerald-700 dark:text-emerald-300"
: "text-gray-600 dark:text-gray-300"
}`}
>
{description}
</p>
)}
@@ -235,7 +345,7 @@ export function UnifiedToast(props: ToastProps) {
)}
{stage === "verifying" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Verifying installation...
Verifying browser files...
</p>
)}
{stage === "downloading (twilight rolling release)" && (
@@ -245,6 +355,27 @@ export function UnifiedToast(props: ToastProps) {
)}
</>
)}
{/* Stage-specific descriptions for app updates */}
{type === "app-update" && !description && (
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Preparing update files...
</p>
)}
{stage === "installing" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Installing new version...
</p>
)}
{stage === "completed" && (
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
Update completed! Restarting application...
</p>
)}
</>
)}
</div>
</div>
);
+24
View File
@@ -0,0 +1,24 @@
export const ZenBrowser = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
role="graphics-symbol img"
fill="currentColor"
viewBox="0 0 24 24"
{...props}
>
<path
d="M12 8.15c-2.12 0-3.85 1.72-3.85 3.85s1.72 3.85 3.85 3.85 3.85-1.72 3.85-3.85S14.13 8.15 12 8.15m0 6.92c-1.7 0-3.08-1.38-3.08-3.08S10.3 8.91 12 8.91s3.08 1.38 3.08 3.08-1.38 3.08-3.08 3.08"
className="b"
/>
<path
d="M12 5.33c-3.68 0-6.67 2.98-6.67 6.67s2.98 6.67 6.67 6.67 6.67-2.98 6.67-6.67S15.69 5.33 12 5.33m0 12.05c-2.97 0-5.38-2.41-5.38-5.38S9.03 6.62 12 6.62s5.38 2.41 5.38 5.38-2.41 5.38-5.38 5.38"
className="b"
/>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18.2c-4.53 0-8.21-3.67-8.21-8.2S7.47 3.79 12 3.79s8.21 3.67 8.21 8.21-3.67 8.2-8.21 8.2"
className="b"
/>
</svg>
);
+57 -21
View File
@@ -1,5 +1,10 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useState } from "react";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
@@ -21,11 +26,6 @@ import {
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
interface ImportProfileDialogProps {
isOpen: boolean;
@@ -63,13 +63,7 @@ export function ImportProfileDialog({
const { supportedBrowsers, isLoading: isLoadingSupport } =
useBrowserSupport();
useEffect(() => {
if (isOpen) {
void loadDetectedProfiles();
}
}, [isOpen]);
const loadDetectedProfiles = async () => {
const loadDetectedProfiles = useCallback(async () => {
setIsLoading(true);
try {
const profiles = await invoke<DetectedProfile[]>(
@@ -96,7 +90,7 @@ export function ImportProfileDialog({
} finally {
setIsLoading(false);
}
};
}, []);
const handleBrowseFolder = async () => {
try {
@@ -115,7 +109,7 @@ export function ImportProfileDialog({
}
};
const handleAutoDetectImport = async () => {
const handleAutoDetectImport = useCallback(async () => {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
@@ -148,13 +142,31 @@ export function ImportProfileDialog({
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to import profile: ${errorMessage}`);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(profile.browser);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
duration: 8000,
},
);
} else {
toast.error(`Failed to import profile: ${errorMessage}`);
}
} finally {
setIsImporting(false);
}
};
}, [
selectedDetectedProfile,
autoDetectProfileName,
detectedProfiles,
onImportComplete,
onClose,
]);
const handleManualImport = async () => {
const handleManualImport = useCallback(async () => {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
@@ -183,11 +195,29 @@ export function ImportProfileDialog({
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to import profile: ${errorMessage}`);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(manualBrowserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
duration: 8000,
},
);
} else {
toast.error(`Failed to import profile: ${errorMessage}`);
}
} finally {
setIsImporting(false);
}
};
}, [
manualBrowserType,
manualProfilePath,
manualProfileName,
onImportComplete,
onClose,
]);
const handleClose = () => {
setSelectedDetectedProfile(null);
@@ -222,6 +252,12 @@ export function ImportProfileDialog({
(p) => p.path === selectedDetectedProfile,
);
useEffect(() => {
if (isOpen) {
void loadDetectedProfiles();
}
}, [isOpen, loadDetectedProfiles]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
@@ -458,7 +494,7 @@ export function ImportProfileDialog({
isLoading
}
>
Import Detected Profile
Import Profile
</LoadingButton>
) : (
<LoadingButton
@@ -472,7 +508,7 @@ export function ImportProfileDialog({
!manualProfileName.trim()
}
>
Import Manual Profile
Import Profile
</LoadingButton>
)}
</DialogFooter>
+1
View File
@@ -1,5 +1,6 @@
import { LuLoaderCircle } from "react-icons/lu";
import { type ButtonProps, Button as UIButton } from "./ui/button";
type Props = ButtonProps & {
isLoading: boolean;
"aria-label"?: string;
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface PermissionDialogProps {
isOpen: boolean;
onClose: () => void;
permissionType: PermissionType;
onPermissionGranted?: () => void;
}
export function PermissionDialog({
isOpen,
onClose,
permissionType,
onPermissionGranted,
}: PermissionDialogProps) {
const [isRequesting, setIsRequesting] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
const {
requestPermission,
isMicrophoneAccessGranted,
isCameraAccessGranted,
} = usePermissions();
// Check if we're on macOS and close dialog if not
useEffect(() => {
const userAgent = navigator.userAgent;
const isMac = userAgent.includes("Mac");
setIsMacOS(isMac);
// If not macOS, close the dialog as permissions aren't needed
if (!isMac) {
onClose();
}
}, [onClose]);
// Get current permission status
const isCurrentPermissionGranted =
permissionType === "microphone"
? isMicrophoneAccessGranted
: isCameraAccessGranted;
// Auto-close dialog when permission is granted
useEffect(() => {
if (isCurrentPermissionGranted && isOpen) {
onPermissionGranted?.();
}
}, [isCurrentPermissionGranted, isOpen, onPermissionGranted]);
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-8 h-8" />;
case "camera":
return <BsCamera className="w-8 h-8" />;
}
};
const getPermissionTitle = (type: PermissionType) => {
switch (type) {
case "microphone":
return "Microphone Access Required";
case "camera":
return "Camera Access Required";
}
};
const getPermissionDescription = (type: PermissionType) => {
switch (type) {
case "microphone":
return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually.";
case "camera":
return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually.";
}
};
const handleRequestPermission = async () => {
setIsRequesting(true);
try {
await requestPermission(permissionType);
showSuccessToast(
`${getPermissionTitle(permissionType).replace(
" Required",
"",
)} permission requested`,
);
} catch (error) {
console.error("Failed to request permission:", error);
showErrorToast("Failed to request permission");
} finally {
setIsRequesting(false);
}
};
// Don't render if not macOS
if (!isMacOS) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full dark:bg-blue-900">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
{getPermissionTitle(permissionType)}
</DialogTitle>
<DialogDescription className="text-base">
{getPermissionDescription(permissionType)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{isCurrentPermissionGranted && (
<div className="p-3 bg-green-50 rounded-lg dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Permission granted! Browsers launched from Donut Browser can
now access your {permissionType}.
</p>
</div>
)}
{!isCurrentPermissionGranted && (
<div className="p-3 bg-amber-50 rounded-lg dark:bg-amber-900/20">
<p className="text-sm text-amber-800 dark:text-amber-200">
Permission not granted. Click the button below to request
access to your {permissionType}.
</p>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
{isCurrentPermissionGranted ? "Done" : "Cancel"}
</Button>
{!isCurrentPermissionGranted && (
<LoadingButton
isLoading={isRequesting}
onClick={() => {
handleRequestPermission().catch(console.error);
}}
className="min-w-24"
>
Grant Access
</LoadingButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+208 -110
View File
@@ -1,6 +1,18 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { CiCircleCheck } from "react-icons/ci";
import { IoEllipsisHorizontal } from "react-icons/io5";
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -18,6 +30,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
@@ -32,20 +45,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import * as React from "react";
import { CiCircleCheck } from "react-icons/ci";
import { IoEllipsisHorizontal } from "react-icons/io5";
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
getBrowserDisplayName,
getBrowserIcon,
getCurrentOS,
} from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -57,8 +63,10 @@ interface ProfilesDataTableProps {
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
onRenameProfile: (oldName: string, newName: string) => Promise<void>;
onChangeVersion: (profile: BrowserProfile) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating?: (browser: string) => boolean;
onReloadProxyData?: () => void | Promise<void>;
}
export function ProfilesDataTable({
@@ -69,8 +77,10 @@ export function ProfilesDataTable({
onDeleteProfile,
onRenameProfile,
onChangeVersion,
onConfigureCamoufox,
runningProfiles,
isUpdating = () => false,
onReloadProxyData,
}: ProfilesDataTableProps) {
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]);
@@ -84,12 +94,65 @@ export function ProfilesDataTable({
React.useState("");
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [isClient, setIsClient] = React.useState(false);
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
// Helper function to check if a profile has a proxy
const hasProxy = React.useCallback(
(profile: BrowserProfile): boolean => {
if (!profile.proxy_id) return false;
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
return proxy !== undefined;
},
[storedProxies],
);
// Helper function to get proxy info for a profile
const getProxyInfo = React.useCallback(
(profile: BrowserProfile): StoredProxy | null => {
if (!profile.proxy_id) return null;
return storedProxies.find((p) => p.id === profile.proxy_id) ?? null;
},
[storedProxies],
);
// Helper function to get proxy name for display
const getProxyDisplayName = React.useCallback(
(profile: BrowserProfile): string => {
if (!profile.proxy_id) return "Disabled";
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
return proxy?.name ?? "Unknown Proxy";
},
[storedProxies],
);
// Ensure we're on the client side to prevent hydration mismatches
React.useEffect(() => {
setIsClient(true);
}, []);
// Load stored proxies
const loadStoredProxies = React.useCallback(async () => {
try {
const proxiesList = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxiesList);
} catch (error) {
console.error("Failed to load stored proxies:", error);
}
}, []);
React.useEffect(() => {
if (isClient) {
void loadStoredProxies();
}
}, [isClient, loadStoredProxies]);
// Reload proxy data when requested from parent
React.useEffect(() => {
if (onReloadProxyData) {
void loadStoredProxies();
}
}, [onReloadProxyData, loadStoredProxies]);
// Update local sorting state when settings are loaded
React.useEffect(() => {
if (isLoaded && isClient) {
@@ -164,7 +227,7 @@ export function ProfilesDataTable({
const isDisabled = shouldDisableTorStart || isBrowserUpdating;
return (
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -206,21 +269,34 @@ export function ProfilesDataTable({
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
className="p-0 h-auto font-semibold hover:bg-transparent"
>
Profile
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
<LuChevronDown className="ml-2 w-4 h-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
)}
</Button>
);
},
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const profile = row.original;
return profile.name.length > 15 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">{profile.name.slice(0, 15)}...</span>
</TooltipTrigger>
<TooltipContent>{profile.name}</TooltipContent>
</Tooltip>
) : (
profile.name
);
},
},
{
accessorKey: "browser",
@@ -232,15 +308,15 @@ export function ProfilesDataTable({
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
className="p-0 h-auto font-semibold hover:bg-transparent"
>
Browser
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
<LuChevronDown className="ml-2 w-4 h-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
)}
</Button>
);
@@ -248,10 +324,21 @@ export function ProfilesDataTable({
cell: ({ row }) => {
const browser: string = row.getValue("browser");
const IconComponent = getBrowserIcon(browser);
return (
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="h-4 w-4" />}
<span>{getBrowserDisplayName(browser)}</span>
const browserDisplayName = getBrowserDisplayName(browser);
return browserDisplayName.length > 15 ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex gap-2 items-center">
{IconComponent && <IconComponent className="w-4 h-4" />}
<span>{browserDisplayName.slice(0, 15)}...</span>
</div>
</TooltipTrigger>
<TooltipContent>{browserDisplayName}</TooltipContent>
</Tooltip>
) : (
<div className="flex gap-2 items-center">
{IconComponent && <IconComponent className="w-4 h-4" />}
<span>{browserDisplayName}</span>
</div>
);
},
@@ -263,67 +350,33 @@ export function ProfilesDataTable({
},
},
{
accessorKey: "version",
header: "Version",
},
{
id: "status",
header: ({ column }) => {
const isSorted = column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</Button>
);
},
accessorKey: "release_type",
header: "Release",
cell: ({ row }) => {
const profile = row.original;
const isRunning = isClient && runningProfiles.has(profile.name);
const releaseType: string = row.getValue("release_type");
const isNightly = releaseType === "nightly";
return (
<div className="flex flex-col gap-1">
<Badge
variant={isRunning ? "default" : "secondary"}
className="text-xs w-fit"
<div className="flex items-center">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
isNightly
? "text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-200"
: "text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-200"
}`}
>
{isClient ? (isRunning ? "Running" : "Stopped") : "Loading..."}
</Badge>
{isClient && isRunning && profile.process_id && (
<span className="text-xs text-muted-foreground">
PID: {profile.process_id}
</span>
)}
{isNightly ? "Nightly" : "Stable"}
</span>
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB) => {
// If not on client, sort by name only to ensure consistency
if (!isClient) {
return rowA.original.name.localeCompare(rowB.original.name);
}
const isRunningA = runningProfiles.has(rowA.original.name);
const isRunningB = runningProfiles.has(rowB.original.name);
// Running profiles come first, then stopped ones
// Secondary sort by profile name
if (isRunningA === isRunningB) {
return rowA.original.name.localeCompare(rowB.original.name);
}
return isRunningA ? -1 : 1;
sortingFn: (rowA, rowB, columnId) => {
const releaseA: string = rowA.getValue(columnId);
const releaseB: string = rowB.getValue(columnId);
// Sort with "stable" before "nightly"
if (releaseA === "stable" && releaseB === "nightly") return -1;
if (releaseA === "nightly" && releaseB === "stable") return 1;
return 0;
},
},
{
@@ -331,26 +384,41 @@ export function ProfilesDataTable({
header: "Proxy",
cell: ({ row }) => {
const profile = row.original;
const hasProxy = profile.proxy?.enabled;
const profileHasProxy = hasProxy(profile);
const proxyDisplayName = getProxyDisplayName(profile);
const proxyInfo = getProxyInfo(profile);
const tooltipText =
profile.browser === "tor-browser"
? "Proxies are not supported for TOR browser"
: profileHasProxy && proxyInfo
? `${proxyDisplayName}, ${proxyInfo.proxy_settings.proxy_type.toUpperCase()} (${
proxyInfo.proxy_settings.host
}:${proxyInfo.proxy_settings.port})`
: "No proxy configured";
return (
<Tooltip>
<TooltipTrigger>
<div className="flex items-center gap-2">
{hasProxy && (
<CiCircleCheck className="h-4 w-4 text-green-500" />
<div className="flex gap-2 items-center">
{profileHasProxy && (
<CiCircleCheck className="w-4 h-4 text-green-500" />
)}
{proxyDisplayName.length > 10 ? (
<span className="text-sm truncate text-muted-foreground">
{proxyDisplayName.slice(0, 10)}...
</span>
) : (
<span className="text-sm text-muted-foreground">
{profile.browser === "tor-browser"
? "Not supported"
: proxyDisplayName}
</span>
)}
<span className="text-sm text-muted-foreground">
{hasProxy ? profile.proxy?.proxy_type : "Disabled"}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{hasProxy
? `${profile.proxy?.proxy_type.toUpperCase()} proxy enabled (${
profile.proxy?.host
}:${profile.proxy?.port})`
: "No proxy configured"}
</TooltipContent>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
},
@@ -363,16 +431,16 @@ export function ProfilesDataTable({
const isRunning = isClient && runningProfiles.has(profile.name);
const isBrowserUpdating = isClient && isUpdating(profile.browser);
return (
<div className="flex items-center justify-end">
<div className="flex justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
className="p-0 w-8 h-8"
disabled={!isClient}
>
<span className="sr-only">Open menu</span>
<IoEllipsisHorizontal className="h-4 w-4" />
<IoEllipsisHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -384,16 +452,30 @@ export function ProfilesDataTable({
}}
disabled={!isClient || isBrowserUpdating}
>
Configure proxy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onChangeVersion(profile);
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Change version
Configure Proxy
</DropdownMenuItem>
{profile.browser === "camoufox" && onConfigureCamoufox && (
<DropdownMenuItem
onClick={() => {
onConfigureCamoufox(profile);
}}
disabled={!isClient || isBrowserUpdating}
>
Configure Camoufox
</DropdownMenuItem>
)}
{!["chromium", "zen", "camoufox"].includes(
profile.browser,
) && (
<DropdownMenuItem
onClick={() => {
onChangeVersion(profile);
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Switch Release
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setProfileToRename(profile);
@@ -429,6 +511,10 @@ export function ProfilesDataTable({
onKillProfile,
onProxySettings,
onChangeVersion,
onConfigureCamoufox,
getProxyInfo,
hasProxy,
getProxyDisplayName,
],
);
@@ -443,9 +529,16 @@ export function ProfilesDataTable({
getCoreRowModel: getCoreRowModel(),
});
const platform = getCurrentOS();
return (
<>
<div className="rounded-md border">
<ScrollArea
className={cn(
"rounded-md border",
platform === "macos" ? "h-[380px]" : "h-[320px]",
)}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -494,7 +587,7 @@ export function ProfilesDataTable({
)}
</TableBody>
</Table>
</div>
</ScrollArea>
<Dialog
open={profileToRename !== null}
@@ -511,7 +604,7 @@ export function ProfilesDataTable({
<DialogTitle>Rename Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<div className="grid grid-cols-4 gap-4 items-center">
<Label htmlFor="name" className="text-right">
Name
</Label>
@@ -573,6 +666,11 @@ export function ProfilesDataTable({
setDeleteConfirmationName(e.target.value);
setDeleteError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleDelete();
}
}}
placeholder="Type the profile name here"
/>
</div>
+162 -149
View File
@@ -1,5 +1,9 @@
"use client";
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 { Button } from "@/components/ui/button";
@@ -24,11 +28,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { LuCopy } from "react-icons/lu";
import { toast } from "sonner";
import type { BrowserProfile, StoredProxy } from "@/types";
interface ProfileSelectorDialogProps {
isOpen: boolean;
@@ -47,25 +47,77 @@ export function ProfileSelectorDialog({
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
useEffect(() => {
if (isOpen) {
void loadProfiles();
}
}, [isOpen]);
// Helper function to check if a profile has a proxy
const hasProxy = useCallback(
(profile: BrowserProfile): boolean => {
if (!profile.proxy_id) return false;
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
return proxy !== undefined;
},
[storedProxies],
);
const loadProfiles = async () => {
// Helper function to determine if a profile can be used for opening links
const canUseProfileForLinks = useCallback(
(
profile: BrowserProfile,
allProfiles: BrowserProfile[],
runningProfiles: Set<string>,
): boolean => {
const isRunning = runningProfiles.has(profile.name);
// For TOR browser: Check if any TOR browser is running
if (profile.browser === "tor-browser") {
const runningTorProfiles = allProfiles.filter(
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
);
// If no TOR browser is running, allow any TOR profile
if (runningTorProfiles.length === 0) {
return true;
}
// If TOR browser(s) are running, only allow the running one(s)
return isRunning;
}
// For Mullvad browser: Check if any Mullvad browser is running
if (profile.browser === "mullvad-browser") {
const runningMullvadProfiles = allProfiles.filter(
(p) => p.browser === "mullvad-browser" && runningProfiles.has(p.name),
);
// If no Mullvad browser is running, allow any Mullvad profile
if (runningMullvadProfiles.length === 0) {
return true;
}
// If Mullvad browser(s) are running, only allow the running one(s)
return isRunning;
}
return true;
},
[],
);
const loadProfiles = useCallback(async () => {
setIsLoading(true);
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
// Load both profiles and stored proxies
const [profileList, proxiesList] = await Promise.all([
invoke<BrowserProfile[]>("list_browser_profiles"),
invoke<StoredProxy[]>("get_stored_proxies"),
]);
// Sort profiles by name
profileList.sort((a, b) => a.name.localeCompare(b.name));
// Don't filter any profiles, show all of them
// Set both profiles and proxies
setProfiles(profileList);
setStoredProxies(proxiesList);
// Auto-select first available profile for link opening
if (profileList.length > 0) {
@@ -99,60 +151,20 @@ export function ProfileSelectorDialog({
} finally {
setIsLoading(false);
}
};
// Helper function to determine if a profile can be used for opening links
const canUseProfileForLinks = (
profile: BrowserProfile,
allProfiles: BrowserProfile[],
runningProfiles: Set<string>,
): boolean => {
const isRunning = runningProfiles.has(profile.name);
// For TOR browser: Check if any TOR browser is running
if (profile.browser === "tor-browser") {
const runningTorProfiles = allProfiles.filter(
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
);
// If no TOR browser is running, allow any TOR profile
if (runningTorProfiles.length === 0) {
return true;
}
// If TOR browser(s) are running, only allow the running one(s)
return isRunning;
}
// For Mullvad browser: never allow if running
if (profile.browser === "mullvad-browser" && isRunning) {
return false;
}
// For other browsers: always allow
return true;
};
}, [runningProfiles, canUseProfileForLinks]);
// Helper function to get tooltip content for profiles
const getProfileTooltipContent = (profile: BrowserProfile): string => {
const isRunning = runningProfiles.has(profile.name);
if (profile.browser === "tor-browser") {
const runningTorProfiles = profiles.filter(
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
);
// If another TOR profile is running, this one is not available
if (
profile.browser === "tor-browser" ||
profile.browser === "mullvad-browser"
) {
// If another TOR/Mullvad profile is running, this one is not available
return "Only 1 instance can run at a time";
}
if (profile.browser === "mullvad-browser") {
if (isRunning) {
return "Only launching the browser is supported, opening them in a running browser is not yet available";
}
return "Only launching the browser is supported, opening them in a running browser is not yet available";
}
if (isRunning) {
return "URL will open in a new tab in the existing browser window";
}
@@ -160,7 +172,7 @@ export function ProfileSelectorDialog({
return "";
};
const handleOpenUrl = async () => {
const handleOpenUrl = useCallback(async () => {
if (!selectedProfile || !url) return;
setIsLaunching(true);
@@ -175,14 +187,14 @@ export function ProfileSelectorDialog({
} finally {
setIsLaunching(false);
}
};
}, [selectedProfile, url, onClose]);
const handleCancel = () => {
const handleCancel = useCallback(() => {
setSelectedProfile(null);
onClose();
};
}, [onClose]);
const handleCopyUrl = async () => {
const handleCopyUrl = useCallback(async () => {
if (!url) return;
try {
@@ -192,12 +204,9 @@ export function ProfileSelectorDialog({
console.error("Failed to copy URL:", error);
toast.error("Failed to copy URL to clipboard");
}
};
}, [url]);
const selectedProfileData = profiles.find((p) => p.name === selectedProfile);
const isSelectedProfileRunning = selectedProfile
? runningProfiles.has(selectedProfile)
: false;
// Check if the selected profile can be used for opening links
const canOpenWithSelectedProfile = () => {
@@ -215,6 +224,12 @@ export function ProfileSelectorDialog({
return getProfileTooltipContent(selectedProfileData);
};
useEffect(() => {
if (isOpen) {
void loadProfiles();
}
}, [isOpen, loadProfiles]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
@@ -225,19 +240,19 @@ export function ProfileSelectorDialog({
<div className="grid gap-4 py-4">
{url && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">Opening URL:</Label>
<Button
variant="outline"
size="sm"
onClick={() => void handleCopyUrl()}
className="flex items-center gap-2"
className="flex gap-2 items-center"
>
<LuCopy className="h-3 w-3" />
<LuCopy className="w-3 h-3" />
Copy
</Button>
</div>
<div className="p-2 bg-muted rounded text-sm break-all">
<div className="p-2 text-sm break-all rounded bg-muted">
{url}
</div>
</div>
@@ -260,86 +275,84 @@ export function ProfileSelectorDialog({
</div>
</div>
) : (
<>
<Select
value={selectedProfile ?? undefined}
onValueChange={setSelectedProfile}
>
<SelectTrigger>
<SelectValue placeholder="Choose a profile" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => {
const isRunning = runningProfiles.has(profile.name);
const canUseForLinks = canUseProfileForLinks(
profile,
profiles,
runningProfiles,
);
const tooltipContent = getProfileTooltipContent(profile);
<Select
value={selectedProfile ?? undefined}
onValueChange={setSelectedProfile}
>
<SelectTrigger>
<SelectValue placeholder="Choose a profile" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => {
const isRunning = runningProfiles.has(profile.name);
const canUseForLinks = canUseProfileForLinks(
profile,
profiles,
runningProfiles,
);
const tooltipContent = getProfileTooltipContent(profile);
return (
<Tooltip key={profile.name}>
<TooltipTrigger asChild>
<SelectItem
value={profile.name}
disabled={!canUseForLinks}
return (
<Tooltip key={profile.name}>
<TooltipTrigger asChild>
<SelectItem
value={profile.name}
disabled={!canUseForLinks}
>
<div
className={`flex items-center gap-2 ${
!canUseForLinks ? "opacity-50" : ""
}`}
>
<div
className={`flex items-center gap-2 ${
!canUseForLinks ? "opacity-50" : ""
}`}
>
<div className="flex items-center gap-3 py-1 px-2 rounded-lg hover:bg-accent cursor-pointer">
<div className="flex items-center gap-2">
{(() => {
const IconComponent = getBrowserIcon(
profile.browser,
);
return IconComponent ? (
<IconComponent className="h-4 w-4" />
) : null;
})()}
</div>
<div className="flex-1 text-right">
<div className="font-medium">
{profile.name}
</div>
<div className="flex gap-3 items-center px-2 py-1 rounded-lg cursor-pointer hover:bg-accent">
<div className="flex gap-2 items-center">
{(() => {
const IconComponent = getBrowserIcon(
profile.browser,
);
return IconComponent ? (
<IconComponent className="w-4 h-4" />
) : null;
})()}
</div>
<div className="flex-1 text-right">
<div className="font-medium">
{profile.name}
</div>
</div>
<Badge variant="secondary" className="text-xs">
{getBrowserDisplayName(profile.browser)}
</Badge>
{profile.proxy?.enabled && (
<Badge variant="outline" className="text-xs">
Proxy
</Badge>
)}
{isRunning && (
<Badge variant="default" className="text-xs">
Running
</Badge>
)}
{!canUseForLinks && (
<Badge
variant="destructive"
className="text-xs"
>
Unavailable
</Badge>
)}
</div>
</SelectItem>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent>{tooltipContent}</TooltipContent>
)}
</Tooltip>
);
})}
</SelectContent>
</Select>
</>
<Badge variant="secondary" className="text-xs">
{getBrowserDisplayName(profile.browser)}
</Badge>
{hasProxy(profile) && (
<Badge variant="outline" className="text-xs">
Proxy
</Badge>
)}
{isRunning && (
<Badge variant="default" className="text-xs">
Running
</Badge>
)}
{!canUseForLinks && (
<Badge
variant="destructive"
className="text-xs"
>
Unavailable
</Badge>
)}
</div>
</SelectItem>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent>{tooltipContent}</TooltipContent>
)}
</Tooltip>
);
})}
</SelectContent>
</Select>
)}
</div>
</div>
+285
View File
@@ -0,0 +1,285 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
interface ProxyFormData {
name: string;
proxy_type: string;
host: string;
port: number;
username: string;
password: string;
}
interface ProxyFormDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (proxy: StoredProxy) => void;
editingProxy?: StoredProxy | null;
}
export function ProxyFormDialog({
isOpen,
onClose,
onSave,
editingProxy,
}: ProxyFormDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<ProxyFormData>({
name: "",
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
const resetForm = useCallback(() => {
setFormData({
name: "",
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
}, []);
// Load editing proxy data when dialog opens
useEffect(() => {
if (isOpen) {
if (editingProxy) {
setFormData({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
} else {
resetForm();
}
}
}, [isOpen, editingProxy, resetForm]);
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) {
toast.error("Proxy name is required");
return;
}
if (!formData.host.trim() || !formData.port) {
toast.error("Host and port are required");
return;
}
setIsSubmitting(true);
try {
const proxySettings = {
proxy_type: formData.proxy_type,
host: formData.host.trim(),
port: formData.port,
username: formData.username.trim() || undefined,
password: formData.password.trim() || undefined,
};
let savedProxy: StoredProxy;
if (editingProxy) {
// Update existing proxy
savedProxy = await invoke<StoredProxy>("update_stored_proxy", {
proxyId: editingProxy.id,
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy updated successfully");
} else {
// Create new proxy
savedProxy = await invoke<StoredProxy>("create_stored_proxy", {
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy created successfully");
}
onSave(savedProxy);
onClose();
} catch (error) {
console.error("Failed to save proxy:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to save proxy: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
}, [formData, editingProxy, onSave, onClose]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
onClose();
}
}, [isSubmitting, onClose]);
const isFormValid =
formData.name.trim() &&
formData.host.trim() &&
formData.port > 0 &&
formData.port <= 65535;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingProxy ? "Edit Proxy" : "Create New Proxy"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proxy-name">Proxy Name</Label>
<Input
id="proxy-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={formData.proxy_type}
onValueChange={(value) =>
setFormData({ ...formData, proxy_type: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={formData.host}
onChange={(e) =>
setFormData({ ...formData, host: e.target.value })
}
placeholder="e.g. 127.0.0.1"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({
...formData,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder="e.g. 8080"
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label>
<Input
id="proxy-username"
value={formData.username}
onChange={(e) =>
setFormData({
...formData,
username: e.target.value,
})
}
placeholder="Proxy username"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label>
<Input
id="proxy-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({
...formData,
password: e.target.value,
})
}
placeholder="Proxy password"
disabled={isSubmitting}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<LoadingButton
isLoading={isSubmitting}
onClick={handleSubmit}
disabled={!isFormValid}
>
{editingProxy ? "Update Proxy" : "Create Proxy"}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
import { toast } from "sonner";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { StoredProxy } from "@/types";
interface ProxyManagementDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function ProxyManagementDialog({
isOpen,
onClose,
}: ProxyManagementDialogProps) {
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [loading, setLoading] = useState(false);
const [showProxyForm, setShowProxyForm] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const loadStoredProxies = useCallback(async () => {
try {
setLoading(true);
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxies);
} catch (error) {
console.error("Failed to load stored proxies:", error);
toast.error("Failed to load proxies");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
loadStoredProxies();
}
}, [isOpen, loadStoredProxies]);
const handleDeleteProxy = useCallback(async (proxy: StoredProxy) => {
if (
!confirm(`Are you sure you want to delete the proxy "${proxy.name}"?`)
) {
return;
}
try {
await invoke("delete_stored_proxy", { proxyId: proxy.id });
setStoredProxies((prev) => prev.filter((p) => p.id !== proxy.id));
toast.success("Proxy deleted successfully");
} catch (error) {
console.error("Failed to delete proxy:", error);
toast.error("Failed to delete proxy");
}
}, []);
const handleCreateProxy = useCallback(() => {
setEditingProxy(null);
setShowProxyForm(true);
}, []);
const handleEditProxy = useCallback((proxy: StoredProxy) => {
setEditingProxy(proxy);
setShowProxyForm(true);
}, []);
const handleProxySaved = useCallback((savedProxy: StoredProxy) => {
setStoredProxies((prev) => {
const existingIndex = prev.findIndex((p) => p.id === savedProxy.id);
if (existingIndex >= 0) {
// Update existing proxy
const updated = [...prev];
updated[existingIndex] = savedProxy;
return updated;
} else {
// Add new proxy
return [...prev, savedProxy];
}
});
setShowProxyForm(false);
setEditingProxy(null);
}, []);
const handleProxyFormClose = useCallback(() => {
setShowProxyForm(false);
setEditingProxy(null);
}, []);
const trimName = useCallback((name: string) => {
return name.length > 30 ? `${name.substring(0, 30)}...` : name;
}, []);
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>
</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>
<Button
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create Proxy
</Button>
</div>
{/* Proxy List - Scrollable */}
<div className="flex-1 min-h-0">
{loading ? (
<div className="flex justify-center items-center h-32">
<p className="text-sm text-muted-foreground">
Loading proxies...
</p>
</div>
) : storedProxies.length === 0 ? (
<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>
<Button variant="outline" onClick={handleCreateProxy}>
<FiPlus className="mr-2 w-4 h-4" />
Create First Proxy
</Button>
</div>
) : (
<div className="overflow-y-auto pr-2 space-y-2 h-full">
{storedProxies.map((proxy) => (
<div
key={proxy.id}
className="flex justify-between items-center p-1 rounded border bg-card"
>
<div className="flex-1 ml-2 min-w-0">
{proxy.name.length > 30 ? (
<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="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>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
>
<FiTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete proxy</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter className="flex-shrink-0">
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
editingProxy={editingProxy}
/>
</>
);
}
+235 -195
View File
@@ -1,7 +1,13 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { FiPlus } from "react-icons/fi";
import { toast } from "sonner";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -9,34 +15,20 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEffect, useState } from "react";
interface ProxySettings {
enabled: boolean;
proxy_type: string;
host: string;
port: number;
}
import { cn } from "@/lib/utils";
import type { StoredProxy } from "@/types";
interface ProxySettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (proxySettings: ProxySettings) => void;
initialSettings?: ProxySettings;
onSave: (proxyId: string | null) => void;
initialProxyId?: string | null;
browserType?: string;
}
@@ -44,197 +36,245 @@ export function ProxySettingsDialog({
isOpen,
onClose,
onSave,
initialSettings,
initialProxyId,
browserType,
}: ProxySettingsDialogProps) {
const [settings, setSettings] = useState<ProxySettings>({
enabled: initialSettings?.enabled ?? false,
proxy_type: initialSettings?.proxy_type ?? "http",
host: initialSettings?.host ?? "",
port: initialSettings?.port ?? 8080,
});
const [initialSettingsState, setInitialSettingsState] =
useState<ProxySettings>({
enabled: false,
proxy_type: "http",
host: "",
port: 8080,
});
useEffect(() => {
if (isOpen && initialSettings) {
const newSettings = {
enabled: initialSettings.enabled,
proxy_type: initialSettings.proxy_type,
host: initialSettings.host,
port: initialSettings.port,
};
setSettings(newSettings);
setInitialSettingsState(newSettings);
} else if (isOpen) {
const defaultSettings = {
enabled: false,
proxy_type: "http",
host: "",
port: 80,
};
setSettings(defaultSettings);
setInitialSettingsState(defaultSettings);
}
}, [isOpen, initialSettings]);
const handleSubmit = () => {
onSave(settings);
};
// Check if settings have changed
const hasChanged = () => {
return (
settings.enabled !== initialSettingsState.enabled ||
settings.proxy_type !== initialSettingsState.proxy_type ||
settings.host !== initialSettingsState.host ||
settings.port !== initialSettingsState.port
);
};
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(
initialProxyId || null,
);
const [loading, setLoading] = useState(false);
const [showProxyForm, setShowProxyForm] = useState(false);
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = browserType === "tor-browser";
// Update proxy enabled state when browser is tor-browser
useEffect(() => {
if (browserType === "tor-browser" && settings.enabled) {
setSettings((prev) => ({ ...prev, enabled: false }));
const loadStoredProxies = useCallback(async () => {
try {
setLoading(true);
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxies);
} catch (error) {
console.error("Failed to load stored proxies:", error);
toast.error("Failed to load proxies");
} finally {
setLoading(false);
}
}, [browserType, settings.enabled]);
}, []);
useEffect(() => {
if (isOpen) {
loadStoredProxies();
if (isProxyDisabled) {
setSelectedProxyId(null);
}
}
}, [isOpen, isProxyDisabled, loadStoredProxies]);
const handleCreateProxy = useCallback(() => {
setShowProxyForm(true);
}, []);
const handleProxySaved = useCallback((savedProxy: StoredProxy) => {
setStoredProxies((prev) => {
const existingIndex = prev.findIndex((p) => p.id === savedProxy.id);
if (existingIndex >= 0) {
// Update existing proxy
const updated = [...prev];
updated[existingIndex] = savedProxy;
return updated;
} else {
// Add new proxy
return [...prev, savedProxy];
}
});
setSelectedProxyId(savedProxy.id);
setShowProxyForm(false);
}, []);
const handleProxyFormClose = useCallback(() => {
setShowProxyForm(false);
}, []);
const handleSave = () => {
onSave(selectedProxyId);
};
const hasChanged = () => {
return selectedProxyId !== initialProxyId;
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Proxy Settings</DialogTitle>
</DialogHeader>
<>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Proxy Settings</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex items-center space-x-2">
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 opacity-50">
<Checkbox
id="proxy-enabled"
checked={false}
disabled={true}
/>
<Label htmlFor="proxy-enabled" className="text-gray-500">
Enable Proxy
</Label>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser has its own built-in proxy system and
doesn&apos;t support additional proxy configuration
</p>
</TooltipContent>
</Tooltip>
) : (
<div className="grid gap-6 py-4">
{isProxyDisabled && (
<div className="p-4 bg-yellow-50 rounded-md border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Tor Browser has its own built-in proxy system and doesn't
support additional proxy configuration.
</p>
</div>
)}
{!isProxyDisabled && (
<>
<Checkbox
id="proxy-enabled"
checked={settings.enabled}
onCheckedChange={(checked) => {
setSettings({ ...settings, enabled: checked as boolean });
}}
/>
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
{/* Proxy Selection */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-base font-medium">
Select Proxy
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create New
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Create a new proxy configuration</p>
</TooltipContent>
</Tooltip>
</div>
<div className="overflow-y-auto p-2 space-y-2 h-full">
<Button
variant="ghost"
onClick={() => setSelectedProxyId(null)}
asChild
>
<Card
className={cn(
"w-full bg-card cursor-pointer transition-colors",
selectedProxyId === null
? "ring-2 ring-blue-500"
: "",
)}
>
<CardContent className="p-4 w-full">
<div className="flex items-center space-x-3">
<input
type="radio"
id="no-proxy"
name="proxy-selection"
checked={selectedProxyId === null}
onChange={() => setSelectedProxyId(null)}
/>
<div className="flex gap-2 items-center">
<Label
htmlFor="no-proxy"
className="font-medium cursor-pointer"
>
No Proxy
</Label>
</div>
</div>
</CardContent>
</Card>
</Button>
{loading ? (
<p className="text-sm text-muted-foreground">
Loading proxies...
</p>
) : (
storedProxies.map((proxy) => (
<Button
key={proxy.id}
variant="ghost"
onClick={() => setSelectedProxyId(proxy.id)}
asChild
>
<Card
className={cn(
"w-full bg-card cursor-pointer transition-colors",
selectedProxyId === proxy.id
? "ring-2 ring-blue-500"
: "",
)}
>
<CardContent className="p-4 w-full">
<div className="flex items-center space-x-3">
<input
type="radio"
id={`proxy-${proxy.id}`}
name="proxy-selection"
checked={selectedProxyId === proxy.id}
onChange={() => setSelectedProxyId(proxy.id)}
/>
<div className="flex gap-2 items-center">
<Label
htmlFor={`proxy-${proxy.id}`}
className="font-medium cursor-pointer"
>
{proxy.name}
</Label>
<Badge variant="outline">
{proxy.proxy_settings.proxy_type.toUpperCase()}
</Badge>
</div>
</div>
</CardContent>
</Card>
</Button>
))
)}
{!loading && storedProxies.length === 0 && (
<div className="py-4 text-center">
<p className="mb-2 text-sm text-muted-foreground">
No saved proxies available.
</p>
<Button
variant="outline"
size="sm"
onClick={handleCreateProxy}
>
<FiPlus className="mr-2 w-4 h-4" />
Create First Proxy
</Button>
</div>
)}
</div>
</div>
</>
)}
</div>
{settings.enabled && !isProxyDisabled && (
<>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={settings.proxy_type}
onValueChange={(value) => {
setSettings({
...settings,
proxy_type: value,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanged()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="grid gap-2">
<Label htmlFor="host">Host</Label>
<Input
id="host"
value={settings.host}
onChange={(e) => {
setSettings({ ...settings, host: e.target.value });
}}
placeholder="e.g. 127.0.0.1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
value={settings.port}
onChange={(e) => {
setSettings({
...settings,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder="e.g. 8080"
min="1"
max="65535"
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={
!hasChanged() ||
(!isProxyDisabled &&
settings.enabled &&
(!settings.host || !settings.port))
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
/>
</>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { BrowserReleaseTypes } from "@/types";
interface ReleaseTypeSelectorProps {
selectedReleaseType: "stable" | "nightly" | null;
onReleaseTypeSelect: (releaseType: "stable" | "nightly" | null) => void;
availableReleaseTypes: BrowserReleaseTypes;
browser: string;
isDownloading: boolean;
onDownload: () => void;
placeholder?: string;
showDownloadButton?: boolean;
downloadedVersions?: string[];
}
export function ReleaseTypeSelector({
selectedReleaseType,
onReleaseTypeSelect,
availableReleaseTypes,
browser,
isDownloading,
onDownload,
placeholder = "Select release type...",
showDownloadButton = true,
downloadedVersions = [],
}: ReleaseTypeSelectorProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const releaseOptions = [
...(availableReleaseTypes.stable
? [{ type: "stable" as const, version: availableReleaseTypes.stable }]
: []),
...(availableReleaseTypes.nightly && browser !== "chromium"
? [{ type: "nightly" as const, version: availableReleaseTypes.nightly }]
: []),
];
// Only show dropdown if there are multiple release types available
const showDropdown = releaseOptions.length > 1;
// If only one release type is available, auto-select it
if (!showDropdown && releaseOptions.length === 1 && !selectedReleaseType) {
setTimeout(() => {
onReleaseTypeSelect(releaseOptions[0].type);
}, 0);
}
const selectedDisplayText = selectedReleaseType
? selectedReleaseType === "stable"
? "Stable"
: "Nightly"
: placeholder;
const selectedVersion =
selectedReleaseType === "stable"
? availableReleaseTypes.stable
: selectedReleaseType === "nightly"
? availableReleaseTypes.nightly
: null;
const isVersionDownloaded =
selectedVersion && downloadedVersions.includes(selectedVersion);
return (
<div className="space-y-4">
{showDropdown ? (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
className="justify-between w-full"
>
{selectedDisplayText}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandEmpty>No release types available.</CommandEmpty>
<CommandList>
<CommandGroup>
{releaseOptions.map((option) => {
const isDownloaded = downloadedVersions.includes(
option.version,
);
return (
<CommandItem
key={option.type}
value={option.type}
onSelect={(currentValue) => {
const selectedType = currentValue as
| "stable"
| "nightly";
onReleaseTypeSelect(
selectedType === selectedReleaseType
? null
: selectedType,
);
setPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedReleaseType === option.type
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex gap-2 items-center">
<span className="capitalize">{option.type}</span>
{option.type === "nightly" && (
<Badge variant="secondary" className="text-xs">
Nightly
</Badge>
)}
<Badge variant="outline" className="text-xs">
{option.version}
</Badge>
{isDownloaded && (
<Badge variant="default" className="text-xs">
Downloaded
</Badge>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
// Show a simple display when only one release type is available
releaseOptions.length === 1 && (
<div className="flex gap-2 justify-center items-center p-3 rounded-md border bg-muted/50">
<span className="text-sm font-medium capitalize">
{releaseOptions[0].type}
</span>
{releaseOptions[0].type === "nightly" && (
<Badge variant="secondary" className="text-xs">
Nightly
</Badge>
)}
<Badge variant="outline" className="text-xs">
{releaseOptions[0].version}
</Badge>
{downloadedVersions.includes(releaseOptions[0].version) && (
<Badge variant="default" className="text-xs">
Downloaded
</Badge>
)}
</div>
)
)}
{showDownloadButton &&
selectedReleaseType &&
selectedVersion &&
!isVersionDownloaded && (
<LoadingButton
isLoading={isDownloading}
onClick={() => {
onDownload();
}}
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton>
)}
</div>
);
}
+250 -86
View File
@@ -1,5 +1,9 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useState } from "react";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -19,17 +23,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { showSuccessToast } from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface AppSettings {
set_as_default_browser: boolean;
show_settings_on_startup: boolean;
theme: string;
auto_updates_enabled: boolean;
auto_delete_unused_binaries: boolean;
}
interface PermissionInfo {
permission_type: PermissionType;
isGranted: boolean;
description: string;
}
interface SettingsDialogProps {
@@ -42,42 +49,68 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
const [requestingPermission, setRequestingPermission] =
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const { setTheme } = useTheme();
const {
requestPermission,
isMicrophoneAccessGranted,
isCameraAccessGranted,
} = usePermissions();
useEffect(() => {
if (isOpen) {
void loadSettings();
void checkDefaultBrowserStatus();
// Set up interval to check default browser status
const intervalId = setInterval(() => {
void checkDefaultBrowserStatus();
}, 500); // Check every 2 seconds
// Cleanup interval on component unmount or dialog close
return () => {
clearInterval(intervalId);
};
const getPermissionIcon = useCallback((type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-4 h-4" />;
case "camera":
return <BsCamera className="w-4 h-4" />;
}
}, [isOpen]);
}, []);
const loadSettings = async () => {
const getPermissionDisplayName = useCallback((type: PermissionType) => {
switch (type) {
case "microphone":
return "Microphone";
case "camera":
return "Camera";
}
}, []);
const getStatusBadge = useCallback((isGranted: boolean) => {
if (isGranted) {
return (
<Badge variant="default" className="text-green-800 bg-green-100">
Granted
</Badge>
);
}
return <Badge variant="secondary">Not Granted</Badge>;
}, []);
const getPermissionDescription = useCallback((type: PermissionType) => {
switch (type) {
case "microphone":
return "Access to microphone for browser applications";
case "camera":
return "Access to camera for browser applications";
}
}, []);
const loadSettings = useCallback(async () => {
setIsLoading(true);
try {
const appSettings = await invoke<AppSettings>("get_app_settings");
@@ -88,18 +121,53 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
} finally {
setIsLoading(false);
}
};
}, []);
const checkDefaultBrowserStatus = async () => {
const loadPermissions = useCallback(async () => {
setIsLoadingPermissions(true);
try {
if (!isMacOS) {
// On non-macOS platforms, don't show permissions
setPermissions([]);
return;
}
const permissionList: PermissionInfo[] = [
{
permission_type: "microphone",
isGranted: isMicrophoneAccessGranted,
description: getPermissionDescription("microphone"),
},
{
permission_type: "camera",
isGranted: isCameraAccessGranted,
description: getPermissionDescription("camera"),
},
];
setPermissions(permissionList);
} catch (error) {
console.error("Failed to load permissions:", error);
} finally {
setIsLoadingPermissions(false);
}
}, [
getPermissionDescription,
isCameraAccessGranted,
isMacOS,
isMicrophoneAccessGranted,
]);
const checkDefaultBrowserStatus = useCallback(async () => {
try {
const isDefault = await invoke<boolean>("is_default_browser");
setIsDefaultBrowser(isDefault);
} catch (error) {
console.error("Failed to check default browser status:", error);
}
};
}, []);
const handleSetDefaultBrowser = async () => {
const handleSetDefaultBrowser = useCallback(async () => {
setIsSettingDefault(true);
try {
await invoke("set_as_default_browser");
@@ -109,25 +177,46 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
} finally {
setIsSettingDefault(false);
}
};
}, [checkDefaultBrowserStatus]);
const handleClearCache = async () => {
const handleClearCache = useCallback(async () => {
setIsClearingCache(true);
try {
await invoke("clear_all_version_cache_and_refetch");
showSuccessToast("Cache cleared successfully", {
description:
"All browser version cache has been cleared and browsers are being refreshed",
"All browser version cache has been cleared and browsers are being refreshed.",
duration: 4000,
});
} catch (error) {
console.error("Failed to clear cache:", error);
showErrorToast("Failed to clear cache", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
});
} finally {
setIsClearingCache(false);
}
};
}, []);
const handleSave = async () => {
const handleRequestPermission = useCallback(
async (permissionType: PermissionType) => {
setRequestingPermission(permissionType);
try {
await requestPermission(permissionType);
showSuccessToast(
`${getPermissionDisplayName(permissionType)} access requested`,
);
} catch (error) {
console.error("Failed to request permission:", error);
} finally {
setRequestingPermission(null);
}
},
[getPermissionDisplayName, requestPermission],
);
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
await invoke("save_app_settings", { settings });
@@ -139,20 +228,72 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
} finally {
setIsSaving(false);
}
};
}, [onClose, setTheme, settings]);
const updateSetting = (key: keyof AppSettings, value: boolean | string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
const updateSetting = useCallback(
(key: keyof AppSettings, value: boolean | string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
},
[],
);
useEffect(() => {
if (isOpen) {
loadSettings().catch(console.error);
checkDefaultBrowserStatus().catch(console.error);
// Check if we're on macOS
const userAgent = navigator.userAgent;
const isMac = userAgent.includes("Mac");
setIsMacOS(isMac);
if (isMac) {
loadPermissions().catch(console.error);
}
// Set up interval to check default browser status
const intervalId = setInterval(() => {
checkDefaultBrowserStatus().catch(console.error);
}, 500); // Check every 500ms
// Cleanup interval on component unmount or dialog close
return () => {
clearInterval(intervalId);
};
}
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
// Update permissions when the permission states change
useEffect(() => {
if (isMacOS) {
const permissionList: PermissionInfo[] = [
{
permission_type: "microphone",
isGranted: isMicrophoneAccessGranted,
description: getPermissionDescription("microphone"),
},
{
permission_type: "camera",
isGranted: isCameraAccessGranted,
description: getPermissionDescription("camera"),
},
];
setPermissions(permissionList);
} else {
setPermissions([]);
}
}, [
isMacOS,
isMicrophoneAccessGranted,
isCameraAccessGranted,
getPermissionDescription,
]);
// Check if settings have changed (excluding default browser setting)
const hasChanges =
settings.show_settings_on_startup !==
originalSettings.show_settings_on_startup ||
settings.theme !== originalSettings.theme ||
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled ||
settings.auto_delete_unused_binaries !==
originalSettings.auto_delete_unused_binaries;
settings.theme !== originalSettings.theme;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -204,7 +345,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<LoadingButton
isLoading={isSettingDefault}
onClick={() => {
void handleSetDefaultBrowser();
handleSetDefaultBrowser().catch(console.error);
}}
disabled={isDefaultBrowser}
variant={isDefaultBrowser ? "outline" : "default"}
@@ -221,46 +362,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</p>
</div>
{/* Auto-Update Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Auto-Updates</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-updates"
checked={settings.auto_updates_enabled}
onCheckedChange={(checked) => {
updateSetting("auto_updates_enabled", checked as boolean);
}}
/>
<Label htmlFor="auto-updates" className="text-sm">
Enable automatic browser updates
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-delete-binaries"
checked={settings.auto_delete_unused_binaries}
onCheckedChange={(checked) => {
updateSetting(
"auto_delete_unused_binaries",
checked as boolean,
);
}}
/>
<Label htmlFor="auto-delete-binaries" className="text-sm">
Automatically delete unused browser binaries
</Label>
</div>
<p className="text-xs text-muted-foreground">
When enabled, Donut Browser will check for browser updates and
notify you when updates are available for your profiles. Unused
binaries will be automatically deleted to save disk space.
</p>
</div>
{/* Startup Behavior Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Startup Behavior</Label>
@@ -284,6 +385,69 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</p>
</div>
{/* Permissions Section - Only show on macOS */}
{isMacOS && (
<div className="space-y-4">
<Label className="text-base font-medium">
System Permissions
</Label>
{isLoadingPermissions ? (
<div className="text-sm text-muted-foreground">
Loading permissions...
</div>
) : (
<div className="space-y-3">
{permissions.map((permission) => (
<div
key={permission.permission_type}
className="flex justify-between items-center p-3 rounded-lg border"
>
<div className="flex items-center space-x-3">
{getPermissionIcon(permission.permission_type)}
<div>
<div className="text-sm font-medium">
{getPermissionDisplayName(
permission.permission_type,
)}
</div>
<div className="text-xs text-muted-foreground">
{permission.description}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{getStatusBadge(permission.isGranted)}
{!permission.isGranted && (
<LoadingButton
size="sm"
isLoading={
requestingPermission ===
permission.permission_type
}
onClick={() => {
handleRequestPermission(
permission.permission_type,
).catch(console.error);
}}
>
Grant
</LoadingButton>
)}
</div>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
These permissions allow browsers launched from Donut Browser to
access system resources. Each website will still ask for your
permission individually.
</p>
</div>
)}
{/* Advanced Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>
@@ -291,7 +455,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
void handleClearCache();
handleClearCache().catch(console.error);
}}
variant="outline"
className="w-full"
@@ -314,7 +478,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<LoadingButton
isLoading={isSaving}
onClick={() => {
void handleSave();
handleSave().catch(console.error);
}}
disabled={isLoading || !hasChanges}
>
@@ -0,0 +1,568 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { CamoufoxConfig } from "@/types";
const osOptions = [
{ value: "windows", label: "Windows" },
{ value: "macos", label: "macOS" },
{ value: "linux", label: "Linux" },
];
const timezoneOptions = [
{ value: "America/New_York", label: "America/New_York" },
{ value: "America/Los_Angeles", label: "America/Los_Angeles" },
{ value: "America/Chicago", label: "America/Chicago" },
{ value: "America/Denver", label: "America/Denver" },
{ value: "America/Phoenix", label: "America/Phoenix" },
{ value: "America/Toronto", label: "America/Toronto" },
{ value: "America/Vancouver", label: "America/Vancouver" },
{ value: "Europe/London", label: "Europe/London" },
{ value: "Europe/Paris", label: "Europe/Paris" },
{ value: "Europe/Berlin", label: "Europe/Berlin" },
{ value: "Europe/Rome", label: "Europe/Rome" },
{ value: "Europe/Madrid", label: "Europe/Madrid" },
{ value: "Europe/Amsterdam", label: "Europe/Amsterdam" },
{ value: "Europe/Zurich", label: "Europe/Zurich" },
{ value: "Europe/Vienna", label: "Europe/Vienna" },
{ value: "Europe/Warsaw", label: "Europe/Warsaw" },
{ value: "Europe/Prague", label: "Europe/Prague" },
{ value: "Europe/Stockholm", label: "Europe/Stockholm" },
{ value: "Europe/Copenhagen", label: "Europe/Copenhagen" },
{ value: "Europe/Helsinki", label: "Europe/Helsinki" },
{ value: "Europe/Oslo", label: "Europe/Oslo" },
{ value: "Europe/Brussels", label: "Europe/Brussels" },
{ value: "Europe/Dublin", label: "Europe/Dublin" },
{ value: "Europe/Lisbon", label: "Europe/Lisbon" },
{ value: "Europe/Athens", label: "Europe/Athens" },
{ value: "Europe/Budapest", label: "Europe/Budapest" },
{ value: "Europe/Bucharest", label: "Europe/Bucharest" },
{ value: "Europe/Sofia", label: "Europe/Sofia" },
{ value: "Europe/Kiev", label: "Europe/Kiev" },
{ value: "Europe/Moscow", label: "Europe/Moscow" },
{ value: "Asia/Tokyo", label: "Asia/Tokyo" },
{ value: "Asia/Seoul", label: "Asia/Seoul" },
{ value: "Asia/Shanghai", label: "Asia/Shanghai" },
{ value: "Asia/Hong_Kong", label: "Asia/Hong_Kong" },
{ value: "Asia/Singapore", label: "Asia/Singapore" },
{ value: "Asia/Bangkok", label: "Asia/Bangkok" },
{ value: "Asia/Jakarta", label: "Asia/Jakarta" },
{ value: "Asia/Manila", label: "Asia/Manila" },
{ value: "Asia/Kolkata", label: "Asia/Kolkata" },
{ value: "Asia/Dubai", label: "Asia/Dubai" },
{ value: "Asia/Riyadh", label: "Asia/Riyadh" },
{ value: "Asia/Tehran", label: "Asia/Tehran" },
{ value: "Asia/Jerusalem", label: "Asia/Jerusalem" },
{ value: "Asia/Istanbul", label: "Asia/Istanbul" },
{ value: "Australia/Sydney", label: "Australia/Sydney" },
{ value: "Australia/Melbourne", label: "Australia/Melbourne" },
{ value: "Australia/Brisbane", label: "Australia/Brisbane" },
{ value: "Australia/Perth", label: "Australia/Perth" },
{ value: "Australia/Adelaide", label: "Australia/Adelaide" },
{ value: "Pacific/Auckland", label: "Pacific/Auckland" },
{ value: "Pacific/Honolulu", label: "Pacific/Honolulu" },
{ value: "Africa/Cairo", label: "Africa/Cairo" },
{ value: "Africa/Johannesburg", label: "Africa/Johannesburg" },
{ value: "Africa/Lagos", label: "Africa/Lagos" },
{ value: "Africa/Nairobi", label: "Africa/Nairobi" },
{ value: "America/Sao_Paulo", label: "America/Sao_Paulo" },
{ value: "America/Buenos_Aires", label: "America/Buenos_Aires" },
{ value: "America/Lima", label: "America/Lima" },
{ value: "America/Bogota", label: "America/Bogota" },
{ value: "America/Santiago", label: "America/Santiago" },
{ value: "America/Caracas", label: "America/Caracas" },
{ value: "America/Mexico_City", label: "America/Mexico_City" },
];
const localeOptions = [
{ value: "en-US", label: "English (US)" },
{ value: "en-GB", label: "English (UK)" },
{ value: "en-CA", label: "English (Canada)" },
{ value: "en-AU", label: "English (Australia)" },
{ value: "fr-FR", label: "French (France)" },
{ value: "fr-CA", label: "French (Canada)" },
{ value: "de-DE", label: "German (Germany)" },
{ value: "de-AT", label: "German (Austria)" },
{ value: "de-CH", label: "German (Switzerland)" },
{ value: "es-ES", label: "Spanish (Spain)" },
{ value: "es-MX", label: "Spanish (Mexico)" },
{ value: "es-AR", label: "Spanish (Argentina)" },
{ value: "it-IT", label: "Italian (Italy)" },
{ value: "it-CH", label: "Italian (Switzerland)" },
{ value: "pt-BR", label: "Portuguese (Brazil)" },
{ value: "pt-PT", label: "Portuguese (Portugal)" },
{ value: "ru-RU", label: "Russian (Russia)" },
{ value: "zh-CN", label: "Chinese (Simplified)" },
{ value: "zh-TW", label: "Chinese (Traditional)" },
{ value: "ja-JP", label: "Japanese (Japan)" },
{ value: "ko-KR", label: "Korean (Korea)" },
{ value: "ar-SA", label: "Arabic (Saudi Arabia)" },
{ value: "ar-EG", label: "Arabic (Egypt)" },
{ value: "hi-IN", label: "Hindi (India)" },
{ value: "tr-TR", label: "Turkish (Turkey)" },
{ value: "pl-PL", label: "Polish (Poland)" },
{ value: "nl-NL", label: "Dutch (Netherlands)" },
{ value: "nl-BE", label: "Dutch (Belgium)" },
{ value: "sv-SE", label: "Swedish (Sweden)" },
{ value: "da-DK", label: "Danish (Denmark)" },
{ value: "no-NO", label: "Norwegian (Norway)" },
{ value: "fi-FI", label: "Finnish (Finland)" },
{ value: "he-IL", label: "Hebrew (Israel)" },
{ value: "th-TH", label: "Thai (Thailand)" },
{ value: "vi-VN", label: "Vietnamese (Vietnam)" },
{ value: "id-ID", label: "Indonesian (Indonesia)" },
{ value: "ms-MY", label: "Malay (Malaysia)" },
{ value: "uk-UA", label: "Ukrainian (Ukraine)" },
{ value: "cs-CZ", label: "Czech (Czech Republic)" },
{ value: "sk-SK", label: "Slovak (Slovakia)" },
{ value: "hu-HU", label: "Hungarian (Hungary)" },
{ value: "ro-RO", label: "Romanian (Romania)" },
{ value: "bg-BG", label: "Bulgarian (Bulgaria)" },
{ value: "hr-HR", label: "Croatian (Croatia)" },
{ value: "sr-RS", label: "Serbian (Serbia)" },
{ value: "sl-SI", label: "Slovenian (Slovenia)" },
{ value: "lt-LT", label: "Lithuanian (Lithuania)" },
{ value: "lv-LV", label: "Latvian (Latvia)" },
{ value: "et-EE", label: "Estonian (Estonia)" },
{ value: "el-GR", label: "Greek (Greece)" },
{ value: "ca-ES", label: "Catalan (Spain)" },
{ value: "eu-ES", label: "Basque (Spain)" },
{ value: "gl-ES", label: "Galician (Spain)" },
{ value: "is-IS", label: "Icelandic (Iceland)" },
{ value: "mt-MT", label: "Maltese (Malta)" },
];
const getCurrentOS = () => {
if (typeof window !== "undefined") {
const userAgent = window.navigator.userAgent;
if (userAgent.includes("Win")) return "windows";
if (userAgent.includes("Mac")) return "macos";
if (userAgent.includes("Linux")) return "linux";
}
return "unknown";
};
interface SystemLocale {
locale: string;
language: string;
country: string;
}
interface SystemTimezone {
timezone: string;
offset: string;
}
interface SharedCamoufoxConfigFormProps {
config: CamoufoxConfig;
onConfigChange: (key: keyof CamoufoxConfig, value: unknown) => void;
className?: string;
}
export function SharedCamoufoxConfigForm({
config,
onConfigChange,
className = "",
}: SharedCamoufoxConfigFormProps) {
const [systemLocale, setSystemLocale] = useState<SystemLocale | null>(null);
const [systemTimezone, setSystemTimezone] = useState<SystemTimezone | null>(
null,
);
const [isLoadingSystemDefaults, setIsLoadingSystemDefaults] = useState(true);
// Load system defaults on component mount
useEffect(() => {
const loadSystemDefaults = async () => {
try {
const [locale, timezone] = await Promise.all([
invoke<SystemLocale>("get_system_locale"),
invoke<SystemTimezone>("get_system_timezone"),
]);
setSystemLocale(locale);
setSystemTimezone(timezone);
} catch (error) {
console.error("Failed to load system defaults:", error);
// Set fallback defaults
setSystemLocale({
locale: "en-US",
language: "en",
country: "US",
});
setSystemTimezone({
timezone: "America/New_York",
offset: "-05:00",
});
} finally {
setIsLoadingSystemDefaults(false);
}
};
loadSystemDefaults();
}, []);
// Get the selected OS for warning
const selectedOS = config.os?.[0];
const currentOS = getCurrentOS();
const showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
return (
<div className={`space-y-6 ${className}`}>
{/* OS Selection */}
<div className="space-y-3">
<Label>Operating System</Label>
<Select
value={config.os?.[0] || getCurrentOS()}
onValueChange={(value) => onConfigChange("os", [value])}
>
<SelectTrigger>
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
{osOptions.map((os) => (
<SelectItem key={os.value} value={os.value}>
{os.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showOSWarning && (
<p className="text-sm text-yellow-600 dark:text-yellow-400">
Selected OS ({selectedOS}) differs from your current OS (
{currentOS}). This may affect fingerprinting effectiveness.
</p>
)}
</div>
{/* Privacy & Blocking */}
<div className="space-y-3">
<Label>Privacy & Blocking</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="block-images"
checked={config.block_images || false}
onCheckedChange={(checked) =>
onConfigChange("block_images", checked)
}
/>
<Label htmlFor="block-images">Block Images</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webrtc"
checked={config.block_webrtc || false}
onCheckedChange={(checked) =>
onConfigChange("block_webrtc", checked)
}
/>
<Label htmlFor="block-webrtc">Block WebRTC</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webgl"
checked={config.block_webgl || false}
onCheckedChange={(checked) =>
onConfigChange("block_webgl", checked)
}
/>
<Label htmlFor="block-webgl">Block WebGL</Label>
</div>
</div>
</div>
{/* Geolocation */}
<div className="space-y-3">
<Label>Geolocation</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Input
id="country"
value={config.country || ""}
onChange={(e) =>
onConfigChange("country", e.target.value || undefined)
}
placeholder={
systemLocale
? `e.g., ${systemLocale.country}`
: "e.g., US, GB, DE"
}
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Select
value={config.timezone || "auto"}
onValueChange={(value) =>
onConfigChange("timezone", value === "auto" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSystemDefaults ? "Loading..." : "Select timezone"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{isLoadingSystemDefaults
? "Auto (loading...)"
: `Auto (${systemTimezone?.timezone || "UTC"})`}
</SelectItem>
{timezoneOptions.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
type="number"
step="any"
value={config.latitude || ""}
onChange={(e) =>
onConfigChange(
"latitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 40.7128"
/>
</div>
<div className="space-y-2">
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
type="number"
step="any"
value={config.longitude || ""}
onChange={(e) =>
onConfigChange(
"longitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., -74.0060"
/>
</div>
</div>
</div>
{/* Localization */}
<div className="space-y-3">
<Label>Locale</Label>
<Select
value={config.locale?.[0] || ""}
onValueChange={(value) =>
onConfigChange("locale", value ? [value] : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSystemDefaults
? "Loading..."
: `Select locale (system: ${systemLocale?.locale || "unknown"})`
}
/>
</SelectTrigger>
<SelectContent>
{!isLoadingSystemDefaults && systemLocale && (
<SelectItem value={systemLocale.locale}>
{systemLocale.locale} (System Default)
</SelectItem>
)}
{localeOptions.map((locale) => (
<SelectItem key={locale.value} value={locale.value}>
{locale.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Screen Resolution */}
<div className="space-y-3">
<Label>Screen Resolution</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-width">Min Width</Label>
<Input
id="screen-min-width"
type="number"
value={config.screen_min_width || ""}
onChange={(e) =>
onConfigChange(
"screen_min_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1024"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-width">Max Width</Label>
<Input
id="screen-max-width"
type="number"
value={config.screen_max_width || ""}
onChange={(e) =>
onConfigChange(
"screen_max_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1920"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-height">Min Height</Label>
<Input
id="screen-min-height"
type="number"
value={config.screen_min_height || ""}
onChange={(e) =>
onConfigChange(
"screen_min_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-height">Max Height</Label>
<Input
id="screen-max-height"
type="number"
value={config.screen_max_height || ""}
onChange={(e) =>
onConfigChange(
"screen_max_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1080"
/>
</div>
</div>
</div>
{/* Window Size */}
<div className="space-y-3">
<Label>Window Size</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="window-width">Width</Label>
<Input
id="window-width"
type="number"
value={config.window_width || ""}
onChange={(e) =>
onConfigChange(
"window_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1366"
/>
</div>
<div className="space-y-2">
<Label htmlFor="window-height">Height</Label>
<Input
id="window-height"
type="number"
value={config.window_height || ""}
onChange={(e) =>
onConfigChange(
"window_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
</div>
</div>
{/* Advanced Options */}
<div className="space-y-3">
<Label>Advanced Options</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-cache"
checked={config.enable_cache || false}
onCheckedChange={(checked) =>
onConfigChange("enable_cache", checked)
}
/>
<Label htmlFor="enable-cache">Enable Browser Cache</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="main-world-eval"
checked={config.main_world_eval || false}
onCheckedChange={(checked) =>
onConfigChange("main_world_eval", checked)
}
/>
<Label htmlFor="main-world-eval">
Enable Main World Evaluation
</Label>
</div>
</div>
</div>
{/* WebGL Settings */}
<div className="space-y-3">
<Label>WebGL Settings</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">WebGL Vendor</Label>
<Input
id="webgl-vendor"
value={config.webgl_vendor || ""}
onChange={(e) =>
onConfigChange("webgl_vendor", e.target.value || undefined)
}
placeholder="e.g., Intel Inc."
/>
</div>
<div className="space-y-2">
<Label htmlFor="webgl-renderer">WebGL Renderer</Label>
<Input
id="webgl-renderer"
value={config.webgl_renderer || ""}
onChange={(e) =>
onConfigChange("webgl_renderer", e.target.value || undefined)
}
placeholder="e.g., Intel HD Graphics"
/>
</div>
</div>
</div>
</div>
);
}
-1
View File
@@ -103,7 +103,6 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const currentSystemTheme = await getNativeSystemTheme();
// Force re-evaluation by toggling the theme
const html = document.documentElement;
const currentClass = html.className;
// Apply the system theme class
if (currentSystemTheme === "dark") {
+1 -1
View File
@@ -1,4 +1,4 @@
import { type VariantProps, cva } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
+1 -1
View File
@@ -1,5 +1,5 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
+1 -1
View File
@@ -1,5 +1,5 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
+79
View File
@@ -19,6 +19,85 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ComboboxOption {
value: string;
label: string;
description?: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
className?: string;
}
export function Combobox({
options,
value,
onValueChange,
placeholder = "Select option...",
searchPlaceholder = "Search...",
className,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", className)}
>
{value
? options.find((option) => option.value === value)?.label
: placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onValueChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-sm text-muted-foreground">
{option.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const frameworks = [
{
value: "next.js",
+8 -5
View File
@@ -5,6 +5,7 @@ import type * as React from "react";
import { RxCross2 } from "react-icons/rx";
import { cn } from "@/lib/utils";
import { WindowDragArea } from "../window-drag-area";
function Dialog({
...props
@@ -38,11 +39,13 @@ function DialogOverlay({
<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-50 bg-black/50",
"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-black/50",
className,
)}
{...props}
/>
>
<WindowDragArea />
</DialogPrimitive.Overlay>
);
}
@@ -57,7 +60,7 @@ function DialogContent({
<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-50 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",
"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,
)}
{...props}
@@ -102,7 +105,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
@@ -115,7 +118,7 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
+2 -2
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
+1 -1
View File
@@ -30,7 +30,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
+1 -1
View File
@@ -61,7 +61,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[50000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
+3 -3
View File
@@ -8,11 +8,11 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
className="overflow-x-auto relative w-full"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
className={cn("w-full text-sm caption-bottom", className)}
{...props}
/>
</div>
@@ -98,7 +98,7 @@ function TableCaption({
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
+55
View File
@@ -0,0 +1,55 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"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,
)}
{...props}
/>
));
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;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+2 -2
View File
@@ -46,13 +46,13 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-[50000] size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
-108
View File
@@ -1,108 +0,0 @@
"use client";
/* eslint-disable @typescript-eslint/no-misused-promises */
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import React from "react";
import { FaDownload, FaTimes } from "react-icons/fa";
import { LuDownload } from "react-icons/lu";
interface UpdateNotification {
id: string;
browser: string;
current_version: string;
new_version: string;
affected_profiles: string[];
is_stable_update: boolean;
timestamp: number;
}
interface UpdateNotificationProps {
notification: UpdateNotification;
onUpdate: (browser: string, newVersion: string) => Promise<void>;
onDismiss: (notificationId: string) => Promise<void>;
isUpdating?: boolean;
}
export function UpdateNotificationComponent({
notification,
onUpdate,
onDismiss,
isUpdating = false,
}: UpdateNotificationProps) {
const browserDisplayName = getBrowserDisplayName(notification.browser);
const profileText =
notification.affected_profiles.length === 1
? `profile "${notification.affected_profiles[0]}"`
: `${notification.affected_profiles.length} profiles`;
const handleUpdateClick = async () => {
// Dismiss the notification immediately to close the modal
await onDismiss(notification.id);
// Then start the update process
await onUpdate(notification.browser, notification.new_version);
};
return (
<div className="flex flex-col gap-3 p-4 max-w-md rounded-lg border shadow-lg bg-background border-border">
<div className="flex gap-2 justify-between items-start">
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className="font-semibold text-foreground">
{browserDisplayName} Update Available
</span>
<Badge
variant={notification.is_stable_update ? "default" : "secondary"}
>
{notification.is_stable_update ? "Stable" : "Nightly"}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
Update {profileText} from {notification.current_version} to{" "}
<span className="font-medium">{notification.new_version}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={async () => {
await onDismiss(notification.id);
}}
className="p-0 w-6 h-6 shrink-0"
>
<FaTimes className="w-3 h-3" />
</Button>
</div>
<div className="flex gap-2 items-center">
<Button
onClick={handleUpdateClick}
disabled={isUpdating}
size="sm"
className="flex gap-2 items-center"
>
<FaDownload className="w-3 h-3" />
Update
</Button>
<Button
variant="outline"
onClick={async () => {
await onDismiss(notification.id);
}}
size="sm"
>
Later
</Button>
</div>
{notification.affected_profiles.length > 1 && (
<div className="text-xs text-muted-foreground">
Affected profiles: {notification.affected_profiles.join(", ")}
</div>
)}
</div>
);
}
-156
View File
@@ -1,156 +0,0 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { LuDownload } from "react-icons/lu";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { ScrollArea } from "./ui/scroll-area";
interface GithubRelease {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
hash?: string;
}>;
published_at: string;
is_nightly: boolean;
}
interface VersionSelectorProps {
selectedVersion: string | null;
onVersionSelect: (version: string | null) => void;
availableVersions: GithubRelease[];
downloadedVersions: string[];
isDownloading: boolean;
onDownload: () => void;
placeholder?: string;
showDownloadButton?: boolean;
}
export function VersionSelector({
selectedVersion,
onVersionSelect,
availableVersions,
downloadedVersions,
isDownloading,
onDownload,
placeholder = "Select version...",
showDownloadButton = true,
}: VersionSelectorProps) {
const [versionPopoverOpen, setVersionPopoverOpen] = useState(false);
const isVersionDownloaded = selectedVersion
? downloadedVersions.includes(selectedVersion)
: false;
return (
<div className="space-y-4">
<Popover
open={versionPopoverOpen}
onOpenChange={setVersionPopoverOpen}
modal={true}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={versionPopoverOpen}
className="justify-between w-full"
>
{selectedVersion ?? placeholder}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search versions..." />
<CommandEmpty>No versions found.</CommandEmpty>
<CommandList>
<ScrollArea
className={
"[&>[data-radix-scroll-area-viewport]]:max-h-[200px]"
}
>
<CommandGroup>
{availableVersions.map((version) => {
const isDownloaded = downloadedVersions.includes(
version.tag_name,
);
return (
<CommandItem
key={version.tag_name}
value={version.tag_name}
onSelect={(currentValue) => {
onVersionSelect(
currentValue === selectedVersion
? null
: currentValue,
);
setVersionPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedVersion === version.tag_name
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex gap-2 items-center">
<span>{version.tag_name}</span>
{version.is_nightly && (
<Badge variant="secondary" className="text-xs">
Nightly
</Badge>
)}
{isDownloaded && (
<Badge variant="default" className="text-xs">
Downloaded
</Badge>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Download Button */}
{showDownloadButton && selectedVersion && !isVersionDownloaded && (
<LoadingButton
isLoading={isDownloading}
onClick={() => {
onDownload();
}}
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton>
)}
</div>
);
}

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