Compare commits

...

260 Commits

Author SHA1 Message Date
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
zhom c46f54536b chore: linting 2025-06-06 04:52:05 +04:00
zhom 6cbc8627a1 chore: version bump 2025-06-06 04:43:22 +04:00
zhom a4f4cc2f27 style: clean up settings dialog 2025-06-06 04:41:50 +04:00
zhom 21c4d0a8ab build: use custom dependabot token for dependency automerge 2025-06-06 04:29:18 +04:00
zhom 9335149153 Merge pull request #12 from zhom/dependabot/cargo/src-tauri/rust-dependencies-ac9a5aa5e0
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 6 updates
2025-06-06 04:08:04 +04:00
zhom 6711659231 Merge pull request #13 from zhom/dependabot/npm_and_yarn/frontend-dependencies-1eccf95907
deps(deps): bump @types/node from 22.15.29 to 22.15.30 in the frontend-dependencies group
2025-06-06 04:07:37 +04:00
zhom 8a592e3d7d Merge pull request #14 from zhom/dependabot/npm_and_yarn/nodecar/nodecar-dependencies-1eccf95907
deps(nodecar)(deps): bump @types/node from 22.15.29 to 22.15.30 in /nodecar in the nodecar-dependencies group
2025-06-06 04:07:11 +04:00
zhom beea23307b build: install xdg-utils on ubuntu arm 2025-06-06 03:34:46 +04:00
zhom 19b66d006d build: switch to ubuntu-22.04-arm for linux arm build 2025-06-06 03:12:24 +04:00
zhom c7a36f6cd0 build: try to update mirrors if security.ubuntu.com is down 2025-06-06 02:51:39 +04:00
zhom 7404cb3ff8 build: install arm64 libraries on linux arm build worker 2025-06-06 02:12:52 +04:00
zhom ee91445fe1 feat: add ability to import existing profiles 2025-06-06 02:12:21 +04:00
zhom 77d53c7f32 build: install pkg-config on ubuntu 2025-06-06 01:28:34 +04:00
zhom a21f22a916 build: remove invalid package installation 2025-06-06 01:09:34 +04:00
dependabot[bot] 4aaf2eecbc deps(nodecar)(deps): bump @types/node
Bumps the nodecar-dependencies group in /nodecar with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


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

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nodecar-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 21:02:39 +00:00
dependabot[bot] f750e64b81 deps(deps): bump @types/node in the frontend-dependencies group
Bumps the frontend-dependencies group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


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

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 21:00:09 +00:00
dependabot[bot] 16fd3e3c5e deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.35.1` | `0.35.2` |
| [bumpalo](https://github.com/fitzgen/bumpalo) | `3.17.0` | `3.18.0` |
| [hyper-util](https://github.com/hyperium/hyper-util) | `0.1.13` | `0.1.14` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.6.5` | `0.6.6` |
| [windows-registry](https://github.com/microsoft/windows-rs) | `0.4.0` | `0.5.2` |
| [windows-strings](https://github.com/microsoft/windows-rs) | `0.3.1` | `0.4.2` |


Updates `sysinfo` from 0.35.1 to 0.35.2
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.35.1...v0.35.2)

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

Updates `hyper-util` from 0.1.13 to 0.1.14
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.13...v0.1.14)

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

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

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

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.35.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bumpalo
  dependency-version: 3.18.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hyper-util
  dependency-version: 0.1.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: windows-registry
  dependency-version: 0.5.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: windows-strings
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 21:00:03 +00:00
zhom 93eae1d77f build: set pkg-config variables for linux arm 2025-06-06 00:57:21 +04:00
zhom 82615c24bd build: set cross-compile build variables for linux arm 2025-06-06 00:39:05 +04:00
zhom 0769106a51 build: run dependabot on saturday 2025-06-06 00:32:56 +04:00
zhom 18aa3cb87b test: fix 2025-06-06 00:22:01 +04:00
zhom f066105c0f build: remove universal macos build until @yao-pkg/pkg has support for universal macos binaries 2025-06-06 00:10:20 +04:00
zhom 9d8b3629f6 docs: readme 2025-06-06 00:04:38 +04:00
zhom 353e149886 build: switch to ubuntu 22 for linux build 2025-06-05 23:59:44 +04:00
zhom 2258edbb18 test: fix 2025-06-05 23:45:03 +04:00
zhom 69fff99bbe docs: readme 2025-06-05 23:38:16 +04:00
zhom a277b7d8a2 build: fix script path 2025-06-05 23:35:38 +04:00
zhom 7af3f7e86a docs: update readme 2025-06-05 23:34:48 +04:00
zhom de9c47241a build: cargo audit && copy binary instead of moving 2025-06-05 23:25:43 +04:00
zhom 5747729e30 build: use cargo audit for rust linting 2025-06-05 23:12:08 +04:00
zhom f71bda0fbf build: don't set extra build variables on linux arm systems 2025-06-05 22:59:51 +04:00
zhom 67bfb17e5a fix: correctly mark tor browser alpha versions: 2025-06-05 22:11:57 +04:00
zhom bb2eb65c1e chore: linter 2025-06-05 21:26:47 +04:00
zhom f397568785 docs: update readme to include linux 2025-06-05 21:17:10 +04:00
zhom 0da34f04cb feat: linux support preview 2025-06-05 21:15:05 +04:00
zhom 6836d73ffa refactor: prepare the backend for cross-platform function 2025-06-03 23:37:31 +04:00
zhom 63b890d47f docs: add responsible desclosure guidelines 2025-06-03 17:01:47 +04:00
zhom aeb6a08fc8 build: automatically merge dependendabot changes if they pass ci 2025-06-03 16:54:14 +04:00
zhom c698fff101 build: manage nodecar dependencies via workspace 2025-06-03 16:53:27 +04:00
zhom ccfd1f81f6 build: fail build if security scan fails 2025-06-03 16:30:34 +04:00
zhom 4c42099661 fix: osv permissions 2025-06-03 15:33:45 +04:00
zhom 4c4aa10d8c fix: reset lock files after bad dependabot merge 2025-06-03 15:33:03 +04:00
zhom a1a6ef63e4 Merge pull request #11 from zhom/dependabot/npm_and_yarn/frontend-dependencies-852ce57221
deps(deps): bump the frontend-dependencies group across 1 directory with 12 updates
2025-06-03 15:26:21 +04:00
dependabot[bot] bd7b9f1d9f deps(deps): bump the frontend-dependencies group across 1 directory with 12 updates
Bumps the frontend-dependencies group with 1 update in the / directory: [@jridgewell/trace-mapping](https://github.com/jridgewell/trace-mapping).


Updates `@jridgewell/trace-mapping` from 0.3.9 to 0.3.25
- [Release notes](https://github.com/jridgewell/trace-mapping/releases)
- [Commits](https://github.com/jridgewell/trace-mapping/compare/v0.3.9...v0.3.25)

Updates `ansi-regex` from 5.0.1 to 6.1.0
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v5.0.1...v6.1.0)

Updates `chownr` from 1.1.4 to 3.0.0
- [Commits](https://github.com/isaacs/chownr/compare/v1.1.4...v3.0.0)

Updates `emoji-regex` from 8.0.0 to 9.2.2
- [Commits](https://github.com/mathiasbynens/emoji-regex/compare/v8.0.0...v9.2.2)

Updates `has-flag` from 3.0.0 to 4.0.0
- [Release notes](https://github.com/sindresorhus/has-flag/releases)
- [Commits](https://github.com/sindresorhus/has-flag/compare/v3.0.0...v4.0.0)

Updates `is-fullwidth-code-point` from 3.0.0 to 4.0.0
- [Release notes](https://github.com/sindresorhus/is-fullwidth-code-point/releases)
- [Commits](https://github.com/sindresorhus/is-fullwidth-code-point/compare/v3.0.0...v4.0.0)

Updates `isarray` from 1.0.0 to 2.0.5
- [Release notes](https://github.com/juliangruber/isarray/releases)
- [Commits](https://github.com/juliangruber/isarray/compare/v1.0.0...v2.0.5)

Updates `string-width` from 4.2.3 to 7.2.0
- [Release notes](https://github.com/sindresorhus/string-width/releases)
- [Commits](https://github.com/sindresorhus/string-width/compare/v4.2.3...v7.2.0)

Updates `strip-ansi` from 6.0.1 to 7.1.0
- [Release notes](https://github.com/chalk/strip-ansi/releases)
- [Commits](https://github.com/chalk/strip-ansi/compare/v6.0.1...v7.1.0)

Updates `strip-json-comments` from 2.0.1 to 3.1.1
- [Release notes](https://github.com/sindresorhus/strip-json-comments/releases)
- [Commits](https://github.com/sindresorhus/strip-json-comments/compare/v2.0.1...v3.1.1)

Updates `supports-color` from 5.5.0 to 7.2.0
- [Release notes](https://github.com/chalk/supports-color/releases)
- [Commits](https://github.com/chalk/supports-color/compare/v5.5.0...v7.2.0)

Updates `wrap-ansi` from 7.0.0 to 9.0.0
- [Release notes](https://github.com/chalk/wrap-ansi/releases)
- [Commits](https://github.com/chalk/wrap-ansi/compare/v7.0.0...v9.0.0)

---
updated-dependencies:
- dependency-name: "@jridgewell/trace-mapping"
  dependency-version: 0.3.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: ansi-regex
  dependency-version: 6.1.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: chownr
  dependency-version: 3.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: emoji-regex
  dependency-version: 9.2.2
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: has-flag
  dependency-version: 4.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: is-fullwidth-code-point
  dependency-version: 4.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: isarray
  dependency-version: 2.0.5
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: string-width
  dependency-version: 7.2.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: strip-ansi
  dependency-version: 7.1.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: strip-json-comments
  dependency-version: 3.1.1
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: supports-color
  dependency-version: 7.2.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: wrap-ansi
  dependency-version: 9.0.0
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 11:24:36 +00:00
zhom f93b5daa9b Merge pull request #8 from zhom/dependabot/npm_and_yarn/nodecar/nodecar-dependencies-a69e916d1e
deps(nodecar)(deps): bump the nodecar-dependencies group in /nodecar with 4 updates
2025-06-03 15:10:21 +04:00
zhom f7f45bdc90 Merge pull request #10 from zhom/dependabot/cargo/src-tauri/rust-dependencies-04ecd6b2d6
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 3 updates
2025-06-03 15:09:36 +04:00
dependabot[bot] 48067ee3a7 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 3 updates: [reqwest](https://github.com/seanmonstar/reqwest), [camino](https://github.com/camino-rs/camino) and [tower-http](https://github.com/tower-rs/tower-http).


Updates `reqwest` from 0.12.18 to 0.12.19
- [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.18...v0.12.19)

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

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

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: camino
  dependency-version: 1.1.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 11:07:37 +00:00
zhom 87bd75aa21 chore: pnpm update && pnpm install --ignore-workspace 2025-06-03 15:07:13 +04:00
zhom cf443061b6 chore: pnpm install 2025-06-03 15:02:46 +04:00
zhom ca662d91a1 Merge pull request #7 from zhom/dependabot/github_actions/github-actions-0c45e47a22
ci(deps): bump google/osv-scanner-action from 1.7.1 to 2.0.2 in the github-actions group
2025-06-03 14:58:38 +04:00
dependabot[bot] 2963dbc0f9 deps(nodecar)(deps): bump the nodecar-dependencies group
Bumps the nodecar-dependencies group in /nodecar with 4 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [@yao-pkg/pkg](https://github.com/yao-pkg/pkg), [commander](https://github.com/tj/commander.js) and [proxy-chain](https://github.com/apify/proxy-chain).


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

Updates `@yao-pkg/pkg` from 6.4.1 to 6.5.1
- [Release notes](https://github.com/yao-pkg/pkg/releases)
- [Changelog](https://github.com/yao-pkg/pkg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/yao-pkg/pkg/compare/v6.4.1...v6.5.1)

Updates `commander` from 13.1.0 to 14.0.0
- [Release notes](https://github.com/tj/commander.js/releases)
- [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tj/commander.js/compare/v13.1.0...v14.0.0)

Updates `proxy-chain` from 2.5.8 to 2.5.9
- [Release notes](https://github.com/apify/proxy-chain/releases)
- [Changelog](https://github.com/apify/proxy-chain/blob/master/CHANGELOG.md)
- [Commits](https://github.com/apify/proxy-chain/compare/v2.5.8...v2.5.9)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nodecar-dependencies
- dependency-name: "@yao-pkg/pkg"
  dependency-version: 6.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nodecar-dependencies
- dependency-name: commander
  dependency-version: 14.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: nodecar-dependencies
- dependency-name: proxy-chain
  dependency-version: 2.5.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nodecar-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 10:51:44 +00:00
dependabot[bot] 225ed05d08 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 1.7.1 to 2.0.2
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/1f1242919d8a60496dd1874b24b62b2370ed4c78...e69cc6c86b31f1e7e23935bbe7031b50e51082de)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-03 10:48:03 +00:00
zhom 97de246ac6 feat: use osv, add pr checks, extend dependabot 2025-06-03 14:46:58 +04:00
zhom b00f62ebec fix: improve toast and dialog interations 2025-06-03 13:56:58 +04:00
zhom 2025a2a690 feat: better integrate with macos titlebar 2025-06-03 13:11:17 +04:00
zhom 2f1faa02e4 chore: version bump 2025-06-02 18:30:50 +04:00
127 changed files with 22590 additions and 9635 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.
+8 -8
View File
@@ -1,14 +1,14 @@
# ✨ Pull Request
### 📓 Referenced Issue
## 📓 Referenced Issue
<!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> -->
### ️ About the PR
## ️ About the PR
<!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. -->
### 🔄 Type of Change
## 🔄 Type of Change
<!-- Mark the relevant option with an "x". -->
@@ -19,11 +19,11 @@
- [ ] 🧹 Code cleanup/refactoring
- [ ] ⚡ Performance improvement
### 🖼️ Testing Scenarios / Screenshots
## 🖼️ Testing Scenarios / Screenshots
<!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. -->
### ✅ Checklist
## ✅ Checklist
<!-- Mark completed items with an "x". -->
@@ -36,11 +36,11 @@
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
### 🧪 How Has This Been Tested?
## 🧪 How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. -->
### 📱 Platform Testing
## 📱 Platform Testing
<!-- Which platforms have you tested on? -->
@@ -49,6 +49,6 @@
- [ ] Windows (if applicable)
- [ ] Linux (if applicable)
### 📋 Additional Notes
## 📋 Additional Notes
<!-- Any additional information that reviewers should know about this PR. -->
+30 -7
View File
@@ -1,28 +1,51 @@
version: 2
updates:
# Enable version updates for Node.js dependencies
# Frontend dependencies (root package.json)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
allow:
- dependency-type: "all"
groups:
all:
frontend-dependencies:
patterns:
- "*"
ignore:
- dependency-name: "eslint"
versions: ">= 9"
commit-message:
prefix: "deps"
include: "scope"
# Enable version updates for rust
# Rust dependencies
- package-ecosystem: "cargo"
directory: "/src-tauri"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
allow:
- dependency-type: "all"
groups:
all:
rust-dependencies:
patterns:
- "*"
commit-message:
prefix: "deps(rust)"
include: "scope"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
groups:
github-actions:
patterns:
- "*"
commit-message:
prefix: "ci"
include: "scope"
+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 }}
+73 -10
View File
@@ -1,21 +1,84 @@
# Automatically squashes and merges Dependabot dependency upgrades if tests pass
name: Dependabot Automerge
name: Dependabot Auto-merge
on: pull_request_target
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: write
checks: read
jobs:
dependabot:
runs-on: ubuntu-latest
security-scan:
name: Security Vulnerability Scan
if: ${{ github.actor == 'dependabot[bot]' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
permissions:
security-events: write
contents: read
actions: read
lint-js:
name: Lint JavaScript/TypeScript
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, codeql, spellcheck]
runs-on: ubuntu-latest
steps:
- name: Fetch Dependabot metadata
id: dependabot-metadata
uses: dependabot/fetch-metadata@v2
- name: Dependabot metadata
id: metadata
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@338053c6f9b9311a6be80208f6f0723981e40627 #v2.10.122
secrets: inherit
with:
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
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 anti-detect browser.
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
+9 -9
View File
@@ -13,6 +13,11 @@ on:
paths-ignore:
- "src-tauri/**"
- "README.md"
- ".github/workflows/lint-rs.yml"
- ".github/workflows/osv.yml"
permissions:
contents: read
jobs:
build:
@@ -29,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"
@@ -43,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
+24 -12
View File
@@ -12,11 +12,20 @@ on:
pull_request:
paths-ignore:
- "src/**"
- "nodecar/**"
- "package.json"
- "package-lock.json"
- "yarn.lock"
- "pnpm-lock.yaml"
- "yarn.lock"
- "README.md"
- ".github/workflows/lint-js.yml"
- ".github/workflows/osv.yml"
- "next.config.js"
- "tailwind.config.js"
- "tsconfig.json"
- "biome.json"
permissions:
contents: read
jobs:
build:
@@ -33,22 +42,26 @@ 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
run: cargo install cargo-audit
- name: Install dependencies (Ubuntu only)
if: matrix.os == 'ubuntu-latest'
run: |
@@ -58,11 +71,6 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Install nodecar dependencies
working-directory: ./nodecar
run: |
pnpm install --ignore-workspace --frozen-lockfile
- name: Build nodecar binary
shell: bash
working-directory: ./nodecar
@@ -70,7 +78,7 @@ jobs:
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
pnpm run build:linux-x64
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
pnpm run build:aarch64
pnpm run build:mac-aarch64
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
pnpm run build:win-x64
fi
@@ -101,3 +109,7 @@ jobs:
- name: Run Rust unit tests
run: cargo test
working-directory: src-tauri
- name: Run cargo audit security check
run: cargo audit
working-directory: src-tauri
+74
View File
@@ -0,0 +1,74 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
# in addition to a PR check which fails if new vulnerabilities are introduced.
#
# For more examples and options, including how to ignore specific vulnerabilities,
# see https://google.github.io/osv-scanner/github-action/
# Security vulnerability scanning for Donut Browser
# Scans dependencies in package managers (npm/pnpm, Cargo) for known vulnerabilities
# Runs on schedule and when dependencies change
name: Security Vulnerability Scan
on:
pull_request:
branches: ["main"]
paths:
- "package.json"
- "pnpm-lock.yaml"
- "src-tauri/Cargo.toml"
- "src-tauri/Cargo.lock"
- "nodecar/package.json"
- "nodecar/pnpm-lock.yaml"
- ".github/workflows/osv.yml"
merge_group:
branches: ["main"]
schedule:
# Run weekly on Tuesdays at 2:20 PM UTC
- cron: "20 14 * * 2"
push:
branches: ["main"]
paths:
- "package.json"
- "pnpm-lock.yaml"
- "src-tauri/Cargo.toml"
- "src-tauri/Cargo.lock"
- "nodecar/package.json"
- "nodecar/pnpm-lock.yaml"
permissions:
security-events: write
contents: read
actions: read
jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
+54
View File
@@ -0,0 +1,54 @@
name: Pull Request Checks
on:
pull_request:
branches: ["main"]
merge_group:
branches: ["main"]
permissions:
security-events: write
contents: read
actions: read
jobs:
lint-js:
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@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=nodecar/pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
./
pr-status:
name: PR Status Check
runs-on: ubuntu-latest
needs: [lint-js, lint-rust, security-scan]
if: always()
steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then
echo "One or more checks failed"
exit 1
fi
echo "All checks passed!"
@@ -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 anti-detect browser.
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
+69 -30
View File
@@ -11,18 +11,55 @@ env:
STABLE_RELEASE: "true"
jobs:
security-scan:
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
permissions:
security-events: write
contents: read
actions: read
lint-js:
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: [lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
strategy:
@@ -34,33 +71,32 @@ jobs:
arch: "aarch64"
target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64"
nodecar_script: "build:aarch64"
nodecar_script: "build:mac-aarch64"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
arch: "x86_64"
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64"
# Future platforms can be added here:
# - platform: "ubuntu-20.04"
# args: "--target x86_64-unknown-linux-gnu"
# arch: "x86_64"
# target: "x86_64-unknown-linux-gnu"
# pkg_target: "latest-linux-x64"
# nodecar_script: "build:linux-x64"
# - platform: "ubuntu-20.04"
# args: "--target aarch64-unknown-linux-gnu"
# arch: "aarch64"
# target: "aarch64-unknown-linux-gnu"
# pkg_target: "latest-linux-arm64"
# nodecar_script: "build:linux-arm64"
nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.04"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-22.04-arm"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
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-latest"
# - platform: "windows-11-arm"
# args: "--target aarch64-pc-windows-msvc"
# arch: "aarch64"
# target: "aarch64-pc-windows-msvc"
@@ -69,40 +105,36 @@ 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)
if: matrix.platform == 'ubuntu-20.04'
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
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 --ignore-workspace --frozen-lockfile
- name: Build nodecar sidecar
shell: bash
working-directory: ./nodecar
@@ -123,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 }}
@@ -134,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
+95 -23
View File
@@ -10,18 +10,55 @@ env:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
jobs:
security-scan:
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
permissions:
security-events: write
contents: read
actions: read
lint-js:
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: [lint-js, lint-rust]
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
strategy:
@@ -33,44 +70,70 @@ jobs:
arch: "aarch64"
target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64"
nodecar_script: "build:aarch64"
nodecar_script: "build:mac-aarch64"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
arch: "x86_64"
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64"
nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.04"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-22.04-arm"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
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)
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
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@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 --ignore-workspace --frozen-lockfile
- name: Build nodecar sidecar
shell: bash
working-directory: ./nodecar
@@ -81,26 +144,35 @@ jobs:
shell: bash
run: |
mkdir -p src-tauri/binaries
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
else
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
fi
- 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"
+4
View File
@@ -5,6 +5,10 @@
/.pnp
.pnp.js
# npm/yarn lock files (project uses pnpm only)
**/package-lock.json
**/yarn.lock
# testing
/coverage
+1 -1
View File
@@ -1 +1 @@
pnpm lint-staged
pnpm exec lint-staged
+1 -1
View File
@@ -1,2 +1,2 @@
22
23
+1
View File
@@ -0,0 +1 @@
23
+11
View File
@@ -0,0 +1,11 @@
{
"recommendations": [
"biomejs.biome",
"streetsidesoftware.code-spell-checker",
"usernamehw.errorlens",
"heybourn.headwind",
"yoavbls.pretty-ts-errors",
"rust-lang.rust-analyzer",
"bradlc.vscode-tailwindcss"
]
}
+91 -1
View File
@@ -1,23 +1,113 @@
{
"cSpell.words": [
"ahooks",
"akhilmhdh",
"appimage",
"appindicator",
"applescript",
"autoconfig",
"autologin",
"biomejs",
"camoufox",
"cdylib",
"CFURL",
"checkin",
"CLICOLOR",
"clippy",
"cmdk",
"codegen",
"CTYPE",
"devedition",
"doesn",
"donutbrowser",
"dpkg",
"dtolnay",
"dyld",
"elif",
"errorlevel",
"esac",
"esbuild",
"frontmost",
"geoip",
"gettimezone",
"gifs",
"gsettings",
"hkcu",
"icns",
"idletime",
"Inno",
"KHTML",
"launchservices",
"libatk",
"libayatana",
"libcairo",
"libgdk",
"libglib",
"libpango",
"librsvg",
"libwebkit",
"libxdo",
"localtime",
"mmdb",
"mountpoint",
"msiexec",
"msvc",
"msys",
"Mullvad",
"mullvadbrowser",
"nodecar",
"nodemon",
"norestart",
"NSIS",
"ntlm",
"objc",
"orhun",
"osascript",
"pixbuf",
"plasmohq",
"prefs",
"propertylist",
"reqwest",
"ridedott",
"rlib",
"rustc",
"SARIF",
"serde",
"shadcn",
"signon",
"sonner",
"splitn",
"sspi",
"staticlib",
"stefanzweifel",
"subdirs",
"subkey",
"SUPPRESSMSGBOXES",
"swatinem",
"sysinfo",
"systempreferences",
"turbopack"
"systemsetup",
"taskkill",
"tasklist",
"tauri",
"TERX",
"timedatectl",
"titlebar",
"Torbrowser",
"turbopack",
"udeps",
"unlisten",
"unminimize",
"unrs",
"urlencoding",
"vercel",
"VERYSILENT",
"webgl",
"webrtc",
"winreg",
"wiremock",
"xattr",
"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.
+1 -38
View File
@@ -50,7 +50,7 @@ After having the above dependencies installed, proceed through the following ste
```bash
cd nodecar
pnpm install --ignore-workspace --frozen-lockfile
pnpm install --frozen-lockfile
cd ..
```
@@ -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:
+55 -8
View File
@@ -1,41 +1,60 @@
<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>
<strong>A powerful anti-detect browser that puts you in control of your browsing experience. 🍩</strong>
</div>
<br>
<p align="center">
<a 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 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 href="https://github.com/zhom/donutbrowser/issues" target="_blank">
<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 href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<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://github.com/zhom/donutbrowser/stargazers" target="_blank">
<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>
## Donut Browser
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
> A free and open source anti-detect browser 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
> As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it. The app automatically checks for updates on each launch.
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
## Supported Platforms
-**macOS** (Intel & Apple Silicon)
-**Linux** (x64 & arm64)
- 🔄 **Windows** (Planned)
- 🔄 **Linux** (Planned)
## Development
@@ -54,6 +73,34 @@ Have questions or want to contribute? We'd love to hear from you!
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
## Star History
<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
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
+40
View File
@@ -0,0 +1,40 @@
# Security Policy
## Reporting Security Issues
Thanks for helping make Donut Browser safe for everyone! ❤️
We take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to us through coordinated disclosure.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
- The type of issue (e.g., buffer overflow, injection attack, privilege escalation, or cross-site scripting)
- Full paths of source file(s) related to the manifestation of the issue
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit the issue
- Your assessment of the severity level
This information will help us triage your report more quickly.
## What to Expect
- **Response Time**: We will acknowledge receipt of your vulnerability report within 72 hours.
- **Investigation**: We will investigate the issue and provide you with updates on our progress.
- **Resolution**: We aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
- **Disclosure**: We will coordinate with you on the timing of any public disclosure.
## Contact
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
For general questions about this security policy, you can also reach out through:
- [GitHub Issues](https://github.com/zhom/donutbrowser/issues) (for non-security questions only)
- [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
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"
+2 -2
View File
@@ -4,7 +4,7 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
@@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}
-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;
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Determine file extension based on platform
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then
EXT=".exe"
else
EXT=""
fi
# If architecture provided in the command line, use it to rename the binary in TARGET_TRIPLE
if [ -n "$1" ]; then
TARGET_TRIPLE="$1"
else
RUST_INFO=$(rustc -vV)
TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2)
fi
# Check if target triple was found
if [ -z "$TARGET_TRIPLE" ]; then
echo "Failed to determine platform target triple" >&2
exit 1
fi
# 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;
+21 -15
View File
@@ -2,32 +2,38 @@
"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",
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
"build:aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
"build:x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar",
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar",
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar",
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar",
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar"
"start": "tsc && node ./dist/index.js",
"test": "tsc && node ./dist/test-proxy.js",
"rename-binary": "sh ./copy-binary.sh",
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
"build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
"build:mac-x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar && pnpm rename-binary",
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar && pnpm rename-binary",
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar && pnpm rename-binary",
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar && pnpm rename-binary",
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar && pnpm rename-binary"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0",
"packageManager": "pnpm@10.6.1",
"dependencies": {
"@types/node": "^22.15.17",
"@yao-pkg/pkg": "^6.4.1",
"commander": "^13.1.0",
"dotenv": "^16.5.0",
"@types/node": "^24.0.10",
"@yao-pkg/pkg": "^6.5.1",
"camoufox-js": "^0.6.0",
"commander": "^14.0.0",
"dotenv": "^17.0.1",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"proxy-chain": "^2.5.8",
"proxy-chain": "^2.5.9",
"tmp": "^0.2.3",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/tmp": "^0.2.6"
}
}
-1304
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
import { execSync } from "child_process";
import fs from "fs";
const ext = process.platform === "win32" ? ".exe" : "";
const rustInfo = execSync("rustc -vV");
const targetTriple = /host: (\S+)/g.exec(rustInfo)[1];
if (!targetTriple) {
console.error("Failed to determine platform target triple");
}
fs.renameSync(
`dist/nodecar${ext}`,
`../src-tauri/binaries/nodecar-${targetTriple}${ext}`
);
+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();
+350 -24
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));
} catch (error: any) {
console.error(`Failed to start proxy: ${error.message}`);
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: config.id,
localPort: config.localPort,
localUrl: config.localUrl,
upstreamUrl: config.upstreamUrl,
}),
);
// Exit successfully to allow the process to detach
process.exit(0);
} catch (error: unknown) {
console.error(
`Failed to start proxy: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`,
);
process.exit(1);
}
} else if (action === "stop") {
if (options.id) {
const stopped = await stopProxyProcess(options.id);
console.log(`{
"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 anti-detect browser
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 anti-detect browser 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();
+40 -28
View File
@@ -1,14 +1,14 @@
import { spawn } from "child_process";
import path from "path";
import { spawn } from "node:child_process";
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);
}
+14 -13
View File
@@ -1,8 +1,7 @@
import fs from "fs";
import path from "path";
import os from "os";
import fs from "node:fs";
import path from "node:path";
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();
}
+34 -33
View File
@@ -2,22 +2,26 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.2.4",
"version": "0.7.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"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",
"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"
"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": {
"@radix-ui/react-checkbox": "^1.3.2",
@@ -29,48 +33,45 @@
"@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-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.3",
"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.27.0",
"@next/eslint-plugin-next": "^15.3.2",
"@tailwindcss/postcss": "^4.1.7",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.15.21",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.5.0",
"eslint": "^9.27.0",
"eslint-config-next": "^15.3.2",
"eslint-plugin-react-hooks": "^5.2.0",
"@biomejs/biome": "2.0.6",
"@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2.6.2",
"@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.32.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.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
"packageManager": "pnpm@10.11.1",
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix"
],
"src-tauri/**/*.rs": [
+2582 -2893
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,5 +1,9 @@
packages:
- nodecar
onlyBuiltDependencies:
- '@biomejs/biome'
- '@tailwindcss/oxide'
- esbuild
- sharp
- sqlite3
- unrs-resolver
+504 -308
View File
File diff suppressed because it is too large Load Diff
+33 -4
View File
@@ -1,9 +1,10 @@
[package]
name = "donutbrowser"
version = "0.2.4"
description = "Browser Orchestrator"
version = "0.7.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
default-run = "donutbrowser"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -25,23 +26,51 @@ tauri-plugin-opener = "2"
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.2.4</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 {
+17 -1
View File
@@ -6,6 +6,11 @@
"permissions": [
"core:default",
"core:event:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"opener:default",
"fs:default",
"shell:allow-execute",
@@ -13,6 +18,17 @@
"shell:allow-open",
"shell:allow-spawn",
"shell:allow-stdin-write",
"deep-link:default"
"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",
"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"
]
}
+13
View File
@@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Donut Browser
Comment=Simple Yet Powerful Anti-Detect Browser
Exec=donutbrowser %u
Icon=donutbrowser
StartupNotify=true
NoDisplay=false
Categories=Network;WebBrowser;Productivity;
MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;application/xhtml+xml;
StartupWMClass=donutbrowser
Keywords=browser;web;internet;productivity;
+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>
File diff suppressed because it is too large Load Diff
+571 -97
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,11 +159,11 @@ 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)
.header("User-Agent", "donutbrowser")
.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?;
@@ -227,6 +234,7 @@ impl AppAutoUpdater {
/// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
// Priority 1: Get architecture-specific binary for backward compatibility
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
@@ -235,12 +243,9 @@ impl AppAutoUpdater {
"unknown"
};
println!("Looking for assets with architecture: {arch}");
for asset in assets {
println!("Found asset: {}", asset.name);
}
println!("Falling back to architecture-specific search for: {arch}");
// Priority 1: Look for exact architecture match in DMG
// Look for exact architecture match in DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains(&format!("_{arch}.dmg"))
@@ -253,7 +258,7 @@ impl AppAutoUpdater {
}
}
// Priority 2: Look for x86_64 variations if we're looking for x64
// Look for x86_64 variations if we're looking for x64
if arch == "x64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -265,7 +270,7 @@ impl AppAutoUpdater {
}
}
// Priority 3: Look for arm64 variations if we're looking for aarch64
// Look for arm64 variations if we're looking for aarch64
if arch == "aarch64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -277,7 +282,7 @@ impl AppAutoUpdater {
}
}
// Priority 4: Fallback to any macOS DMG
// Priority 2: Fallback to any macOS DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
@@ -313,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?;
@@ -336,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?;
@@ -344,19 +385,20 @@ 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);
let response = self
.client
.get(download_url)
.header("User-Agent", "donutbrowser")
.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?;
@@ -364,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: format!("Downloading update... {percentage:.1}%"),
},
);
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)
}
@@ -390,7 +492,40 @@ impl AppAutoUpdater {
.unwrap_or("");
match extension {
"dmg" => extractor.extract_dmg(archive_path, dest_dir).await,
"dmg" => {
#[cfg(target_os = "macos")]
{
extractor.extract_dmg(archive_path, dest_dir).await
}
#[cfg(not(target_os = "macos"))]
{
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()),
}
@@ -399,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
@@ -478,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())
}
}
}
@@ -535,14 +990,6 @@ pub async fn download_and_install_app_update(
.map_err(|e| format!("Failed to install app update: {e}"))
}
#[tauri::command]
pub fn get_app_version_info() -> Result<(String, bool), String> {
Ok((
AppAutoUpdater::get_current_version(),
AppAutoUpdater::is_nightly_build(),
))
}
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
println!("Manual app update check triggered");
@@ -656,9 +1103,36 @@ mod tests {
let url = updater.get_download_url_for_platform(&assets);
assert!(url.is_some());
// The exact URL depends on the target architecture
let url = url.unwrap();
assert!(url.contains(".dmg"));
// Test with generic macOS DMG (no architecture specified)
let generic_assets = vec![AppReleaseAsset {
name: "Donut.Browser_0.1.0_macos.dmg".to_string(),
browser_download_url: "https://example.com/macos.dmg".to_string(),
size: 12345,
}];
let generic_url = updater.get_download_url_for_platform(&generic_assets);
assert!(generic_url.is_some());
assert_eq!(generic_url.unwrap(), "https://example.com/macos.dmg");
// Test architecture-specific DMG
let arch_specific_assets = vec![
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
];
let arch_url = updater.get_download_url_for_platform(&arch_specific_assets);
assert!(arch_url.is_some());
// The exact URL depends on the target architecture, but should be one of the available ones
let arch_url = arch_url.unwrap();
assert!(arch_url.contains(".dmg"));
}
#[test]
+236 -179
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_alpha_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,77 +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))
}
/// Start browser update process
pub async fn start_browser_update(
&self,
browser: &str,
new_version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Add browser to disabled list to prevent conflicts during update
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.insert(browser.to_string());
// Mark this download as auto-update for toast suppression
let download_key = format!("{browser}-{new_version}");
state.auto_update_downloads.insert(download_key);
self.save_auto_update_state(&state)?;
// The actual download will be triggered by the frontend
// This function now just marks the browser as updating to prevent conflicts
Ok(())
}
/// Complete browser update process
pub async fn complete_browser_update(
&self,
browser: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Remove browser from disabled list
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.remove(browser);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions(
&self,
@@ -312,9 +348,44 @@ impl AutoUpdater {
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
// 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)
}
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(
&self,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// Load current profiles
let profiles = self
.browser_runner
.list_profiles()
.map_err(|e| format!("Failed to load profiles: {e}"))?;
// Load registry
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
// Get active browser versions
let active_versions = registry.get_active_browser_versions(&profiles);
// Cleanup unused binaries
let cleaned_up = registry
.cleanup_unused_binaries(&active_versions)
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))?;
// Save updated registry
registry
.save()
.map_err(|e| format!("Failed to save registry: {e}"))?;
Ok(cleaned_up)
}
/// Check if browser is disabled due to ongoing update
pub fn is_browser_disabled(
&self,
@@ -335,34 +406,18 @@ impl AutoUpdater {
Ok(())
}
// Helper methods
fn is_alpha_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 {
@@ -414,24 +469,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
Ok(grouped)
}
#[tauri::command]
pub async fn start_browser_update(browser: String, new_version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.start_browser_update(&browser, &new_version)
.await
.map_err(|e| format!("Failed to start browser update: {e}"))
}
#[tauri::command]
pub async fn complete_browser_update(browser: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.complete_browser_update(&browser)
.await
.map_err(|e| format!("Failed to complete browser update: {e}"))
}
#[tauri::command]
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
@@ -461,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)]
@@ -490,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,
}
}
@@ -508,21 +529,6 @@ mod tests {
}
}
#[test]
fn test_is_alpha_version() {
let updater = AutoUpdater::new();
assert!(updater.is_alpha_version("1.0.0-alpha"));
assert!(updater.is_alpha_version("1.0.0-beta"));
assert!(updater.is_alpha_version("1.0.0-rc"));
assert!(updater.is_alpha_version("1.0.0a1"));
assert!(updater.is_alpha_version("1.0.0b1"));
assert!(updater.is_alpha_version("1.0.0-dev"));
assert!(!updater.is_alpha_version("1.0.0"));
assert!(!updater.is_alpha_version("1.2.3"));
}
#[test]
fn test_compare_versions() {
let updater = AutoUpdater::new();
@@ -559,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();
@@ -843,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]);
}
}
+693 -185
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+523 -94
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,
@@ -40,6 +46,75 @@ impl BrowserVersionService {
Self { api_client }
}
/// Check if a browser is supported on the current platform and architecture
pub fn is_browser_supported(
&self,
browser: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let (os, arch) = Self::get_platform_info();
match browser {
"firefox" | "firefox-developer" => Ok(true),
"mullvad-browser" => {
// Mullvad doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
Ok(false)
} else {
Ok(true)
}
}
"zen" => {
// Zen supports all platforms and architectures
Ok(true)
}
"brave" => {
// Brave supports all platforms and architectures
Ok(true)
}
"chromium" => {
// Chromium doesn't support ARM64 on Linux
if arch == "arm64" && os == "linux" {
Ok(false)
} else {
Ok(true)
}
}
"tor-browser" => {
// TOR Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
Ok(false)
} else {
Ok(true)
}
}
"camoufox" => {
// Camoufox supports all platforms and architectures according to the JS code
Ok(true)
}
_ => Err(format!("Unknown browser: {browser}").into()),
}
}
/// Get list of browsers supported on the current platform
pub fn get_supported_browsers(&self) -> Vec<String> {
let all_browsers = vec![
"firefox",
"firefox-developer",
"mullvad-browser",
"zen",
"brave",
"chromium",
"tor-browser",
"camoufox",
];
all_browsers
.into_iter()
.filter(|browser| self.is_browser_supported(browser).unwrap_or(false))
.map(|s| s.to_string())
.collect()
}
/// Get cached browser versions immediately (returns None if no cache exists)
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
self.api_client.load_cached_versions(browser)
@@ -58,7 +133,7 @@ impl BrowserVersionService {
.map(|version| {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(browser, &version, None),
date: "".to_string(), // Cache doesn't store dates
}
})
@@ -72,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,
@@ -106,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()),
};
@@ -176,7 +313,9 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"firefox", &version, None,
),
date: "".to_string(),
}
}
@@ -197,7 +336,11 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_browser_version_nightly(
"firefox-developer",
&version,
None,
),
date: "".to_string(),
}
}
@@ -212,7 +355,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.is_alpha,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
@@ -229,17 +372,19 @@ 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 {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("alpha") || version.contains("beta"),
is_prerelease: crate::api_client::is_browser_version_nightly("zen", &version, None),
date: "".to_string(),
}
}
@@ -254,13 +399,15 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} 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(),
}
}
@@ -281,7 +428,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Chromium versions are usually stable
is_prerelease: false, // Chromium usually stable releases
date: "".to_string(),
}
}
@@ -296,20 +443,47 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: release.is_prerelease,
is_prerelease: crate::api_client::is_browser_version_nightly(
"tor-browser",
&release.version,
None,
),
date: release.date.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("alpha") || version.contains("rc"),
is_prerelease: false, // TOR Browser usually stable releases
date: "".to_string(),
}
}
})
.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)
@@ -355,61 +529,282 @@ impl BrowserVersionService {
browser: &str,
version: &str,
) -> Result<DownloadInfo, Box<dyn std::error::Error + Send + Sync>> {
let (os, arch) = Self::get_platform_info();
match browser {
"firefox" => Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=osx&lang=en-US"),
filename: format!("firefox-{version}.dmg"),
is_archive: true,
}),
"firefox-developer" => Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=osx&lang=en-US"),
filename: format!("firefox-developer-{version}.dmg"),
is_archive: true,
}),
"mullvad-browser" => Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-macos-{version}.dmg"
),
filename: format!("mullvad-browser-{version}.dmg"),
is_archive: true,
}),
"zen" => Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.macos-universal.dmg"
),
filename: format!("zen-{version}.dmg"),
is_archive: true,
}),
"brave" => {
// For Brave, we use a placeholder URL since we need to resolve the actual asset URL dynamically
// The actual URL will be resolved in the download service using the GitHub API
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/Brave-Browser-universal.dmg"
),
filename: format!("brave-{version}.dmg"),
is_archive: true,
})
}
"chromium" => {
let arch = if cfg!(target_arch = "aarch64") { "Mac_Arm" } else { "Mac" };
Ok(DownloadInfo {
url: format!(
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{arch}/{version}/chrome-mac.zip"
),
filename: format!("chromium-{version}.zip"),
is_archive: true,
})
}
"tor-browser" => Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg"
),
filename: format!("tor-browser-{version}.dmg"),
is_archive: true,
}),
_ => Err(format!("Unsupported browser: {browser}").into()),
"firefox" => {
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(),
)
}
};
Ok(DownloadInfo {
url: format!(
"https://download-installer.cdn.mozilla.net/pub/firefox/releases/{version}/{platform_path}/en-US/{filename}"
),
filename,
is_archive,
})
}
"firefox-developer" => {
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}")
.into(),
)
}
};
Ok(DownloadInfo {
url: format!(
"https://download-installer.cdn.mozilla.net/pub/devedition/releases/{version}/{platform_path}/en-US/{filename}"
),
filename,
is_archive,
})
}
"mullvad-browser" => {
// Mullvad Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("Mullvad Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64",
format!("mullvad-browser-windows-x86_64-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Linux".into());
}
(
"x86_64",
format!("mullvad-browser-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => (
"macos",
format!("mullvad-browser-macos-{version}.dmg"),
true,
),
_ => return Err(format!("Unsupported platform for Mullvad Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
"zen" => {
let (asset_name, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("zen.installer.exe", format!("zen-{version}.exe"), false),
("windows", "arm64") => (
"zen.installer-arm64.exe",
format!("zen-{version}-arm64.exe"),
false,
),
("linux", "x64") => (
"zen.linux-x86_64.tar.xz",
format!("zen-{version}-x86_64.tar.xz"),
true,
),
("linux", "arm64") => (
"zen.linux-aarch64.tar.xz",
format!("zen-{version}-aarch64.tar.xz"),
true,
),
("macos", _) => (
"zen.macos-universal.dmg",
format!("zen-{version}.dmg"),
true,
),
_ => {
return Err(format!("Unsupported platform/architecture for Zen: {os}/{arch}").into())
}
};
Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/{asset_name}"
),
filename,
is_archive,
})
}
"brave" => {
let (filename, is_archive) = match (&os[..], &arch[..]) {
("windows", _) => (format!("brave-{version}.exe"), false),
("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true),
("linux", "arm64") => (format!("brave-browser-{version}-linux-arm64.zip"), true),
("macos", _) => ("Brave-Browser-universal.dmg".to_string(), true),
_ => {
return Err(format!("Unsupported platform/architecture for Brave: {os}/{arch}").into())
}
};
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/{filename}"
),
filename,
is_archive,
})
}
"chromium" => {
let platform_str = match (&os[..], &arch[..]) {
("windows", "x64") => "Win_x64",
("windows", "arm64") => "Win_Arm64",
("linux", "x64") => "Linux_x64",
("linux", "arm64") => return Err("Chromium doesn't support ARM64 on Linux".into()),
("macos", "x64") => "Mac",
("macos", "arm64") => "Mac_Arm",
_ => {
return Err(
format!("Unsupported platform/architecture for Chromium: {os}/{arch}").into(),
)
}
};
let (archive_name, filename) = match os.as_str() {
"windows" => ("chrome-win.zip", format!("chromium-{version}-win.zip")),
"linux" => ("chrome-linux.zip", format!("chromium-{version}-linux.zip")),
"macos" => ("chrome-mac.zip", format!("chromium-{version}-mac.zip")),
_ => return Err(format!("Unsupported platform for Chromium: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{platform_str}/{version}/{archive_name}"
),
filename,
is_archive: true,
})
}
"tor-browser" => {
// TOR Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("TOR Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64-portable",
format!("tor-browser-windows-x86_64-portable-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Linux".into());
}
(
"linux-x86_64",
format!("tor-browser-linux-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => ("macos", format!("tor-browser-macos-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for TOR Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
"camoufox" => {
// Camoufox downloads from GitHub releases with pattern: camoufox-{version}-{release}-{os}.{arch}.zip
let (os_name, arch_name) = match (&os[..], &arch[..]) {
("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()),
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
// Private helper methods for each browser type
@@ -475,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(
@@ -541,12 +942,30 @@ 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)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -561,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
)
}
@@ -604,7 +1022,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -640,7 +1057,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -682,7 +1098,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -724,7 +1140,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -737,21 +1153,31 @@ mod tests {
async fn setup_brave_mocks(server: &MockServer) {
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"tag_name": "v1.79.119",
"name": "Release v1.79.119 (Chromium 137.0.7151.68)",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"name": "brave-v1.79.119-universal.dmg",
"browser_download_url": "https://example.com/brave-1.79.119-universal.dmg",
"size": 200000000
},
{
"name": "brave-browser-1.79.119-linux-amd64.zip",
"browser_download_url": "https://example.com/brave-browser-1.79.119-linux-amd64.zip",
"size": 150000000
},
{
"name": "brave-browser-1.79.119-linux-arm64.zip",
"browser_download_url": "https://example.com/brave-browser-1.79.119-linux-arm64.zip",
"size": 145000000
}
]
},
{
"tag_name": "v1.81.8",
"name": "Brave Release 1.81.8",
"name": "Nightly v1.81.8",
"prerelease": false,
"published_at": "2024-01-10T10:00:00Z",
"assets": [
@@ -766,7 +1192,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -785,7 +1211,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -833,7 +1258,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
@@ -844,7 +1268,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_144)
@@ -855,7 +1278,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_143)
@@ -866,7 +1288,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.2/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_142)
@@ -878,7 +1299,7 @@ mod tests {
#[tokio::test]
async fn test_browser_version_service_creation() {
let _service = BrowserVersionService::new();
let _ = BrowserVersionService::new();
// Test passes if we can create the service without panicking
}
@@ -1200,23 +1621,31 @@ 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
let mullvad_info = service
.get_download_info("mullvad-browser", "14.5a6")
.unwrap();
assert_eq!(mullvad_info.filename, "mullvad-browser-14.5a6.dmg");
assert_eq!(mullvad_info.filename, "mullvad-browser-macos-14.5a6.dmg");
assert!(mullvad_info.url.contains("mullvad-browser-macos-14.5a6"));
assert!(mullvad_info.is_archive);
@@ -1228,20 +1657,20 @@ mod tests {
// Test Tor Browser
let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap();
assert_eq!(tor_info.filename, "tor-browser-14.0.4.dmg");
assert_eq!(tor_info.filename, "tor-browser-macos-14.0.4.dmg");
assert!(tor_info.url.contains("tor-browser-macos-14.0.4"));
assert!(tor_info.is_archive);
// Test Chromium
let chromium_info = service.get_download_info("chromium", "1465660").unwrap();
assert_eq!(chromium_info.filename, "chromium-1465660.zip");
assert_eq!(chromium_info.filename, "chromium-1465660-mac.zip");
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-v1.81.9.dmg");
assert!(brave_info.url.contains("Brave-Browser"));
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
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}"))
}
+421 -10
View File
@@ -65,25 +65,420 @@ 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 Anti-Detect Browser",
)
.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 Anti-Detect Browser",
)
.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,
);
}
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::process::Command;
const APP_DESKTOP_NAME: &str = "donutbrowser.desktop";
pub fn is_default_browser() -> Result<bool, String> {
// Linux implementation would go here
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
let schemes = ["http", "https"];
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Query the current default handler for this scheme
let output = Command::new("xdg-mime")
.args(["query", "default", &mime_type])
.output()
.map_err(|e| format!("Failed to query default handler for {}: {}", scheme, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("xdg-mime query failed for {}: {}", scheme, stderr));
}
let current_handler = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Check if our app is the default handler
if current_handler != APP_DESKTOP_NAME {
return Ok(false);
}
}
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
// Check if the desktop file exists in common locations
if !check_desktop_file_exists() {
return Err(format!(
"Desktop file '{}' not found in standard locations. Please ensure the application is properly installed. You can manually set Donut Browser as the default browser in your system settings.",
APP_DESKTOP_NAME
));
}
let schemes = ["http", "https"];
let mut all_succeeded = true;
let mut error_messages = Vec::new();
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Set our app as the default handler for this scheme
let output = Command::new("xdg-mime")
.args(["default", APP_DESKTOP_NAME, &mime_type])
.output()
.map_err(|e| format!("Failed to set default handler for {}: {}", scheme, e))?;
if !output.status.success() {
all_succeeded = false;
let stderr = String::from_utf8_lossy(&output.stderr);
error_messages.push(format!("Failed to set default for {}: {}", scheme, stderr));
}
}
if !all_succeeded {
return Err(format!(
"Some xdg-mime commands failed:\n{}\n\nYou may need to:\n1. Run with appropriate permissions\n2. Manually set the default browser in your desktop environment settings\n3. Restart your desktop session",
error_messages.join("\n")
));
}
// Give the system a moment to process the changes
std::thread::sleep(std::time::Duration::from_millis(500));
// Verify the changes took effect
match is_default_browser() {
Ok(true) => Ok(()),
Ok(false) => {
// This is the common case where commands succeed but verification fails
Err(format!(
"The xdg-mime commands completed successfully, but Donut Browser is not yet set as the default. This is common on some Linux distributions. Please try one of these options:\n\n1. Restart your desktop session and try again\n2. Log out and log back in\n3. Manually set Donut Browser as the default in your system settings:\n - GNOME: Settings > Default Applications > Web\n - KDE: System Settings > Applications > Default Applications > Web Browser\n - XFCE: Settings > Preferred Applications > Web Browser\n - Or run: xdg-settings set default-web-browser {}\n\nThe changes may take effect automatically after a desktop restart.",
APP_DESKTOP_NAME
))
}
Err(e) => Err(format!(
"Set as default completed, but verification failed: {}. The changes may still be in effect after restarting your desktop session.",
e
))
}
}
fn is_xdg_mime_available() -> bool {
Command::new("which")
.arg("xdg-mime")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn check_desktop_file_exists() -> bool {
let desktop_locations = [
"~/.local/share/applications/",
"/usr/share/applications/",
"/usr/local/share/applications/",
"/var/lib/flatpak/exports/share/applications/",
"~/.local/share/flatpak/exports/share/applications/",
];
for location in &desktop_locations {
let path = if location.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
location.replace('~', &home)
} else {
continue;
}
} else {
location.to_string()
};
let full_path = format!("{}{}", path, APP_DESKTOP_NAME);
if std::path::Path::new(&full_path).exists() {
return true;
}
}
false
}
}
@@ -140,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}");
@@ -210,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(_) => {
+286 -45
View File
@@ -51,7 +51,7 @@ impl Downloader {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual macOS asset
// For Brave, we need to find the actual platform-specific asset
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
@@ -65,39 +65,63 @@ impl Downloader {
})
.ok_or(format!("Brave version {version} not found"))?;
// Find the universal macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset based on platform and architecture
let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No universal macOS DMG asset found for Brave version {version}"
"No compatible asset found for Brave version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
BrowserType::Zen => {
// For Zen, verify the asset exists
let releases = self
.api_client
.fetch_zen_releases_with_caching(true)
.await?;
// For Zen, verify the asset exists and handle different naming patterns
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(", ")
)
})?;
// Find the macOS universal DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg")
.ok_or(format!(
"No macOS universal asset found for Zen version {version}"
))?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
Ok(asset.browser_download_url.clone())
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &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)
}
BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists
@@ -111,16 +135,41 @@ impl Downloader {
.find(|r| r.tag_name == version)
.ok_or(format!("Mullvad version {version} not found"))?;
// Find the macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_mullvad_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No macOS asset found for Mullvad version {version}"
"No compatible asset found for Mullvad version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
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
@@ -129,6 +178,202 @@ impl Downloader {
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
/// Find the appropriate Brave asset for the current platform and architecture
fn find_brave_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Brave asset naming patterns:
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
let asset = match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
.or_else(|| {
// Fallback to any EXE if standalone not found
assets.iter().find(|asset| asset.name.ends_with(".exe"))
})
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
.or_else(|| {
// Fallback to any DMG
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
})
}
"linux" => {
// 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")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Zen asset for the current platform and architecture
fn find_zen_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Zen asset naming patterns:
// Windows: zen.installer.exe, zen.installer-arm64.exe
// macOS: zen.macos-universal.dmg
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
let asset = match (os, arch) {
("windows", "x64") => assets
.iter()
.find(|asset| asset.name == "zen.installer.exe"),
("windows", "arm64") => assets
.iter()
.find(|asset| asset.name == "zen.installer-arm64.exe"),
("macos", _) => assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg"),
("linux", "x64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-x86_64.AppImage")
})
}
("linux", "arm64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-aarch64.AppImage")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Mullvad asset for the current platform and architecture
fn find_mullvad_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Mullvad asset naming patterns:
// Windows: mullvad-browser-windows-x86_64-VERSION.exe
// macOS: mullvad-browser-macos-VERSION.dmg
// Linux: mullvad-browser-x86_64-VERSION.tar.xz
let asset = match (os, arch) {
("windows", "x64") => assets.iter().find(|asset| {
asset.name.contains("windows")
&& asset.name.contains("x86_64")
&& asset.name.ends_with(".exe")
}),
("windows", "arm64") => {
// Mullvad doesn't support ARM64 on Windows
None
}
("macos", _) => assets
.iter()
.find(|asset| asset.name.contains("macos") && asset.name.ends_with(".dmg")),
("linux", "x64") => assets.iter().find(|asset| {
asset.name.contains("x86_64")
&& asset.name.ends_with(".tar.xz")
&& !asset.name.contains("windows")
}),
("linux", "arm64") => {
// Mullvad doesn't support ARM64 on Linux
None
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Camoufox asset for the current platform and architecture
fn find_camoufox_asset(
&self,
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>,
@@ -170,7 +415,7 @@ impl Downloader {
let response = self
.client
.get(&download_url)
.header("User-Agent", "donutbrowser")
.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?;
@@ -247,7 +492,7 @@ mod tests {
use crate::browser_version_service::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -262,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
)
}
@@ -290,7 +534,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -338,7 +582,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -386,7 +630,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -497,7 +741,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -547,7 +791,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -570,7 +814,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS universal asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -589,7 +833,6 @@ mod tests {
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content)
@@ -640,7 +883,6 @@ mod tests {
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
@@ -691,7 +933,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -714,7 +956,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -741,7 +983,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -780,7 +1022,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/chunked-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content.clone())
+106 -4
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() {
@@ -175,6 +175,108 @@ impl DownloadedBrowsersRegistry {
}
Ok(())
}
/// Find and remove unused browser binaries that are not referenced by any active profiles
pub fn cleanup_unused_binaries(
&mut self,
active_profiles: &[(String, String)], // (browser, version) pairs
) -> 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();
// Collect all downloaded browsers that are not in active profiles
let mut to_remove = Vec::new();
for (browser, versions) in &self.browsers {
for (version, info) in versions {
// Only remove verified downloads that are not used by any active profile
if info.verified && !active_set.contains(&(browser.clone(), version.clone())) {
// Double-check that this browser+version is truly not in use
// by looking for exact matches in the active profiles
let is_in_use = active_profiles
.iter()
.any(|(active_browser, active_version)| {
active_browser == browser && active_version == version
});
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)");
}
}
}
}
// Remove unused binaries
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
eprintln!("Failed to cleanup unused binary {browser}:{version}: {e}");
} 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)
}
/// Get all browsers and versions referenced by active profiles
pub fn get_active_browser_versions(
&self,
profiles: &[crate::browser_runner::BrowserProfile],
) -> Vec<(String, String)> {
profiles
.iter()
.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)]
+1477 -156
View File
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)
}
}
+417 -67
View File
@@ -1,7 +1,7 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
// Store pending URLs that need to be handled when the window is ready
@@ -13,29 +13,34 @@ 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, create_browser_profile_new,
delete_profile, download_browser, fetch_browser_versions, fetch_browser_versions_cached_first,
fetch_browser_versions_detailed, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_cached_browser_versions_detailed,
get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers,
is_browser_downloaded, kill_browser_profile, launch_browser_profile, list_browser_profiles,
rename_profile, should_update_browser_cache, 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::{
disable_default_browser_prompt, get_app_settings, get_table_sorting_settings, save_app_settings,
save_table_sorting_settings, should_show_settings_on_startup,
clear_all_version_cache_and_refetch, get_app_settings, get_table_sorting_settings,
save_app_settings, save_table_sorting_settings, should_show_settings_on_startup,
};
use default_browser::{
@@ -43,26 +48,67 @@ use default_browser::{
};
use version_updater::{
check_version_update_needed, force_version_update_check, get_version_update_status,
get_version_updater, trigger_manual_version_update,
get_version_update_status, get_version_updater, trigger_manual_version_update,
};
use auto_updater::{
check_for_browser_updates, complete_browser_update, 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, start_browser_update,
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
is_browser_disabled_for_update,
};
use app_auto_updater::{
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
get_app_version_info,
};
#[tauri::command]
fn greet() -> String {
let now = SystemTime::now();
let epoch_ms = now.duration_since(UNIX_EPOCH).unwrap().as_millis();
format!("Hello world from Rust! Current epoch: {epoch_ms}")
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")]
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
}
impl<R: Runtime> WindowExt for WebviewWindow<R> {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String> {
use objc2::rc::Retained;
use objc2_app_kit::{NSWindow, NSWindowStyleMask, NSWindowTitleVisibility};
unsafe {
let ns_window: Retained<NSWindow> =
Retained::retain(self.ns_window().unwrap().cast()).unwrap();
if transparent {
// Hide the title text
ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden
// Make titlebar transparent
ns_window.setTitlebarAppearsTransparent(true);
// Set full size content view
let current_mask = ns_window.styleMask();
let new_mask = NSWindowStyleMask(current_mask.0 | (1 << 15)); // NSFullSizeContentViewWindowMask
ns_window.setStyleMask(new_mask);
} else {
// Show the title text
ns_window.setTitleVisibility(NSWindowTitleVisibility(0)); // NSWindowTitleVisible
// Make titlebar opaque
ns_window.setTitlebarAppearsTransparent(false);
// Remove full size content view
let current_mask = ns_window.styleMask();
let new_mask = NSWindowStyleMask(current_mask.0 & !(1 << 15));
ns_window.setStyleMask(new_mask);
}
}
Ok(())
}
}
#[tauri::command]
@@ -71,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");
@@ -97,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();
@@ -104,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()) {
@@ -124,30 +180,141 @@ 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)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(900.0, 600.0)
.resizable(false)
.fullscreen(false)
.center()
.focused(true)
.visible(true);
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
// Set transparent titlebar for macOS
#[cfg(target_os = "macos")]
{
if let Err(e) = window.set_transparent_titlebar(true) {
eprintln!("Failed to set transparent titlebar: {e}");
}
}
// 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}");
@@ -165,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 {
@@ -211,25 +403,19 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
delete_profile,
is_browser_downloaded,
check_browser_exists,
create_browser_profile_new,
create_browser_profile,
list_browser_profiles,
launch_browser_profile,
fetch_browser_versions,
fetch_browser_versions_detailed,
fetch_browser_versions_with_count,
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_cached_browser_versions_detailed,
should_update_browser_cache,
get_downloaded_browser_versions,
get_saved_mullvad_releases,
get_browser_release_types,
update_profile_proxy,
update_profile_version,
check_browser_status,
@@ -238,33 +424,197 @@ pub fn run() {
get_app_settings,
save_app_settings,
should_show_settings_on_startup,
disable_default_browser_prompt,
get_table_sorting_settings,
save_table_sorting_settings,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
set_as_default_browser,
smart_open_url,
handle_url_open,
check_and_handle_startup_url,
trigger_manual_version_update,
get_version_update_status,
check_version_update_needed,
force_version_update_check,
check_for_browser_updates,
start_browser_update,
complete_browser_update,
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_app_version_info,
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");
}
#[cfg(test)]
mod tests {
use std::fs;
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
}
#[test]
fn test_unused_tauri_commands_detailed() {
check_unused_commands(true); // Run in verbose mode for development
}
fn check_unused_commands(verbose: bool) {
// Extract command names from the generate_handler! macro in this file
let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs");
let commands = extract_tauri_commands(&lib_rs_content);
// Get all frontend files
let frontend_files = get_frontend_files("../src");
// Check which commands are actually used
let mut unused_commands = Vec::new();
let mut used_commands = Vec::new();
for command in &commands {
let mut is_used = false;
for file_content in &frontend_files {
// More comprehensive search for command usage
if is_command_used(file_content, command) {
is_used = true;
break;
}
}
if is_used {
used_commands.push(command.clone());
if verbose {
println!("{command}");
}
} else {
unused_commands.push(command.clone());
if verbose {
println!("{command} (UNUSED)");
}
}
}
if verbose {
println!("\n📊 Summary:");
println!(" ✅ Used commands: {}", used_commands.len());
println!(" ❌ Unused commands: {}", unused_commands.len());
}
if !unused_commands.is_empty() {
let message = format!(
"Found {} unused Tauri commands: {}\n\nThese commands are exported in generate_handler! but not used in the frontend.\nConsider removing them or add them to the allowlist if they're used elsewhere.\n\nRun `pnpm check-unused-commands` for detailed analysis.",
unused_commands.len(),
unused_commands.join(", ")
);
if verbose {
println!("\n🚨 {message}");
} else {
panic!("{}", message);
}
} else if verbose {
println!("\n🎉 All exported commands are being used!");
} else {
println!(
"✅ All {} exported Tauri commands are being used in the frontend",
commands.len()
);
}
}
fn is_command_used(content: &str, command: &str) -> bool {
// Check various patterns for invoke usage
let patterns = vec![
format!("invoke<{}>(\"{}\"", "", command), // invoke<Type>("command"
format!("invoke(\"{}\"", command), // invoke("command"
format!("invoke<{}>(\"{}\",", "", command), // invoke<Type>("command",
format!("invoke(\"{}\",", command), // invoke("command",
format!("\"{}\"", command), // Just the command name in quotes
];
for pattern in patterns {
if content.contains(&pattern) {
return true;
}
}
// Also check for the command name appearing after "invoke" within a reasonable distance
if let Some(invoke_pos) = content.find("invoke") {
let after_invoke = &content[invoke_pos..];
if let Some(cmd_pos) = after_invoke.find(&format!("\"{command}\"")) {
// If the command appears within 100 characters of "invoke", consider it used
if cmd_pos < 100 {
return true;
}
}
}
false
}
fn extract_tauri_commands(content: &str) -> Vec<String> {
let mut commands = Vec::new();
// Find the generate_handler! macro
if let Some(start) = content.find("tauri::generate_handler![") {
if let Some(end) = content[start..].find("])") {
let handler_content = &content[start + 25..start + end]; // Skip "tauri::generate_handler!["
// Extract command names
for line in handler_content.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with("//") {
// Remove trailing comma and whitespace
let command = line.trim_end_matches(',').trim();
if !command.is_empty() {
commands.push(command.to_string());
}
}
}
}
}
commands
}
fn get_frontend_files(src_dir: &str) -> Vec<String> {
let mut files_content = Vec::new();
if let Ok(entries) = fs::read_dir(src_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Recursively read subdirectories
let subdir_files = get_frontend_files(&path.to_string_lossy());
files_content.extend(subdir_files);
} else if let Some(extension) = path.extension() {
if matches!(
extension.to_str(),
Some("ts") | Some("tsx") | Some("js") | Some("jsx")
) {
if let Ok(content) = fs::read_to_string(&path) {
files_content.push(content);
}
}
}
}
}
files_content
}
}
+773
View File
@@ -0,0 +1,773 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use crate::browser::BrowserType;
use crate::browser_runner::BrowserRunner;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedProfile {
pub browser: String,
pub name: String,
pub path: String,
pub description: String,
}
pub struct ProfileImporter {
base_dirs: BaseDirs,
browser_runner: BrowserRunner,
}
impl ProfileImporter {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
browser_runner: BrowserRunner::new(),
}
}
/// Detect existing browser profiles on the system
pub fn detect_existing_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new();
// Detect Firefox profiles
detected_profiles.extend(self.detect_firefox_profiles()?);
// Detect Chrome profiles
detected_profiles.extend(self.detect_chrome_profiles()?);
// Detect Brave profiles
detected_profiles.extend(self.detect_brave_profiles()?);
// Detect Firefox Developer Edition profiles
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
// Detect Chromium profiles
detected_profiles.extend(self.detect_chromium_profiles()?);
// Detect Mullvad Browser profiles
detected_profiles.extend(self.detect_mullvad_browser_profiles()?);
// 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
.into_iter()
.filter(|profile| seen_paths.insert(profile.path.clone()))
.collect();
Ok(unique_profiles)
}
/// Detect Firefox profiles
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let firefox_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
#[cfg(target_os = "windows")]
{
// 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")?);
}
}
#[cfg(target_os = "linux")]
{
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
Ok(profiles)
}
/// Detect Firefox Developer Edition profiles
fn detect_firefox_developer_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
// Firefox Developer Edition on macOS uses separate profile directories
let firefox_dev_alt_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox Developer Edition/Profiles");
// Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates
if firefox_dev_alt_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
}
}
#[cfg(target_os = "windows")]
{
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")?);
}
}
#[cfg(target_os = "linux")]
{
// Firefox Developer Edition on Linux uses separate directories
let firefox_dev_dir = self
.base_dirs
.home_dir()
.join(".mozilla/firefox-dev-edition");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
}
Ok(profiles)
}
/// Detect Chrome profiles
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let chrome_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Google/Chrome");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
#[cfg(target_os = "windows")]
{
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")]
{
let chrome_dir = self.base_dirs.home_dir().join(".config/google-chrome");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
Ok(profiles)
}
/// Detect Chromium profiles
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let chromium_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Chromium");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
#[cfg(target_os = "windows")]
{
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")]
{
let chromium_dir = self.base_dirs.home_dir().join(".config/chromium");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
Ok(profiles)
}
/// Detect Brave profiles
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let brave_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/BraveSoftware/Brave-Browser");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
#[cfg(target_os = "windows")]
{
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")]
{
let brave_dir = self
.base_dirs
.home_dir()
.join(".config/BraveSoftware/Brave-Browser");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
Ok(profiles)
}
/// Detect Mullvad Browser profiles
fn detect_mullvad_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let mullvad_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/MullvadBrowser/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
}
#[cfg(target_os = "windows")]
{
// 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")?);
}
}
#[cfg(target_os = "linux")]
{
let mullvad_dir = self.base_dirs.home_dir().join(".mullvad-browser");
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
}
Ok(profiles)
}
/// Detect Zen Browser profiles
fn detect_zen_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let zen_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[cfg(target_os = "windows")]
{
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")]
{
let zen_dir = self.base_dirs.home_dir().join(".zen");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
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,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
if !profiles_dir.exists() {
return Ok(profiles);
}
// Read profiles.ini file if it exists
let profiles_ini = profiles_dir
.parent()
.unwrap_or(profiles_dir)
.join("profiles.ini");
if profiles_ini.exists() {
if let Ok(content) = fs::read_to_string(&profiles_ini) {
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
}
}
// Also scan directory for any profile folders not in profiles.ini
if let Ok(entries) = fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let prefs_file = path.join("prefs.js");
if prefs_file.exists() {
let profile_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown Profile");
// Check if this profile was already found in profiles.ini
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
if !already_added {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: format!(
"{} Profile - {}",
self.get_browser_display_name(browser_type),
profile_name
),
path: path.to_string_lossy().to_string(),
description: format!("Profile folder: {profile_name}"),
});
}
}
}
}
}
Ok(profiles)
}
/// Parse Firefox profiles.ini file
fn parse_firefox_profiles_ini(
&self,
content: &str,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
let mut current_section = String::new();
let mut profile_name = String::new();
let mut profile_path = String::new();
let mut is_relative = true;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
// Save previous profile if complete
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
// Start new section
current_section = line[1..line.len() - 1].to_string();
profile_name.clear();
profile_path.clear();
is_relative = true;
} else if line.contains('=') {
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() == 2 {
let key = parts[0].trim();
let value = parts[1].trim();
match key {
"Name" => profile_name = value.to_string(),
"Path" => profile_path = value.to_string(),
"IsRelative" => is_relative = value == "1",
_ => {}
}
}
}
}
// Handle last profile
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
Ok(profiles)
}
/// Scan Chrome-style profiles directory
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
if !browser_dir.exists() {
return Ok(profiles);
}
// Check for Default profile
let default_profile = browser_dir.join("Default");
if default_profile.exists() && default_profile.join("Preferences").exists() {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: format!(
"{} - Default Profile",
self.get_browser_display_name(browser_type)
),
path: default_profile.to_string_lossy().to_string(),
description: "Default profile".to_string(),
});
}
// Check for Profile X directories
if let Ok(entries) = fs::read_dir(browser_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
let profile_number = &dir_name[8..]; // Remove "Profile " prefix
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
name: format!(
"{} - Profile {}",
self.get_browser_display_name(browser_type),
profile_number
),
path: path.to_string_lossy().to_string(),
description: format!("Profile {profile_number}"),
});
}
}
}
}
Ok(profiles)
}
/// Get browser display name
fn get_browser_display_name(&self, browser_type: &str) -> &str {
match browser_type {
"firefox" => "Firefox",
"firefox-developer" => "Firefox Developer",
"chromium" => "Chrome/Chromium",
"brave" => "Brave",
"mullvad-browser" => "Mullvad Browser",
"zen" => "Zen Browser",
"tor-browser" => "Tor Browser",
_ => "Unknown Browser",
}
}
/// Import a profile from an existing browser profile
pub fn import_profile(
&self,
source_path: &str,
browser_type: &str,
new_profile_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Validate that source path exists
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err("Source profile path does not exist".into());
}
// Validate browser type
let _browser_type = BrowserType::from_str(browser_type)
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
// Check if a profile with this name already exists
let existing_profiles = self.browser_runner.list_profiles()?;
if existing_profiles
.iter()
.any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase())
{
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
}
// 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_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_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// 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,
proxy_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
};
// Save the profile metadata
self.browser_runner.save_profile(&profile)?;
println!(
"Successfully imported profile '{}' from '{}'",
new_profile_name,
source_path.display()
);
Ok(())
}
/// Get a default version for a browser type
fn get_default_version_for_browser(
&self,
browser_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// 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);
if let Some(version) = downloaded_versions.first() {
return Ok(version.clone());
}
// 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
fn copy_directory_recursive(
source: &Path,
destination: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
if !destination.exists() {
create_dir_all(destination)?;
}
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let dest_path = destination.join(entry.file_name());
if source_path.is_dir() {
Self::copy_directory_recursive(&source_path, &dest_path)?;
} else {
fs::copy(&source_path, &dest_path)?;
}
}
Ok(())
}
}
// Tauri commands
#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
let importer = ProfileImporter::new();
importer
.detect_existing_profiles()
.map_err(|e| format!("Failed to detect existing profiles: {e}"))
}
#[tauri::command]
pub async fn import_browser_profile(
source_path: String,
browser_type: String,
new_profile_name: String,
) -> Result<(), String> {
let importer = ProfileImporter::new();
importer
.import_profile(&source_path, &browser_type, &new_profile_name)
.map_err(|e| format!("Failed to import profile: {e}"))
}
+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(())
}
}
+28 -29
View File
@@ -3,6 +3,9 @@ use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
use crate::api_client::ApiClient;
use crate::version_updater;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
@@ -22,33 +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,
}
fn default_show_settings_on_startup() -> bool {
true
}
fn default_theme() -> String {
"system".to_string()
}
fn default_auto_updates_enabled() -> 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(),
show_settings_on_startup: true,
theme: "system".to_string(),
}
}
}
@@ -163,13 +155,6 @@ impl SettingsManager {
// 3. User hasn't explicitly disabled the default browser setting
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
}
pub fn disable_default_browser_prompt(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.show_settings_on_startup = false;
self.save_settings(&settings)?;
Ok(())
}
}
#[tauri::command]
@@ -196,14 +181,6 @@ pub async fn should_show_settings_on_startup() -> Result<bool, String> {
.map_err(|e| format!("Failed to check prompt setting: {e}"))
}
#[tauri::command]
pub async fn disable_default_browser_prompt() -> Result<(), String> {
let manager = SettingsManager::new();
manager
.disable_default_browser_prompt()
.map_err(|e| format!("Failed to disable prompt: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::new();
@@ -219,3 +196,25 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
.save_table_sorting(&sorting)
.map_err(|e| format!("Failed to save table sorting settings: {e}"))
}
#[tauri::command]
pub async fn clear_all_version_cache_and_refetch(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let api_client = ApiClient::new();
// Clear all cache first
api_client
.clear_all_cache()
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
let updater = version_updater::get_version_updater();
let updater_guard = updater.lock().await;
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!("{:+03d}:{:02d}", 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())
}
+539
View File
@@ -0,0 +1,539 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemTheme {
pub theme: String, // "light", "dark", or "unknown"
}
pub struct ThemeDetector;
impl ThemeDetector {
pub fn new() -> Self {
Self
}
/// Detect the system theme preference
pub fn detect_system_theme(&self) -> SystemTheme {
#[cfg(target_os = "linux")]
return linux::detect_system_theme();
#[cfg(target_os = "macos")]
return macos::detect_system_theme();
#[cfg(target_os = "windows")]
return windows::detect_system_theme();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
return SystemTheme {
theme: "unknown".to_string(),
};
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// Try multiple methods in order of preference
// 1. Try GNOME/GTK settings via gsettings
if let Ok(theme) = detect_gnome_theme() {
return SystemTheme { theme };
}
// 2. Try KDE Plasma settings via kreadconfig5/kreadconfig6
if let Ok(theme) = detect_kde_theme() {
return SystemTheme { theme };
}
// 3. Try XFCE settings via xfconf-query
if let Ok(theme) = detect_xfce_theme() {
return SystemTheme { theme };
}
// 4. Try looking at current GTK theme name
if let Ok(theme) = detect_gtk_theme() {
return SystemTheme { theme };
}
// 5. Try dconf directly (fallback for GNOME-based systems)
if let Ok(theme) = detect_dconf_theme() {
return SystemTheme { theme };
}
// 6. Try environment variables
if let Ok(theme) = detect_env_theme() {
return SystemTheme { theme };
}
// 7. Try freedesktop portal
if let Ok(theme) = detect_portal_theme() {
return SystemTheme { theme };
}
// 8. Try looking at system color scheme files
if let Ok(theme) = detect_system_files_theme() {
return SystemTheme { theme };
}
// Fallback to unknown
SystemTheme {
theme: "unknown".to_string(),
}
}
fn detect_gnome_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check if gsettings is available
if !is_command_available("gsettings") {
return Err("gsettings not available".into());
}
// Try GNOME color scheme first (modern way)
if let Ok(output) = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
match scheme.as_str() {
"'prefer-dark'" => return Ok("dark".to_string()),
"'prefer-light'" => return Ok("light".to_string()),
_ => {}
}
}
}
// Fallback to GTK theme name detection
if let Ok(output) = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
.output()
{
if output.status.success() {
let theme_name = String::from_utf8_lossy(&output.stdout)
.trim()
.trim_matches('\'')
.to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
Err("Could not detect GNOME theme".into())
}
fn detect_kde_theme() -> Result<String, Box<dyn std::error::Error>> {
// Try KDE Plasma 6 first
if is_command_available("kreadconfig6") {
if let Ok(output) = Command::new("kreadconfig6")
.args([
"--file",
"kdeglobals",
"--group",
"KDE",
"--key",
"LookAndFeelPackage",
])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("breezedark") {
return Ok("dark".to_string());
} else if theme.contains("light") || theme.contains("breeze") {
return Ok("light".to_string());
}
}
}
// Try color scheme as well
if let Ok(output) = Command::new("kreadconfig6")
.args([
"--file",
"kdeglobals",
"--group",
"General",
"--key",
"ColorScheme",
])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if scheme.contains("dark") || scheme.contains("breezedark") {
return Ok("dark".to_string());
} else if scheme.contains("light") || scheme.contains("breeze") {
return Ok("light".to_string());
}
}
}
}
// Try KDE Plasma 5 as fallback
if is_command_available("kreadconfig5") {
if let Ok(output) = Command::new("kreadconfig5")
.args([
"--file",
"kdeglobals",
"--group",
"KDE",
"--key",
"LookAndFeelPackage",
])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("breezedark") {
return Ok("dark".to_string());
} else if theme.contains("light") || theme.contains("breeze") {
return Ok("light".to_string());
}
}
}
}
Err("Could not detect KDE theme".into())
}
fn detect_xfce_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("xfconf-query") {
return Err("xfconf-query not available".into());
}
// Check XFCE theme
if let Ok(output) = Command::new("xfconf-query")
.args(["-c", "xsettings", "-p", "/Net/ThemeName"])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("night") {
return Ok("dark".to_string());
} else if theme.contains("light") {
return Ok("light".to_string());
}
}
}
// Check XFCE window manager theme as backup
if let Ok(output) = Command::new("xfconf-query")
.args(["-c", "xfwm4", "-p", "/general/theme"])
.output()
{
if output.status.success() {
let theme = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if theme.contains("dark") || theme.contains("night") {
return Ok("dark".to_string());
} else if theme.contains("light") {
return Ok("light".to_string());
}
}
}
Err("Could not detect XFCE theme".into())
}
fn detect_gtk_theme() -> Result<String, Box<dyn std::error::Error>> {
// Try to read GTK3 settings file
if let Ok(home) = std::env::var("HOME") {
let gtk3_settings = std::path::Path::new(&home).join(".config/gtk-3.0/settings.ini");
if gtk3_settings.exists() {
if let Ok(content) = std::fs::read_to_string(gtk3_settings) {
for line in content.lines() {
if line.starts_with("gtk-theme-name=") {
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
}
}
// Try GTK4 settings
let gtk4_settings = std::path::Path::new(&home).join(".config/gtk-4.0/settings.ini");
if gtk4_settings.exists() {
if let Ok(content) = std::fs::read_to_string(gtk4_settings) {
for line in content.lines() {
if line.starts_with("gtk-theme-name=") {
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
}
}
}
Err("Could not detect GTK theme".into())
}
fn detect_dconf_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("dconf") {
return Err("dconf not available".into());
}
// Try reading color scheme directly from dconf
if let Ok(output) = Command::new("dconf")
.args(["read", "/org/gnome/desktop/interface/color-scheme"])
.output()
{
if output.status.success() {
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
match scheme.as_str() {
"'prefer-dark'" => return Ok("dark".to_string()),
"'prefer-light'" => return Ok("light".to_string()),
_ => {}
}
}
}
// Try reading GTK theme from dconf
if let Ok(output) = Command::new("dconf")
.args(["read", "/org/gnome/desktop/interface/gtk-theme"])
.output()
{
if output.status.success() {
let theme_name = String::from_utf8_lossy(&output.stdout)
.trim()
.trim_matches('\'')
.to_lowercase();
if theme_name.contains("dark") || theme_name.contains("night") {
return Ok("dark".to_string());
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
return Ok("light".to_string());
}
}
}
Err("Could not detect dconf theme".into())
}
fn detect_env_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check common environment variables
if let Ok(theme) = std::env::var("GTK_THEME") {
let theme_lower = theme.to_lowercase();
if theme_lower.contains("dark") || theme_lower.contains("night") {
return Ok("dark".to_string());
} else if theme_lower.contains("light") {
return Ok("light".to_string());
}
}
if let Ok(theme) = std::env::var("QT_STYLE_OVERRIDE") {
let theme_lower = theme.to_lowercase();
if theme_lower.contains("dark") || theme_lower.contains("night") {
return Ok("dark".to_string());
} else if theme_lower.contains("light") {
return Ok("light".to_string());
}
}
Err("Could not detect theme from environment".into())
}
fn detect_portal_theme() -> Result<String, Box<dyn std::error::Error>> {
if !is_command_available("busctl") {
return Err("busctl not available".into());
}
// Try to query the color scheme via org.freedesktop.portal.Settings
if let Ok(output) = Command::new("busctl")
.args([
"--user",
"call",
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"Read",
"ss",
"org.freedesktop.appearance",
"color-scheme",
])
.output()
{
if output.status.success() {
let response = String::from_utf8_lossy(&output.stdout);
// Parse DBus response - look for preference values
if response.contains(" 1 ") {
return Ok("dark".to_string());
} else if response.contains(" 2 ") {
return Ok("light".to_string());
}
}
}
Err("Could not detect portal theme".into())
}
fn detect_system_files_theme() -> Result<String, Box<dyn std::error::Error>> {
// Check if we're in a dark terminal (heuristic)
if let Ok(term) = std::env::var("TERM") {
let term_lower = term.to_lowercase();
if term_lower.contains("dark") || term_lower.contains("night") {
return Ok("dark".to_string());
}
}
// Check if we can determine from desktop session
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop_lower = desktop.to_lowercase();
// Some desktops default to dark
if desktop_lower.contains("i3") || desktop_lower.contains("sway") {
// Window managers often use dark themes by default
return Ok("dark".to_string());
}
}
Err("Could not detect theme from system files".into())
}
fn is_command_available(command: &str) -> bool {
Command::new("which")
.arg(command)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// macOS theme detection using osascript
if let Ok(output) = Command::new("osascript")
.args([
"-e",
"tell application \"System Events\" to tell appearance preferences to get dark mode",
])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout).to_string();
let result = result.trim();
match result {
"true" => {
return SystemTheme {
theme: "dark".to_string(),
}
}
"false" => {
return SystemTheme {
theme: "light".to_string(),
}
}
_ => {}
}
}
}
// Fallback method using defaults
if let Ok(output) = Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
{
if output.status.success() {
let style = String::from_utf8_lossy(&output.stdout).to_string();
let style = style.trim();
if style.to_lowercase() == "dark" {
return SystemTheme {
theme: "dark".to_string(),
};
}
}
}
// Default to light if we can't determine
SystemTheme {
theme: "light".to_string(),
}
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn detect_system_theme() -> SystemTheme {
// Windows theme detection via registry
// This is a simplified implementation - you might want to use winreg crate for better registry access
if let Ok(output) = Command::new("reg")
.args([
"query",
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
"/v",
"AppsUseLightTheme",
])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout);
if result.contains("0x0") {
return SystemTheme {
theme: "dark".to_string(),
};
} else if result.contains("0x1") {
return SystemTheme {
theme: "light".to_string(),
};
}
}
}
// Default to light if we can't determine
SystemTheme {
theme: "light".to_string(),
}
}
}
// Command to expose this functionality to the frontend
#[tauri::command]
pub fn get_system_theme() -> SystemTheme {
let detector = ThemeDetector::new();
detector.detect_system_theme()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_theme_detector_creation() {
let detector = ThemeDetector::new();
let theme = detector.detect_system_theme();
// Should return a valid theme string
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
}
#[test]
fn test_get_system_theme_command() {
let theme = get_system_theme();
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
}
}
+115 -124
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)
}
@@ -448,22 +455,6 @@ pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
Ok((last_update, time_until_next))
}
#[tauri::command]
pub async fn check_version_update_needed() -> Result<bool, String> {
Ok(VersionUpdater::should_run_background_update())
}
#[tauri::command]
pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result<bool, String> {
let updater = get_version_updater();
let updater_guard = updater.lock().await;
match updater_guard.check_and_run_startup_update().await {
Ok(_) => Ok(true),
Err(e) => Err(format!("Failed to run version update check: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
+25 -12
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.2.4",
"version": "0.7.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -10,15 +10,7 @@
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Donut Browser",
"width": 900,
"height": 600,
"resizable": false,
"fullscreen": false
}
],
"windows": [],
"security": {
"csp": null
}
@@ -26,6 +18,7 @@
"bundle": {
"active": true,
"targets": "all",
"category": "Productivity",
"externalBin": ["binaries/nodecar"],
"icon": [
"icons/32x32.png",
@@ -45,12 +38,32 @@
"files": {
"Info.plist": "Info.plist"
}
},
"linux": {
"deb": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
},
"rpm": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
},
"appimage": {
"files": {
"usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
}
}
},
"plugins": {
"deep-link": {
"schemes": ["http", "https"],
"domains": []
"desktop": {
"schemes": ["http", "https"]
}
}
}
}
+3 -1
View File
@@ -4,6 +4,7 @@ import "@/styles/globals.css";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WindowDragArea } from "@/components/window-drag-area";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -23,11 +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>
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
<WindowDragArea />
</CustomThemeProvider>
</body>
</html>
+399 -114
View File
@@ -1,26 +1,43 @@
"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";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { 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 { GoGear, GoPlus } from "react-icons/go";
import { sleep } from "@/lib/utils";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -29,7 +46,8 @@ type BrowserTypeString =
| "chromium"
| "brave"
| "zen"
| "tor-browser";
| "tor-browser"
| "camoufox";
interface PendingUrl {
id: string;
@@ -43,11 +61,79 @@ export default function Home() {
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
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 () => {
@@ -56,13 +142,44 @@ export default function Home() {
"list_browser_profiles",
);
setProfiles(profileList);
// 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;
@@ -74,42 +191,44 @@ export default function Home() {
);
setProfiles(profileList);
// 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);
}
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();
const checkStartupPrompt = useCallback(async () => {
// Only check once during app startup to prevent reopening after dismissing notifications
if (hasCheckedStartupPrompt) return;
// 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 () => {
try {
const shouldShow = await invoke<boolean>(
"should_show_settings_on_startup",
@@ -117,12 +236,50 @@ export default function Home() {
if (shouldShow) {
setSettingsDialogOpen(true);
}
setHasCheckedStartupPrompt(true);
} catch (error) {
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",
@@ -133,9 +290,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) => {
@@ -163,27 +320,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);
@@ -195,8 +332,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);
@@ -204,16 +365,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(
@@ -221,29 +384,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: ${
@@ -253,7 +415,7 @@ export default function Home() {
throw error;
}
},
[loadProfiles],
[loadProfiles, triggerProxyDataReload],
);
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
@@ -325,40 +487,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],
@@ -393,29 +554,116 @@ 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)]">
<main className="flex flex-col gap-8 row-start-2 items-center w-full max-w-3xl">
<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>
<div className="flex items-center justify-between">
<div className="flex justify-between items-center">
<CardTitle>Profiles</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex gap-2 items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSettingsDialogOpen(true);
}}
className="flex items-center gap-2"
>
<GoGear className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<GoGear className="mr-2 w-4 h-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProxyManagementDialogOpen(true);
}}
>
<FiWifi className="mr-2 w-4 h-4" />
Proxies
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setImportProfileDialogOpen(true);
}}
>
<FaDownload className="mr-2 w-4 h-4" />
Import Profile
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -423,9 +671,9 @@ export default function Home() {
onClick={() => {
setCreateProfileDialogOpen(true);
}}
className="flex items-center gap-2"
className="flex gap-2 items-center"
>
<GoPlus className="h-4 w-4" />
<GoPlus className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
@@ -433,7 +681,7 @@ export default function Home() {
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<CardContent>
<ProfilesDataTable
data={profiles}
onLaunchProfile={launchProfile}
@@ -442,8 +690,12 @@ export default function Home() {
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onChangeVersion={openChangeVersionDialog}
onConfigureCamoufox={handleConfigureCamoufox}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onReloadProxyData={
proxyDataReloadTrigger > 0 ? triggerProxyDataReload : undefined
}
/>
</CardContent>
</Card>
@@ -454,8 +706,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}
/>
@@ -483,6 +735,21 @@ export default function Home() {
onVersionChanged={() => void loadProfiles()}
/>
<ImportProfileDialog
isOpen={importProfileDialogOpen}
onClose={() => {
setImportProfileDialogOpen(false);
}}
onImportComplete={() => void loadProfiles()}
/>
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
@@ -496,6 +763,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-[350px]">
<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>
+377 -294
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,17 +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 type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -38,7 +35,8 @@ type BrowserTypeString =
| "chromium"
| "brave"
| "zen"
| "tor-browser";
| "tor-browser"
| "camoufox";
interface CreateProfileDialogProps {
isOpen: boolean;
@@ -47,171 +45,220 @@ 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 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 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 [supportedBrowsers, setSupportedBrowsers] = useState<
BrowserTypeString[]
>([]);
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();
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
void loadExistingProfiles();
}
}, [isOpen]);
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected version when browser changes
setSelectedVersion(null);
void loadVersions(selectedBrowser);
void loadDownloadedVersions(selectedBrowser);
}
}, [isOpen, selectedBrowser, loadVersions, loadDownloadedVersions]);
// 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_alpha);
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 loadSupportedBrowsers = async () => {
const loadSupportedBrowsers = useCallback(async () => {
try {
const browsers = await invoke<BrowserTypeString[]>(
"get_supported_browsers",
);
const browsers = await invoke<string[]>("get_supported_browsers");
setSupportedBrowsers(browsers);
if (browsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (browsers.length > 0) {
setSelectedBrowser(browsers[0]);
}
} catch (error) {
console.error("Failed to load supported browsers:", error);
}
};
}, []);
const loadExistingProfiles = async () => {
const loadStoredProxies = useCallback(async () => {
try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
setExistingProfiles(profiles);
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxies);
} catch (error) {
console.error("Failed to load existing profiles:", error);
console.error("Failed to load stored proxies:", error);
}
};
}, []);
const handleDownload = async () => {
if (!selectedBrowser || !selectedVersion) return;
await downloadBrowser(selectedBrowser, selectedVersion);
};
const loadReleaseTypes = useCallback(
async (browser: string) => {
try {
const releaseTypes = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{ browserStr: browser },
);
const validateProfileName = (name: string): string | null => {
const trimmedName = name.trim();
if (browser === "camoufox") {
setCamoufoxReleaseTypes(releaseTypes);
} else {
setAvailableReleaseTypes(releaseTypes);
}
if (!trimmedName) {
return "Profile name cannot be empty";
}
// Load downloaded versions for this browser
await loadDownloadedVersions(browser);
} catch (error) {
console.error(`Failed to load release types for ${browser}:`, error);
}
},
[loadDownloadedVersions],
);
// 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
// Load data when dialog opens
useEffect(() => {
if (selectedBrowser === "tor-browser" && proxyEnabled) {
setProxyEnabled(false);
if (isOpen) {
void loadSupportedBrowsers();
void loadStoredProxies();
// Load camoufox release types when dialog opens
void loadReleaseTypes("camoufox");
}
}, [selectedBrowser, proxyEnabled]);
}, [isOpen, loadSupportedBrowsers, loadStoredProxies, loadReleaseTypes]);
const handleCreate = async () => {
if (!profileName.trim() || !selectedBrowser || !selectedVersion) return;
// Load release types when browser selection changes
useEffect(() => {
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
}
}, [selectedBrowser, loadReleaseTypes]);
// Validate profile name
const nameError = validateProfileName(profileName);
if (nameError) {
toast.error(nameError);
const handleDownload = async (browserStr: string) => {
const releaseTypes =
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
const latestStableVersion = releaseTypes.stable;
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 {
@@ -219,182 +266,218 @@ 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);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select browser" />
</SelectTrigger>
<SelectContent>
{supportedBrowsers.map((browser) => (
<SelectItem key={browser} value={browser}>
{browser
.split("-")
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1),
)
.join(" ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ScrollArea className="flex-1 pr-6 h-[350px]">
<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"
/>
</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>
<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>
{/* 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">
{/* 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>
);
+518
View File
@@ -0,0 +1,518 @@
"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 {
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 { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
onImportComplete?: () => void;
}
export function ImportProfileDialog({
isOpen,
onClose,
onImportComplete,
}: ImportProfileDialogProps) {
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [importMode, setImportMode] = useState<"auto-detect" | "manual">(
"auto-detect",
);
// Auto-detect state
const [selectedDetectedProfile, setSelectedDetectedProfile] = useState<
string | null
>(null);
const [autoDetectProfileName, setAutoDetectProfileName] = useState("");
// Manual import state
const [manualBrowserType, setManualBrowserType] = useState<string | null>(
null,
);
const [manualProfilePath, setManualProfilePath] = useState("");
const [manualProfileName, setManualProfileName] = useState("");
const { supportedBrowsers, isLoading: isLoadingSupport } =
useBrowserSupport();
const loadDetectedProfiles = useCallback(async () => {
setIsLoading(true);
try {
const profiles = await invoke<DetectedProfile[]>(
"detect_existing_profiles",
);
setDetectedProfiles(profiles);
// Auto-switch to manual mode if no profiles detected
if (profiles.length === 0) {
setImportMode("manual");
} else {
// Auto-select first profile if available
setSelectedDetectedProfile(profiles[0].path);
// Generate default name from the detected profile
const profile = profiles[0];
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
setAutoDetectProfileName(defaultName);
}
} catch (error) {
console.error("Failed to detect existing profiles:", error);
toast.error("Failed to detect existing browser profiles");
} finally {
setIsLoading(false);
}
}, []);
const handleBrowseFolder = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: "Select Browser Profile Folder",
});
if (selected && typeof selected === "string") {
setManualProfilePath(selected);
}
} catch (error) {
console.error("Failed to open folder dialog:", error);
toast.error("Failed to open folder dialog");
}
};
const handleAutoDetectImport = useCallback(async () => {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: profile.path,
browserType: profile.browser,
newProfileName: autoDetectProfileName.trim(),
});
toast.success(
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
);
if (onImportComplete) {
onImportComplete();
}
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// 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 = useCallback(async () => {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: manualProfilePath.trim(),
browserType: manualBrowserType,
newProfileName: manualProfileName.trim(),
});
toast.success(
`Successfully imported profile "${manualProfileName.trim()}"`,
);
if (onImportComplete) {
onImportComplete();
}
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// 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);
setAutoDetectProfileName("");
setManualBrowserType(null);
setManualProfilePath("");
setManualProfileName("");
// Only reset to auto-detect if there are profiles available
if (detectedProfiles.length > 0) {
setImportMode("auto-detect");
} else {
setImportMode("manual");
}
onClose();
};
// Update auto-detect profile name when selection changes
useEffect(() => {
if (selectedDetectedProfile) {
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (profile) {
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
setAutoDetectProfileName(defaultName);
}
}
}, [selectedDetectedProfile, detectedProfiles]);
const selectedProfile = detectedProfiles.find(
(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">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Import Browser Profile</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{/* Mode Selection */}
<div className="flex gap-2">
<Button
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</Button>
<Button
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</Button>
</div>
{/* Auto-Detect Mode */}
{importMode === "auto-detect" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Detected Browser Profiles</h3>
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in custom
locations.
</p>
</div>
) : (
<div className="space-y-4">
<div>
<Label htmlFor="detected-profile-select" className="mb-2">
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(profile.browser);
return (
<SelectItem key={profile.path} value={profile.path}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
<span className="text-xs text-muted-foreground">
{profile.description}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
</p>
</div>
)}
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
)}
</div>
)}
{/* Manual Import Mode */}
{importMode === "manual" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{supportedBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
{importMode === "auto-detect" ? (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleAutoDetectImport();
}}
disabled={
!selectedDetectedProfile ||
!autoDetectProfileName.trim() ||
isLoading
}
>
Import Profile
</LoadingButton>
) : (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleManualImport();
}}
disabled={
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
}
>
Import Profile
</LoadingButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+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>
);
}
+196 -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,
@@ -33,19 +46,7 @@ import {
} 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";
import type { BrowserProfile, StoredProxy } from "@/types";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -57,8 +58,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 +72,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 +89,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 +222,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 +264,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 +303,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 +319,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 +345,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 +379,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 +426,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 +447,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 +506,10 @@ export function ProfilesDataTable({
onKillProfile,
onProxySettings,
onChangeVersion,
onConfigureCamoufox,
getProxyInfo,
hasProxy,
getProxyDisplayName,
],
);
@@ -445,7 +526,7 @@ export function ProfilesDataTable({
return (
<>
<div className="rounded-md border">
<ScrollArea className="h-[400px] rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -494,7 +575,7 @@ export function ProfilesDataTable({
)}
</TableBody>
</Table>
</div>
</ScrollArea>
<Dialog
open={profileToRename !== null}
@@ -511,7 +592,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 +654,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>
);
}
+287 -62
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,15 +23,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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;
}
interface PermissionInfo {
permission_type: PermissionType;
isGranted: boolean;
description: string;
}
interface SettingsDialogProps {
@@ -40,39 +49,68 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: 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");
@@ -83,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");
@@ -104,13 +177,49 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
} finally {
setIsSettingDefault(false);
}
};
}, [checkDefaultBrowserStatus]);
const handleSave = 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.",
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 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 });
// Apply theme change immediately
setTheme(settings.theme);
setOriginalSettings(settings);
onClose();
@@ -119,18 +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.theme !== originalSettings.theme;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -139,7 +302,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-4 overflow-y-auto flex-1 min-h-0">
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
{/* Appearance Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Appearance</Label>
@@ -172,7 +335,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
{/* Default Browser Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex justify-between items-center">
<Label className="text-base font-medium">Default Browser</Label>
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
{isDefaultBrowser ? "Active" : "Inactive"}
@@ -182,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"}
@@ -199,29 +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>
<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.
</p>
</div>
{/* Startup Behavior Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Startup Behavior</Label>
@@ -244,6 +384,91 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
starts.
</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>
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
handleClearCache().catch(console.error);
}}
variant="outline"
className="w-full"
>
Clear All Version Cache
</LoadingButton>
<p className="text-xs text-muted-foreground">
Clear all cached browser version data and refresh all browser
versions from their sources. This will force a fresh download of
version information for all browsers.
</p>
</div>
</div>
<DialogFooter className="flex-shrink-0">
@@ -253,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>
);
}
+70 -3
View File
@@ -9,6 +9,10 @@ interface AppSettings {
theme: string;
}
interface SystemTheme {
theme: string;
}
interface CustomThemeProviderProps {
children: React.ReactNode;
}
@@ -24,6 +28,25 @@ function getSystemTheme(): string {
return "light";
}
// Function to get native system theme (fallback to CSS media query)
async function getNativeSystemTheme(): Promise<string> {
try {
const systemTheme = await invoke<SystemTheme>("get_system_theme");
if (systemTheme.theme === "dark" || systemTheme.theme === "light") {
return systemTheme.theme;
}
// Fallback to CSS media query if native detection returns "unknown"
return getSystemTheme();
} catch (error) {
console.warn(
"Failed to get native system theme, falling back to CSS media query:",
error,
);
// Fallback to CSS media query
return getSystemTheme();
}
}
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
@@ -41,7 +64,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
} catch (error) {
console.error("Failed to load theme settings:", error);
// For first-time users, detect system preference and apply it
const systemTheme = getSystemTheme();
const systemTheme = await getNativeSystemTheme();
console.log(
"First-time user detected, applying system theme:",
systemTheme,
@@ -69,6 +92,49 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
void loadTheme();
}, []);
// Monitor system theme changes when using "system" theme
useEffect(() => {
if (!mounted || defaultTheme !== "system") {
return;
}
const checkSystemTheme = async () => {
try {
const currentSystemTheme = await getNativeSystemTheme();
// Force re-evaluation by toggling the theme
const html = document.documentElement;
// Apply the system theme class
if (currentSystemTheme === "dark") {
if (!html.classList.contains("dark")) {
html.classList.add("dark");
html.classList.remove("light");
}
} else {
if (
!html.classList.contains("light") ||
html.classList.contains("dark")
) {
html.classList.add("light");
html.classList.remove("dark");
}
}
} catch (error) {
console.warn("Failed to check system theme:", error);
}
};
// Check system theme every 2 seconds when using system theme
const intervalId = setInterval(() => void checkSystemTheme(), 2000);
// Initial check
void checkSystemTheme();
return () => {
clearInterval(intervalId);
};
}, [mounted, defaultTheme]);
if (isLoading) {
// Use a consistent loading screen that doesn't depend on system theme during SSR
// This prevents hydration mismatch by ensuring server and client render the same initially
@@ -77,6 +143,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
// Only apply system theme detection after component is mounted (client-side only)
if (mounted) {
// Use CSS media query for loading screen since async call would complicate this
const systemTheme = getSystemTheme();
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
spinnerColor =
@@ -85,10 +152,10 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
return (
<div
className={`fixed inset-0 ${loadingBgColor} flex items-center justify-center`}
className={`flex fixed inset-0 justify-center items-center ${loadingBgColor}`}
>
<div
className={`animate-spin rounded-full h-8 w-8 border-2 ${spinnerColor} border-t-transparent`}
className={`w-8 h-8 rounded-full border-2 animate-spin ${spinnerColor} border-t-transparent`}
/>
</div>
);
+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";

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