Compare commits

..

177 Commits

Author SHA1 Message Date
zhom a720f914b0 chore: improve greetings workflow for the first-time contributors 2025-11-22 15:53:53 +04:00
zhom b899af0983 chore: use personal token for automerge 2025-11-22 15:53:53 +04:00
zhom 43277a9579 chore: linting 2025-11-22 15:53:53 +04:00
zhom f4a36996db refactor: fix deprecation warning 2025-11-22 15:53:53 +04:00
zhom 15e8a1029a chore: rename "Donut Browser" to "Donut" 2025-11-22 15:53:53 +04:00
zhom 43b9f405ca chore: conditionally generate suggestions 2025-11-22 15:53:53 +04:00
zhom f9a527637f chore: update dependencies 2025-11-22 15:53:53 +04:00
zhom be0d3053e7 Merge pull request #139 from zhom/dependabot/github_actions/github-actions-3ad8007637
ci(deps): bump the github-actions group with 4 updates
2025-11-22 15:52:38 +04:00
dependabot[bot] 070e40ffe0 ci(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action), [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action) and [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action).


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

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

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

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

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.138
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

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


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

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

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

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

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


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

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

Updates `crate-ci/typos` from 1.38.1 to 1.39.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/80c8a4945eec0f6d464eaf9e65ed98ef085283d1...07d900b8fa1097806b8adb6391b0d3e0ac2fdea7)

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

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


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

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

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

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


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

Updates `crate-ci/typos` from 1.37.2 to 1.38.1
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/7436548694def3314aacd93ed06c721b1f91ea04...80c8a4945eec0f6d464eaf9e65ed98ef085283d1)

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

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

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


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

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

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

Updates `crate-ci/typos` from 1.36.3 to 1.37.2
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/0c17dabcee8b8f1957fa917d17393a23e02e1583...7436548694def3314aacd93ed06c721b1f91ea04)

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

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

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


Updates `crate-ci/typos` from 1.36.2 to 1.36.3
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/85f62a8a84f939ae994ab3763f01a0296d61a7ee...0c17dabcee8b8f1957fa917d17393a23e02e1583)

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

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


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

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

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

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


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

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

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

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


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

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

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

Updates `zip` from 4.5.0 to 5.0.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v4.5.0...v5.0.0)

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

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

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

Updates `cc` from 1.2.34 to 1.2.36
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.34...cc-v1.2.36)

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

Updates `crate-ci/typos` from 1.35.7 to 1.36.2
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/65f69f021b736bdbe548ce72200500752d42b40e...85f62a8a84f939ae994ab3763f01a0296d61a7ee)

Updates `actions/stale` from 9.1.0 to 10.0.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/5bef64f19d7facfb25b37b414482c7164d639639...3a9db7e6a41a89f618792c92c0e97cc736e1b13f)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-06 09:05:57 +00:00
zhom ba36956158 chore: next-env autogen 2025-09-03 22:23:40 +04:00
zhom ce3e27ca64 chore: version bump 2025-09-03 22:05:42 +04:00
zhom fd0fb8c7ca refactor: mark all firefox developers edition releases as nightly 2025-09-03 22:02:48 +04:00
zhom 701c8aefd3 fix: properly update profile after downloading broweser 2025-09-03 20:49:26 +04:00
zhom d4a7c347b6 chore: next-env autogen 2025-09-03 19:57:51 +04:00
zhom 3c3e6df3b2 chore: ignore ts files inside tauri output 2025-09-03 19:44:42 +04:00
zhom cd4b23bd27 refactor: simplify browser runner 2025-09-03 19:40:17 +04:00
zhom 042a348971 refactor: better binary management 2025-09-02 23:41:17 +04:00
zhom b8f4e4adda build: switch back to ubuntu 22.04 runner 2025-09-02 19:26:54 +04:00
zhom e8852a3caf build: switch to ubuntu 18 for linux build 2025-09-02 18:44:21 +04:00
zhom 6ed1adafc8 chore: version bump 2025-09-02 18:21:40 +04:00
zhom 22e6b2762e fix: search for correct folder on chromium extraction on linux x64 2025-09-02 18:18:27 +04:00
zhom bc7c8d1a1e refactor: better profile creation flow 2025-09-01 14:40:55 +04:00
zhom b133f928d4 Merge pull request #79 from zhom/dependabot/github_actions/github-actions-9dffe6f043
ci(deps): bump the github-actions group with 4 updates
2025-08-30 16:22:28 +04:00
zhom 02185e0480 Merge pull request #80 from zhom/dependabot/npm_and_yarn/frontend-dependencies-4db6c81266
deps(deps): bump the frontend-dependencies group with 46 updates
2025-08-30 16:22:15 +04:00
zhom 6402ff302a Merge pull request #81 from zhom/dependabot/cargo/src-tauri/rust-dependencies-84e3784149
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 5 updates
2025-08-30 16:21:59 +04:00
dependabot[bot] ed830ed789 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [async-executor](https://github.com/smol-rs/async-executor) | `1.13.2` | `1.13.3` |
| [camino](https://github.com/camino-rs/camino) | `1.1.11` | `1.1.12` |
| [liblzma](https://github.com/portable-network-archive/liblzma-rs) | `0.4.3` | `0.4.4` |
| [potential_utf](https://github.com/unicode-org/icu4x) | `0.1.2` | `0.1.3` |
| [wry](https://github.com/tauri-apps/wry) | `0.53.2` | `0.53.3` |


Updates `async-executor` from 1.13.2 to 1.13.3
- [Release notes](https://github.com/smol-rs/async-executor/releases)
- [Changelog](https://github.com/smol-rs/async-executor/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/async-executor/compare/v1.13.2...v1.13.3)

Updates `camino` from 1.1.11 to 1.1.12
- [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.11...camino-1.1.12)

Updates `liblzma` 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-0.4.3...liblzma-0.4.4)

Updates `potential_utf` from 0.1.2 to 0.1.3
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits/ind/potential_utf@0.1.3)

Updates `wry` from 0.53.2 to 0.53.3
- [Release notes](https://github.com/tauri-apps/wry/releases)
- [Changelog](https://github.com/tauri-apps/wry/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/wry/compare/wry-v0.53.2...wry-v0.53.3)

---
updated-dependencies:
- dependency-name: async-executor
  dependency-version: 1.13.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: camino
  dependency-version: 1.1.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: liblzma
  dependency-version: 0.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: potential_utf
  dependency-version: 0.1.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: wry
  dependency-version: 0.53.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-30 09:41:48 +00:00
dependabot[bot] d03f598567 deps(deps): bump the frontend-dependencies group with 46 updates
Bumps the frontend-dependencies group with 46 updates:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.541.0` | `0.542.0` |
| [next](https://github.com/vercel/next.js) | `15.5.1` | `15.5.2` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.1.11` | `19.1.12` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.1.8` | `19.1.9` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `5.0.1` | `5.0.2` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.2.0` | `2.2.2` |
| [@emnapi/runtime](https://github.com/toyobayashi/emnapi) | `1.4.5` | `1.5.0` |
| [@next/env](https://github.com/vercel/next.js/tree/HEAD/packages/next-env) | `15.5.1` | `15.5.2` |
| [@next/swc-darwin-arm64](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/darwin-arm64) | `15.5.1` | `15.5.2` |
| [@next/swc-darwin-x64](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/darwin-x64) | `15.5.1` | `15.5.2` |
| [@next/swc-linux-arm64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-arm64-gnu) | `15.5.1` | `15.5.2` |
| [@next/swc-linux-arm64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-arm64-musl) | `15.5.1` | `15.5.2` |
| [@next/swc-linux-x64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-x64-gnu) | `15.5.1` | `15.5.2` |
| [@next/swc-linux-x64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-x64-musl) | `15.5.1` | `15.5.2` |
| [@next/swc-win32-arm64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/win32-arm64-msvc) | `15.5.1` | `15.5.2` |
| [@next/swc-win32-x64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/win32-x64-msvc) | `15.5.1` | `15.5.2` |
| [@rolldown/pluginutils](https://github.com/rolldown/rolldown/tree/HEAD/packages/pluginutils) | `1.0.0-beta.32` | `1.0.0-beta.34` |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-loongarch64-gnu](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-ppc64-gnu](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |
| [rollup](https://github.com/rollup/rollup) | `4.48.1` | `4.49.0` |


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

Updates `next` from 15.5.1 to 15.5.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.1...v15.5.2)

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

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

Updates `@types/react-dom` from 19.1.8 to 19.1.9
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

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

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

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

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

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

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

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

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

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

Updates `@emnapi/runtime` from 1.4.5 to 1.5.0
- [Release notes](https://github.com/toyobayashi/emnapi/releases)
- [Commits](https://github.com/toyobayashi/emnapi/compare/v1.4.5...v1.5.0)

Updates `@next/env` from 15.5.1 to 15.5.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.2/packages/next-env)

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

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

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

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

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

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

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

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

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

Updates `@rollup/rollup-android-arm-eabi` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-android-arm64` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-darwin-arm64` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-darwin-x64` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-freebsd-arm64` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-freebsd-x64` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-arm64-musl` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-loongarch64-gnu` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-ppc64-gnu` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-x64-gnu` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-linux-x64-musl` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `@rollup/rollup-win32-x64-msvc` from 4.48.1 to 4.49.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.48.1...v4.49.0)

Updates `rollup` from 4.48.1 to 4.49.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.48.1...v4.49.0)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.542.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: next
  dependency-version: 15.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@emnapi/runtime"
  dependency-version: 1.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/env"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 15.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rolldown/pluginutils"
  dependency-version: 1.0.0-beta.34
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-gnu"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-30 09:23:10 +00:00
dependabot[bot] 6aedf58264 ci(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [google/osv-scanner-action](https://github.com/google/osv-scanner-action), [actions/ai-inference](https://github.com/actions/ai-inference), [tauri-apps/tauri-action](https://github.com/tauri-apps/tauri-action) and [crate-ci/typos](https://github.com/crate-ci/typos).


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

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

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

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

---
updated-dependencies:
- dependency-name: google/osv-scanner-action
  dependency-version: 2.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/ai-inference
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: tauri-apps/tauri-action
  dependency-version: 0.5.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.35.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-30 09:04:30 +00:00
zhom 636f1ea4ba chore: disable changelog update 2025-08-27 19:21:46 +04:00
zhom adb253e103 chore: version bump 2025-08-27 15:28:21 +04:00
zhom e12ac66c7a chore: next-env gen 2025-08-27 15:28:07 +04:00
zhom e06a824438 refactor: handle pending browser updates 2025-08-27 15:23:49 +04:00
zhom 4293b7eab5 chore: next-env gen 2025-08-27 15:13:58 +04:00
zhom 68b138d5ff chore: update dependencies 2025-08-27 07:47:38 +04:00
zhom b79bd94506 docs: readme 2025-08-24 22:24:48 +04:00
zhom 181c76980a feat: add profile search 2025-08-24 21:49:08 +04:00
zhom 274b275c03 chore: linting 2025-08-23 15:13:32 +04:00
zhom 821cce0986 chore: update biome config 2025-08-23 14:56:04 +04:00
zhom 716a028923 chore: pnpm update 2025-08-23 14:55:29 +04:00
zhom 7c25bd3ba2 Merge pull request #78 from zhom/dependabot/cargo/src-tauri/rust-dependencies-c76d1c372d
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 28 updates
2025-08-23 14:45:46 +04:00
zhom 6d89098263 Merge pull request #77 from zhom/dependabot/npm_and_yarn/frontend-dependencies-b8479b5523
deps(deps): bump the frontend-dependencies group with 64 updates
2025-08-23 14:45:35 +04:00
zhom a1a1a2202e Merge pull request #76 from zhom/dependabot/github_actions/github-actions-7fdfea5e97
ci(deps): bump the github-actions group with 7 updates
2025-08-23 14:45:25 +04:00
zhom 485daae40e chore: formatting 2025-08-23 14:02:49 +04:00
dependabot[bot] 9f22c57b7a deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.142` | `1.0.143` |
| [tauri-plugin-fs](https://github.com/tauri-apps/plugins-workspace) | `2.4.1` | `2.4.2` |
| [tauri-plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.1` | `2.4.2` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.3.2` | `2.3.3` |
| [zip](https://github.com/zip-rs/zip2) | `4.3.0` | `4.5.0` |
| [url](https://github.com/servo/rust-url) | `2.5.4` | `2.5.6` |
| [tempfile](https://github.com/Stebalien/tempfile) | `3.20.0` | `3.21.0` |
| [hyper](https://github.com/hyperium/hyper) | `1.6.0` | `1.7.0` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.3.1` | `2.4.0` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.33` | `1.2.34` |
| [cfg-if](https://github.com/rust-lang/cfg-if) | `1.0.1` | `1.0.3` |
| [dlopen2](https://github.com/OpenByteDev/dlopen2) | `0.7.0` | `0.8.0` |
| [filetime](https://github.com/alexcrichton/filetime) | `0.2.25` | `0.2.26` |
| [form_urlencoded](https://github.com/servo/rust-url) | `1.2.1` | `1.2.2` |
| [idna](https://github.com/servo/rust-url) | `1.0.3` | `1.1.0` |
| [io-uring](https://github.com/tokio-rs/io-uring) | `0.7.9` | `0.7.10` |
| [percent-encoding](https://github.com/servo/rust-url) | `2.3.1` | `2.3.2` |
| [serialize-to-javascript](https://github.com/chippers/serialize-to-javascript) | `0.1.1` | `0.1.2` |
| [serialize-to-javascript-impl](https://github.com/chippers/serialize-to-javascript) | `0.1.1` | `0.1.2` |
| [tao](https://github.com/tauri-apps/tao) | `0.34.0` | `0.34.2` |
| [tauri-codegen](https://github.com/tauri-apps/tauri) | `2.3.1` | `2.4.0` |
| [tauri-macros](https://github.com/tauri-apps/tauri) | `2.3.2` | `2.4.0` |
| [tauri-plugin](https://github.com/tauri-apps/tauri) | `2.3.1` | `2.4.0` |
| [tauri-runtime](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.0` |
| [tauri-runtime-wry](https://github.com/tauri-apps/tauri) | `2.7.2` | `2.8.0` |
| [tauri-utils](https://github.com/tauri-apps/tauri) | `2.6.0` | `2.7.0` |
| [winapi-util](https://github.com/BurntSushi/winapi-util) | `0.1.9` | `0.1.10` |
| [wry](https://github.com/tauri-apps/wry) | `0.52.1` | `0.53.1` |


Updates `serde_json` from 1.0.142 to 1.0.143
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.142...v1.0.143)

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

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

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

Updates `zip` from 4.3.0 to 4.5.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v4.3.0...v4.5.0)

Updates `url` from 2.5.4 to 2.5.6
- [Release notes](https://github.com/servo/rust-url/releases)
- [Commits](https://github.com/servo/rust-url/commits)

Updates `tempfile` from 3.20.0 to 3.21.0
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/commits)

Updates `hyper` from 1.6.0 to 1.7.0
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.6.0...v1.7.0)

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

Updates `cc` from 1.2.33 to 1.2.34
- [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.33...cc-v1.2.34)

Updates `cfg-if` from 1.0.1 to 1.0.3
- [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/v1.0.1...v1.0.3)

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

Updates `filetime` from 0.2.25 to 0.2.26
- [Commits](https://github.com/alexcrichton/filetime/commits)

Updates `form_urlencoded` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/servo/rust-url/releases)
- [Commits](https://github.com/servo/rust-url/compare/v1.2.1...v1.2.2)

Updates `idna` from 1.0.3 to 1.1.0
- [Release notes](https://github.com/servo/rust-url/releases)
- [Commits](https://github.com/servo/rust-url/commits)

Updates `io-uring` from 0.7.9 to 0.7.10
- [Commits](https://github.com/tokio-rs/io-uring/commits)

Updates `percent-encoding` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/servo/rust-url/releases)
- [Commits](https://github.com/servo/rust-url/commits)

Updates `serialize-to-javascript` from 0.1.1 to 0.1.2
- [Commits](https://github.com/chippers/serialize-to-javascript/commits)

Updates `serialize-to-javascript-impl` from 0.1.1 to 0.1.2
- [Commits](https://github.com/chippers/serialize-to-javascript/commits)

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

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

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

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

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

Updates `tauri-runtime-wry` from 2.7.2 to 2.8.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-wry-v2.7.2...tauri-runtime-wry-v2.8.0)

Updates `tauri-utils` from 2.6.0 to 2.7.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-utils-v2.6.0...tauri-utils-v2.7.0)

Updates `winapi-util` from 0.1.9 to 0.1.10
- [Commits](https://github.com/BurntSushi/winapi-util/compare/0.1.9...0.1.10)

Updates `wry` from 0.52.1 to 0.53.1
- [Release notes](https://github.com/tauri-apps/wry/releases)
- [Changelog](https://github.com/tauri-apps/wry/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/wry/compare/wry-v0.52.1...wry-v0.53.1)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.143
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-fs
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-deep-link
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: url
  dependency-version: 2.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tempfile
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hyper
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.34
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cfg-if
  dependency-version: 1.0.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: dlopen2
  dependency-version: 0.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: filetime
  dependency-version: 0.2.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: form_urlencoded
  dependency-version: 1.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: idna
  dependency-version: 1.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: io-uring
  dependency-version: 0.7.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: percent-encoding
  dependency-version: 2.3.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serialize-to-javascript
  dependency-version: 0.1.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serialize-to-javascript-impl
  dependency-version: 0.1.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tao
  dependency-version: 0.34.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-codegen
  dependency-version: 2.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-macros
  dependency-version: 2.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin
  dependency-version: 2.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime
  dependency-version: 2.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime-wry
  dependency-version: 2.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-utils
  dependency-version: 2.7.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: winapi-util
  dependency-version: 0.1.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: wry
  dependency-version: 0.53.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-23 09:56:23 +00:00
dependabot[bot] 45d959e407 deps(deps): bump the frontend-dependencies group with 64 updates
Bumps the frontend-dependencies group with 64 updates:

| Package | From | To |
| --- | --- | --- |
| [@tauri-apps/api](https://github.com/tauri-apps/tauri) | `2.7.0` | `2.8.0` |
| [@tauri-apps/plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.1` | `2.4.2` |
| [@tauri-apps/plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.3.2` | `2.3.3` |
| [@tauri-apps/plugin-fs](https://github.com/tauri-apps/plugins-workspace) | `2.4.1` | `2.4.2` |
| [@tauri-apps/plugin-opener](https://github.com/tauri-apps/plugins-workspace) | `2.4.0` | `2.5.0` |
| [ahooks](https://github.com/alibaba/hooks) | `3.9.0` | `3.9.4` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.539.0` | `0.541.0` |
| [next](https://github.com/vercel/next.js) | `15.4.6` | `15.5.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@tauri-apps/cli](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.1.10` | `19.1.11` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `5.0.0` | `5.0.1` |
| [playwright-core](https://github.com/microsoft/playwright) | `1.54.2` | `1.55.0` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.1.4` | `2.2.0` |
| [@next/env](https://github.com/vercel/next.js/tree/HEAD/packages/next-env) | `15.4.6` | `15.5.0` |
| [@next/swc-darwin-arm64](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/darwin-arm64) | `15.4.6` | `15.5.0` |
| [@next/swc-darwin-x64](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/darwin-x64) | `15.4.6` | `15.5.0` |
| [@next/swc-linux-arm64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-arm64-gnu) | `15.4.6` | `15.5.0` |
| [@next/swc-linux-arm64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-arm64-musl) | `15.4.6` | `15.5.0` |
| [@next/swc-linux-x64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-x64-gnu) | `15.4.6` | `15.5.0` |
| [@next/swc-linux-x64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/linux-x64-musl) | `15.4.6` | `15.5.0` |
| [@next/swc-win32-arm64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/win32-arm64-msvc) | `15.4.6` | `15.5.0` |
| [@next/swc-win32-x64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/napi/npm/win32-x64-msvc) | `15.4.6` | `15.5.0` |
| [@rolldown/pluginutils](https://github.com/rolldown/rolldown/tree/HEAD/packages/pluginutils) | `1.0.0-beta.30` | `1.0.0-beta.32` |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-loongarch64-gnu](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-ppc64-gnu](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |
| [@tauri-apps/cli-darwin-arm64](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-darwin-x64](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-linux-arm-gnueabihf](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-linux-arm64-gnu](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-linux-arm64-musl](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-linux-riscv64-gnu](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-linux-x64-gnu](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-linux-x64-musl](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-win32-arm64-msvc](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-win32-ia32-msvc](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [@tauri-apps/cli-win32-x64-msvc](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.8.1` |
| [caniuse-lite](https://github.com/browserslist/caniuse-lite) | `1.0.30001735` | `1.0.30001737` |
| [rollup](https://github.com/rollup/rollup) | `4.46.2` | `4.48.0` |


Updates `@tauri-apps/api` from 2.7.0 to 2.8.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/api-v2.7.0...@tauri-apps/api-v2.8.0)

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

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

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

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

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

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

Updates `next` from 15.4.6 to 15.5.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.4.6...v15.5.0)

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

Updates `@tauri-apps/cli` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v2.7.1...@tauri-apps/cli-v2.8.1)

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

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

Updates `playwright-core` from 1.54.2 to 1.55.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.54.2...v1.55.0)

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

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

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

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

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

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

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

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

Updates `@next/env` from 15.4.6 to 15.5.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.0/packages/next-env)

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

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

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

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

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

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

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

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

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

Updates `@rollup/rollup-android-arm-eabi` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-android-arm64` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-darwin-arm64` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-darwin-x64` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-freebsd-arm64` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-freebsd-x64` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-arm64-musl` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-loongarch64-gnu` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-ppc64-gnu` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-x64-gnu` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-linux-x64-musl` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@rollup/rollup-win32-x64-msvc` from 4.46.2 to 4.48.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.46.2...v4.48.0)

Updates `@tauri-apps/cli-darwin-arm64` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-darwin-x64` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-linux-arm-gnueabihf` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-linux-arm64-gnu` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-linux-arm64-musl` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-linux-riscv64-gnu` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-linux-x64-gnu` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-linux-x64-musl` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-win32-arm64-msvc` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-win32-ia32-msvc` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

Updates `@tauri-apps/cli-win32-x64-msvc` from 2.7.1 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-cli-v2.7.1...tauri-v2.8.1)

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

Updates `rollup` from 4.46.2 to 4.48.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.46.2...v4.48.0)

---
updated-dependencies:
- dependency-name: "@tauri-apps/api"
  dependency-version: 2.8.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.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-dialog"
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-fs"
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/plugin-opener"
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: ahooks
  dependency-version: 3.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: lucide-react
  dependency-version: 0.541.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: next
  dependency-version: 15.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli"
  dependency-version: 2.8.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: playwright-core
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/env"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 15.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rolldown/pluginutils"
  dependency-version: 1.0.0-beta.32
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-gnu"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-arm64"
  dependency-version: 2.8.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-x64"
  dependency-version: 2.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: caniuse-lite
  dependency-version: 1.0.30001737
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-23 09:43:13 +00:00
dependabot[bot] d75a367f39 ci(deps): bump the github-actions group with 7 updates
Bumps the github-actions group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4.2.2` | `5.0.0` |
| [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) | `b3b07ba8b418998c39fb20f53e8b695cdcc8de1b` | `e97e2d8cc328f1b50210efc529dca0028893a2d9` |
| [google/osv-scanner-action](https://github.com/google/osv-scanner-action) | `2.1.0` | `2.2.1` |
| [ridedott/merge-me-action](https://github.com/ridedott/merge-me-action) | `2.10.124` | `2.10.126` |
| [actions/first-interaction](https://github.com/actions/first-interaction) | `2.0.0` | `3.0.0` |
| [actions/ai-inference](https://github.com/actions/ai-inference) | `1.2.8` | `2.0.0` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.35.3` | `1.35.5` |


Updates `actions/checkout` from 4.2.2 to 5.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/11bd71901bbe5b1630ceea73d27597364c9af683...08c6903cd8c0fde910a37f88322edcfb5dd907a8)

Updates `dtolnay/rust-toolchain` from b3b07ba8b418998c39fb20f53e8b695cdcc8de1b to e97e2d8cc328f1b50210efc529dca0028893a2d9
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/b3b07ba8b418998c39fb20f53e8b695cdcc8de1b...e97e2d8cc328f1b50210efc529dca0028893a2d9)

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

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

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

Updates `actions/ai-inference` from 1.2.8 to 2.0.0
- [Release notes](https://github.com/actions/ai-inference/releases)
- [Commits](https://github.com/actions/ai-inference/compare/b81b2afb8390ee6839b494a404766bef6493c7d9...f347eae8ebabecb85d17f52960f909b8a4a8dad5)

Updates `crate-ci/typos` from 1.35.3 to 1.35.5
- [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/52bd719c2c91f9d676e2aa359fc8e0db8925e6d8...a4c3e43aea0a9e9b9e6578d2731ebd9a27e8f6cd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: dtolnay/rust-toolchain
  dependency-version: e97e2d8cc328f1b50210efc529dca0028893a2d9
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action
  dependency-version: 2.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: ridedott/merge-me-action
  dependency-version: 2.10.126
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/first-interaction
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/ai-inference
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-23 09:20:24 +00:00
zhom a48eb5d631 refactor: cleanup empty browser folders 2025-08-21 09:13:30 +04:00
zhom 0d79f385bd chore: cleanup 2025-08-19 14:01:24 +04:00
zhom 25bb1dccdc chore: version bump 2025-08-19 13:57:58 +04:00
zhom 97044d58fe feat: proxy sorting 2025-08-19 13:55:40 +04:00
zhom 4748a31714 style: don't overflow 2025-08-19 13:50:24 +04:00
zhom d91c97dd85 chore: formatting 2025-08-19 13:47:19 +04:00
zhom 8e299fddd4 feat: docs inside ui 2025-08-19 13:45:19 +04:00
zhom 6c3c9fb58a chore: comment 2025-08-19 13:38:38 +04:00
zhom f5066e866b fix: pass id instead of profile name to open_url_with_profile 2025-08-19 13:38:29 +04:00
zhom e12a5661b1 refactor: use ids instead of names for all profile operations 2025-08-19 13:31:46 +04:00
zhom f8a4ec3277 refactor: require auth for local api 2025-08-19 13:31:28 +04:00
zhom 1e5664e3b2 feat: launch browsers via api and expose them to selenium 2025-08-19 09:49:39 +04:00
zhom d0fea2fec1 style: scroll area adjustments 2025-08-19 09:48:46 +04:00
zhom ce0627030d style: move geolocation and locale fields above webgl 2025-08-18 18:26:05 +04:00
zhom d70ec16706 refacto: use backend events for ui update 2025-08-18 18:10:37 +04:00
zhom 5863d5549e chore: version bump 2025-08-18 17:47:07 +04:00
zhom 4df35515ae chore: formatting 2025-08-18 17:46:52 +04:00
zhom 59f430ec43 refactor: make ui reactive for proxy changes 2025-08-18 17:37:42 +04:00
zhom 9f68a21824 refactor: make ui reactive for group changes 2025-08-18 17:37:22 +04:00
zhom 9bf7f39c0c docs: agents 2025-08-18 17:01:10 +04:00
zhom d1b45778c4 style: don't trim 'Not Selected' 2025-08-18 16:58:38 +04:00
zhom 6d6527d812 refactor: update ui on profile events 2025-08-18 16:54:36 +04:00
zhom c30df278fb chore: formatting 2025-08-17 13:34:11 +04:00
zhom 95592b4aa1 fix: pass proper argument to profile rename 2025-08-17 13:30:02 +04:00
zhom 58b0067b37 fix: pass correct props to the backend 2025-08-17 13:22:34 +04:00
zhom 6260d78901 style: adjust column width 2025-08-17 13:01:47 +04:00
zhom efab286dad chore: version bump 2025-08-17 12:48:22 +04:00
zhom e51e31911b chore: pnpm update 2025-08-17 12:47:13 +04:00
zhom 348a727da7 chore: formatting 2025-08-17 12:45:58 +04:00
zhom 10f8061acf refactor: better browser process handling 2025-08-17 12:44:20 +04:00
zhom 69348a101e chore: fix check-unused-commands command 2025-08-17 11:31:47 +04:00
zhom f7e116f345 refactor: reduce table re-renders 2025-08-17 11:31:24 +04:00
zhom 2e6bb2498b chore: remove console logs in production 2025-08-16 19:26:18 +04:00
zhom 178f07bec7 chore: pnpm update 2025-08-16 19:02:34 +04:00
zhom c6caf0633e style: init ui block spinner 2025-08-16 13:59:58 +04:00
zhom 29fe20af09 refactor: move profile filtering outside table component 2025-08-16 12:33:54 +04:00
zhom 1cb8e7236d style: highlight non-editing tags state 2025-08-16 12:05:59 +04:00
zhom ab256cd695 style: copy 2025-08-16 11:57:36 +04:00
zhom 96c42ae55e refactor: better error handling for failed downloads 2025-08-16 11:50:34 +04:00
zhom c98e12900f feat: add local api support 2025-08-16 11:42:15 +04:00
zhom 7a0d14642a refactor: better profile creation handling and navigation 2025-08-15 23:31:13 +04:00
zhom a1f153f4fa refactor: show cursor pointer on badge removal button 2025-08-15 19:39:31 +04:00
zhom ff9ad0a5ad refactor: prevent layout shift on tags mode change 2025-08-15 19:37:36 +04:00
zhom 3b78fea62a style: make the tag list more compact 2025-08-15 19:29:06 +04:00
zhom 74e1642aa2 refactor: attempt to rerender less 2025-08-15 19:26:53 +04:00
zhom c9d37519f7 style: make name cell same size in both fields 2025-08-15 19:21:22 +04:00
zhom da9e1d1b58 refactor: show tooltip on tags hover 2025-08-15 19:17:07 +04:00
zhom 77f93cc122 refactor: cleanup tags ui 2025-08-15 19:10:16 +04:00
zhom f7ccca0075 style: cleanup 2025-08-15 18:57:13 +04:00
zhom d7c2f08133 fix: show absolutely positioned items in the table 2025-08-15 18:48:47 +04:00
zhom 8dffd86ab9 refactor: allow window drag during ui block 2025-08-15 18:11:44 +04:00
zhom f3b3207489 refactor: default theme for custom is light 2025-08-15 18:09:55 +04:00
zhom d7a787586d refactor: custom theme cleanup 2025-08-15 18:08:33 +04:00
zhom 4a98eedba0 refactor: reduce table re-renders 2025-08-15 10:33:22 +04:00
zhom 95ee807f3b refactor: better theme initialization 2025-08-15 10:08:15 +04:00
zhom fac99f4a51 refactor: tags 2025-08-15 00:55:10 +04:00
zhom 88cb154fca refactor: color picker 2025-08-15 00:54:57 +04:00
zhom a6af568d9e feat: custom theme 2025-08-15 00:04:31 +04:00
zhom 7c2ed1e0fc refactor: tags 2025-08-15 00:04:10 +04:00
zhom 334f894e68 chore: version bump 2025-08-14 23:06:17 +04:00
zhom a77b733a31 chore: version bump 2025-08-14 22:39:16 +04:00
zhom c10c3b0f95 refactor: creation modal cleanup 2025-08-14 22:37:03 +04:00
zhom 4b16341401 refactor: integrage rename of profile into row 2025-08-14 22:35:44 +04:00
zhom 016d423d2c refactor: revert global listener 2025-08-14 22:01:14 +04:00
zhom 0596cc4009 style: fix toast width 2025-08-14 21:46:36 +04:00
zhom 269db678b7 chore: version bump 2025-08-13 16:29:42 +04:00
zhom f809b975f3 refactor: better ui handling for proxy changes 2025-08-13 16:28:08 +04:00
zhom e369214715 refactor: do not try to reuse old proxy port 2025-08-13 16:13:47 +04:00
zhom 5f93841bb7 style: copy 2025-08-13 15:28:05 +04:00
zhom 1d71729c9e style: copy 2025-08-13 11:20:03 +04:00
zhom a14da3d2f0 refactor: better proxy handling 2025-08-13 11:19:31 +04:00
zhom 59c69c44a1 chore: version bump 2025-08-13 10:55:05 +04:00
zhom 025523d0d3 style: warmup ui 2025-08-13 10:49:53 +04:00
zhom 76d17df281 style: warmup 2025-08-13 10:22:04 +04:00
zhom 727fa51a64 refactor: increase warmup timeout 2025-08-13 10:21:15 +04:00
zhom 80305ef903 refactor: try to supress all firefox update and default browser dialogs 2025-08-13 10:13:15 +04:00
zhom 4d98606f28 chore: linting 2025-08-13 10:10:44 +04:00
zhom c2d083a10d fix: properly warmup nodecar 2025-08-13 10:03:53 +04:00
zhom 6d1d15d366 refactor: block the ui while nodecar is initializing 2025-08-13 09:54:30 +04:00
zhom 2b2c855679 refactor: always point to local proxy 2025-08-13 09:53:31 +04:00
zhom e80043167f test: group manager linting 2025-08-13 09:51:09 +04:00
zhom 2ee3a90e25 refactor: only delete browser archive on successful browser download 2025-08-13 09:44:38 +04:00
zhom 231ac3f26c chore: remove dead code 2025-08-13 09:43:59 +04:00
zhom 41c02c539f refactor allow overriding groups data dir 2025-08-13 09:38:09 +04:00
zhom ec78787079 chore: linting 2025-08-13 09:35:29 +04:00
zhom 7fc6f985dd chore: disable doctest 2025-08-13 09:34:20 +04:00
zhom 5814f00f3d style: toast colors 2025-08-13 09:33:56 +04:00
zhom 621a2dd0a1 refactor: update proxy ui 2025-08-13 09:32:45 +04:00
zhom 3564762872 chore: switch to pnpm 10.14.0 2025-08-13 09:26:39 +04:00
zhom b12d3af3bd style: icons have monotonic color 2025-08-13 09:24:05 +04:00
zhom 32e70a5943 test: prevent concurrency issues 2025-08-13 09:15:46 +04:00
zhom 8b8ba31cce chore: cleanup 2025-08-13 09:08:57 +04:00
zhom 201e0270c7 refactor: display group even if it has no items 2025-08-13 09:07:46 +04:00
zhom ceb2eec80e refactor: don't close dialog on drag 2025-08-13 09:03:24 +04:00
zhom f2b3b2cc69 refactor: better process handling on macos 2025-08-13 08:48:27 +04:00
zhom 8ac077d81b chore: formatting 2025-08-13 08:35:58 +04:00
zhom dab5ab5805 style: copy 2025-08-13 08:35:22 +04:00
zhom 83a7c0e394 refactor: better profile deletion handling 2025-08-13 08:26:24 +04:00
zhom f622c77a3e chore: update entitlements 2025-08-12 19:05:04 +04:00
91 changed files with 13315 additions and 8013 deletions
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
+7 -5
View File
@@ -31,19 +31,21 @@ jobs:
# build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
with:
toolchain: stable
targets: x86_64-unknown-linux-gnu
@@ -55,7 +57,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
with:
workdir: ./src-tauri
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.actor == 'dependabot[bot]' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
with:
scan-args: |-
-r
@@ -73,9 +73,9 @@ jobs:
compat-lookup: true
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge minor and patch updates
uses: ridedott/merge-me-action@f96a67511b4be051e77760230e6a3fb9cb7b1903 #v2.10.124
uses: ridedott/merge-me-action@18dd4f01d259faf0a2d900a56cd6b7e765009209 #v2.10.138
with:
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MERGE_METHOD: SQUASH
PRESET: DEPENDABOT_MINOR
MAXIMUM_RETRIES: 5
+3 -5
View File
@@ -3,8 +3,6 @@ name: Greetings
on:
pull_request:
types: [opened]
issues:
types: [opened]
jobs:
greeting:
@@ -13,7 +11,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@2d4393e6bc0e2efb2e48fba7e06819c3bf61ffc9 # v2.0.0
- uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
with:
issue-message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
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."
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."
+69 -30
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- name: Get issue templates
id: get-templates
@@ -49,7 +49,7 @@ jobs:
- name: Validate issue with AI
id: validate
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
with:
prompt-file: issue_analysis.txt
system-prompt: |
@@ -93,6 +93,25 @@ jobs:
Be constructive and helpful in your feedback. If the issue is incomplete, provide specific guidance on what's needed.
model: openai/gpt-4o
- name: Check if first-time contributor
id: check-first-time
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
run: |
# Check if user has created issues before (excluding the current one)
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues" \
--jq "map(select(.user.login == \"$ISSUE_AUTHOR\" and .number != ${{ github.event.issue.number }})) | length" \
--paginate || echo "0")
if [ "$ISSUE_COUNT" = "0" ]; then
echo "is_first_time=true" >> $GITHUB_OUTPUT
echo "✅ First-time contributor detected"
else
echo "is_first_time=false" >> $GITHUB_OUTPUT
echo "️ Returning contributor"
fi
- name: Parse validation result and take action
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -120,41 +139,53 @@ jobs:
echo "Issue validation result: $IS_VALID"
echo "Issue type: $ISSUE_TYPE"
# Prepare greeting message for first-time contributors
IS_FIRST_TIME="${{ steps.check-first-time.outputs.is_first_time }}"
GREETING_SECTION=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING_SECTION="## 👋 Welcome!
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.
---"
fi
if [ "$IS_VALID" = "false" ]; then
# Create a comment asking for more information
cat > comment.md << EOF
## 🤖 Issue Validation
$GREETING_SECTION
## 🤖 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.
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\`
**Issue Type Detected:** \`$ISSUE_TYPE\`
**Assessment:** $ASSESSMENT
**Assessment:** $ASSESSMENT
### 📋 Missing Information:
$MISSING_INFO
### 📋 Missing Information:
$MISSING_INFO
### 💡 Suggestions for Improvement:
$SUGGESTIONS
### 💡 Suggestions for Improvement:
$SUGGESTIONS
### 📝 How to Provide Additional Information:
### 📝 How to Provide Additional Information:
Please edit your original issue description to include the missing information. Here are our issue templates for reference:
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)
- **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
### 🔧 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.
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
---
*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
@@ -167,18 +198,26 @@ jobs:
echo "✅ Issue contains sufficient information"
# Prepare a summary comment even when valid
# Build suggestions section conditionally
SUGGESTIONS_SECTION=""
if [ -n "$SUGGESTIONS" ]; then
SUGGESTIONS_SECTION="### 💡 Suggestions:
$SUGGESTIONS
"
fi
cat > comment.md << EOF
## 🤖 Issue Validation
$GREETING_SECTION
## 🤖 Issue Validation
**Issue Type Detected:** \`$ISSUE_TYPE\`
**Issue Type Detected:** \`$ISSUE_TYPE\`
**Assessment:** $ASSESSMENT
**Assessment:** $ASSESSMENT
$( [ -n "$SUGGESTIONS" ] && echo "### 💡 Suggestions:" && echo "$SUGGESTIONS" )
---
*This validation was performed automatically to help triage issues.*
EOF
$SUGGESTIONS_SECTION---
*This validation was performed automatically to help triage issues.*
EOF
# Post the summary comment
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
+5 -3
View File
@@ -34,13 +34,15 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
with:
node-version-file: .node-version
cache: "pnpm"
+10 -8
View File
@@ -31,7 +31,7 @@ jobs:
build:
strategy:
matrix:
os: [macos-latest, ubuntu-latest]
os: [macos-latest, ubuntu-22.04]
runs-on: ${{ matrix.os }}
@@ -41,19 +41,21 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
with:
toolchain: stable
components: rustfmt, clippy
@@ -65,7 +67,7 @@ jobs:
run: cargo install banderole
- name: Install dependencies (Ubuntu only)
if: matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
@@ -77,7 +79,7 @@ jobs:
shell: bash
working-directory: ./nodecar
run: |
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
if [[ "${{ matrix.os }}" == "ubuntu-22.04" ]]; then
pnpm run build:linux-x64
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
pnpm run build:mac-aarch64
@@ -94,7 +96,7 @@ jobs:
shell: bash
run: |
mkdir -p src-tauri/binaries
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
if [[ "${{ matrix.os }}" == "ubuntu-22.04" ]]; then
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-aarch64-apple-darwin
+2 -2
View File
@@ -50,7 +50,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
with:
scan-args: |-
-r
@@ -63,7 +63,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
with:
scan-args: |-
-r
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
with:
scan-args: |-
-r
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with:
fetch-depth: 0 # Fetch full history to compare with previous release
@@ -58,7 +58,7 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
with:
prompt-file: commits.txt
system-prompt: |
+19 -16
View File
@@ -13,7 +13,7 @@ env:
jobs:
security-scan:
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
with:
scan-args: |-
-r
@@ -105,18 +105,21 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
@@ -128,7 +131,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
with:
workdir: ./src-tauri
@@ -162,7 +165,7 @@ jobs:
run: pnpm build
- name: Build Tauri app
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
uses: tauri-apps/tauri-action@19b93bb55601e3e373a93cfb6eb4242e45f5af20 #v0.6.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REF_NAME: ${{ github.ref_name }}
@@ -174,8 +177,8 @@ jobs:
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]"
# - 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]"
+14 -11
View File
@@ -12,7 +12,7 @@ env:
jobs:
security-scan:
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
with:
scan-args: |-
-r
@@ -104,18 +104,21 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
@@ -127,7 +130,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
with:
workdir: ./src-tauri
@@ -170,7 +173,7 @@ jobs:
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
- name: Build Tauri app
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
uses: tauri-apps/tauri-action@19b93bb55601e3e373a93cfb6eb4242e45f5af20 #v0.6.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- name: Spell Check Repo
uses: crate-ci/typos@52bd719c2c91f9d676e2aa359fc8e0db8925e6d8 #v1.35.3
uses: crate-ci/typos@626c4bedb751ce0b7f03262ca97ddda9a076ae1c #v1.39.2
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.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."
+16
View File
@@ -1,5 +1,7 @@
{
"cSpell.words": [
"ABORTIFHUNG",
"aboutwelcome",
"adwaita",
"ahooks",
"akhilmhdh",
@@ -20,6 +22,8 @@
"CFURL",
"checkin",
"chrono",
"ciphertext",
"cksum",
"CLICOLOR",
"clippy",
"cmdk",
@@ -31,6 +35,7 @@
"dataclasses",
"datareporting",
"datas",
"DBAPI",
"dconf",
"devedition",
"distro",
@@ -38,6 +43,7 @@
"doesn",
"domcontentloaded",
"donutbrowser",
"doorhanger",
"dpkg",
"dtolnay",
"dyld",
@@ -66,6 +72,7 @@
"kdeglobals",
"keras",
"KHTML",
"killall",
"Kolkata",
"kreadconfig",
"launchservices",
@@ -80,11 +87,14 @@
"libwebkit",
"libxdo",
"localtime",
"lpdw",
"lxml",
"lzma",
"Matchalk",
"mmdb",
"mountpoint",
"msiexec",
"mstone",
"msvc",
"msys",
"Mullvad",
@@ -110,6 +120,8 @@
"peerconnection",
"pids",
"pixbuf",
"pkexec",
"pkill",
"plasmohq",
"platformdirs",
"prefs",
@@ -126,10 +138,13 @@
"ridedott",
"rlib",
"rustc",
"rwxr",
"SARIF",
"scipy",
"screeninfo",
"selectables",
"serde",
"SETTINGCHANGE",
"setuptools",
"shadcn",
"showcursor",
@@ -137,6 +152,7 @@
"signon",
"signum",
"sklearn",
"SMTO",
"sonner",
"splitn",
"sspi",
+1
View File
@@ -6,3 +6,4 @@
- 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.
- Anytime you change nodecar's code and try to test, recompile it with "cd nodecar && pnpm build".
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
- If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
+1 -1
View File
@@ -35,7 +35,7 @@
- Create unlimited number of local browser profiles completely isolated from each other
- Safely use multiple accounts on one device by using anti-detect browser profiles, powered by [Camoufox](https://camoufox.com)
- Proxy support with basic auth for all browsers except for TOR Browser
- Proxy support with basic auth for all browsers
- Import profiles from your existing browsers
- Automatic updates for browsers
- Set Donut Browser as your default browser to control in which profile to open links
+4 -4
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
@@ -18,11 +18,11 @@
"rules": {
"recommended": true,
"correctness": {
"useUniqueElementIds": "off",
"useHookAtTopLevel": "error"
},
"nursery": {
"useUniqueElementIds": "off"
},
"nursery": "off",
"suspicious": "off",
"a11y": {
"useSemanticElements": "off"
}
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./dist/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3
View File
@@ -7,6 +7,9 @@ const nextConfig: NextConfig = {
unoptimized: true,
},
distDir: "dist",
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
};
export default nextConfig;
+8 -8
View File
@@ -21,18 +21,18 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.2.1",
"commander": "^14.0.0",
"donutbrowser-camoufox-js": "^0.6.6",
"dotenv": "^17.2.1",
"fingerprint-generator": "^2.1.69",
"@types/node": "^24.10.0",
"commander": "^14.0.2",
"donutbrowser-camoufox-js": "^0.7.0",
"dotenv": "^17.2.3",
"fingerprint-generator": "^2.1.76",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"playwright-core": "^1.54.2",
"nodemon": "^3.1.11",
"playwright-core": "^1.56.1",
"proxy-chain": "^2.5.9",
"tmp": "^0.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
"typescript": "^5.9.3"
},
"devDependencies": {
"@types/tmp": "^0.2.6"
+2
View File
@@ -431,6 +431,8 @@ export async function generateCamoufoxConfig(
}
}
launchOpts.allowAddonNewTab = true;
// Generate the configuration using launchOptions
const generatedOptions = await launchOptions(launchOpts);
+124 -1
View File
@@ -1,18 +1,40 @@
import fs from "node:fs";
import path from "node:path";
import { launchOptions } from "donutbrowser-camoufox-js";
import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js";
import { type Browser, type BrowserContext, firefox } from "playwright-core";
import tmp from "tmp";
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
import { getEnvVars, parseProxyString } from "./utils.js";
// Set up debug logging to a file
const LOG_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox-logs");
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
function debugLog(id: string, message: string, data?: any): void {
const logFile = path.join(LOG_DIR, `${id}.log`);
const timestamp = new Date().toISOString();
const logMessage = data
? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
: `[${timestamp}] ${message}\n`;
fs.appendFileSync(logFile, logMessage);
}
/**
* Run a Camoufox browser server as a worker process
* @param id The Camoufox configuration ID
*/
export async function runCamoufoxWorker(id: string): Promise<void> {
debugLog(id, "Worker starting", { pid: process.pid });
// Get the Camoufox configuration
debugLog(id, "Loading Camoufox configuration");
const config = getCamoufoxConfig(id);
if (!config) {
debugLog(id, "Configuration not found");
console.error(
JSON.stringify({
error: "Configuration not found",
@@ -22,6 +44,13 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
process.exit(1);
}
debugLog(id, "Configuration loaded successfully", {
profilePath: config.profilePath,
hasOptions: !!config.options,
hasCustomConfig: !!config.customConfig,
hasUrl: !!config.url,
});
config.processId = process.pid;
saveCamoufoxConfig(config);
@@ -37,12 +66,14 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Launch browser in background - this can take time and may fail
setImmediate(async () => {
debugLog(id, "Starting browser launch in background");
let browser: Browser | null = null;
let context: BrowserContext | null = null;
let windowCheckInterval: NodeJS.Timeout | null = null;
// Graceful shutdown handler with access to browser and server
const gracefulShutdown = async () => {
debugLog(id, "Graceful shutdown initiated");
try {
// Clear any intervals first
if (windowCheckInterval) {
@@ -76,14 +107,19 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
process.on("unhandledRejection", () => void gracefulShutdown());
try {
debugLog(id, "Preparing launch options");
// Deep clone to avoid reference sharing and ensure fresh configuration for each instance
const camoufoxOptions: LaunchOptions = JSON.parse(
JSON.stringify(config.options || {}),
);
debugLog(id, "Base options cloned", {
hasOptions: Object.keys(camoufoxOptions).length,
});
// Add profile path if provided
if (config.profilePath) {
camoufoxOptions.user_data_dir = config.profilePath;
debugLog(id, "Set user_data_dir", { profilePath: config.profilePath });
}
// Ensure block options are properly set
@@ -111,52 +147,94 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
showcursor: false,
...(camoufoxOptions.config || {}),
};
debugLog(id, "Set default options", {
i_know_what_im_doing: true,
disableTheming: true,
showcursor: false,
});
// Generate fresh options for this specific instance
debugLog(id, "Generating launch options via launchOptions function");
const generatedOptions = await launchOptions(camoufoxOptions);
debugLog(id, "Launch options generated successfully", {
hasEnv: !!generatedOptions.env,
argsLength: generatedOptions.args?.length || 0,
});
// Start with process environment to ensure proper inheritance
let finalEnv = { ...process.env };
debugLog(id, "Base environment variables set", {
envVarCount: Object.keys(finalEnv).length,
});
// Add generated options environment variables
if (generatedOptions.env) {
finalEnv = { ...finalEnv, ...generatedOptions.env };
debugLog(id, "Added generated environment variables", {
generatedEnvCount: Object.keys(generatedOptions.env).length,
totalEnvCount: Object.keys(finalEnv).length,
});
}
// If we have a custom config from Rust, use it directly as environment variables
if (config.customConfig) {
debugLog(id, "Processing custom config", {
customConfigLength: config.customConfig.length,
});
try {
// Parse the custom config JSON string
const customConfigObj = JSON.parse(config.customConfig);
debugLog(id, "Custom config parsed successfully", {
customConfigKeys: Object.keys(customConfigObj),
});
// Ensure default config values are preserved even with custom config
const mergedConfig = {
...customConfigObj,
disableTheming: true,
showcursor: false,
// allowAddonNewTab will be handled from the fingerprint config if present
};
// Convert merged config to environment variables using getEnvVars
const customEnvVars = getEnvVars(mergedConfig);
debugLog(id, "Custom config converted to environment variables", {
customEnvVarCount: Object.keys(customEnvVars).length,
});
// Merge custom config with generated config (custom takes precedence)
finalEnv = { ...finalEnv, ...customEnvVars };
debugLog(id, "Custom config merged with final environment", {
finalEnvCount: Object.keys(finalEnv).length,
});
} catch (error) {
debugLog(id, "Failed to parse custom config", {
error: error instanceof Error ? error.message : String(error),
});
console.error(
`Camoufox worker ${id}: Failed to parse custom config, using generated config:`,
error,
);
await gracefulShutdown();
return;
}
} else {
debugLog(id, "No custom config provided");
}
// Prepare profile path for persistent context
const profilePath = config.profilePath || "";
debugLog(id, "Profile path prepared", { profilePath });
// Launch persistent context with the final configuration
const finalOptions: any = {
...generatedOptions,
env: finalEnv,
};
debugLog(id, "Final launch options prepared", {
hasExecutablePath: !!finalOptions.executablePath,
hasProxy: !!camoufoxOptions.proxy,
profilePath,
});
// If a custom executable path was provided, ensure Playwright uses it
if (
@@ -165,46 +243,66 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
) {
finalOptions.executablePath = (camoufoxOptions as any)
.executable_path as string;
debugLog(id, "Custom executable path set", {
executablePath: finalOptions.executablePath,
});
}
// Only add proxy if it exists and is valid
if (camoufoxOptions.proxy) {
debugLog(id, "Processing proxy configuration", {
proxyString: camoufoxOptions.proxy,
});
try {
finalOptions.proxy = parseProxyString(camoufoxOptions.proxy);
debugLog(id, "Proxy parsed successfully");
} catch (error) {
debugLog(id, "Failed to parse proxy", {
error: error instanceof Error ? error.message : String(error),
});
console.error({
message: "Failed to parse proxy, launching without proxy",
error,
});
await gracefulShutdown();
return;
}
}
// Use launchPersistentContext instead of launchServer
debugLog(id, "Launching persistent context", { profilePath });
context = await firefox.launchPersistentContext(
profilePath,
finalOptions,
);
debugLog(id, "Persistent context launched successfully");
// Get the browser instance from context
browser = context.browser();
debugLog(id, "Browser instance obtained from context", {
browserConnected: browser?.isConnected(),
});
// Handle browser disconnection for proper cleanup
if (browser) {
browser.on("disconnected", () => void gracefulShutdown());
debugLog(id, "Browser disconnect handler registered");
}
// Handle context close for proper cleanup
context.on("close", () => void gracefulShutdown());
debugLog(id, "Context close handler registered");
saveCamoufoxConfig(config);
// Monitor for window closure
const startWindowMonitoring = () => {
debugLog(id, "Starting window monitoring");
windowCheckInterval = setInterval(async () => {
try {
// Check if context is still active
if (!context?.pages || context.pages().length === 0) {
debugLog(id, "No pages found in context, shutting down");
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
}
@@ -214,6 +312,7 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Check if browser is still connected (if available)
if (browser && !browser.isConnected()) {
debugLog(id, "Browser disconnected, shutting down");
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
}
@@ -224,12 +323,16 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Check pages in the persistent context
const pages = context.pages();
if (pages.length === 0) {
debugLog(id, "No pages in context, shutting down");
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
}
await gracefulShutdown();
}
} catch {
} catch (error) {
debugLog(id, "Error in window monitoring", {
error: error instanceof Error ? error.message : String(error),
});
// If we can't check windows, assume browser is closing
if (windowCheckInterval) {
clearInterval(windowCheckInterval);
@@ -241,19 +344,29 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Handle URL opening if provided
if (config.url) {
debugLog(id, "Opening URL in browser", { url: config.url });
try {
const pages = await context.pages();
if (pages.length) {
const page = pages[0];
debugLog(id, "Navigating to URL");
await page.goto(config.url, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
debugLog(id, "URL opened successfully");
// Start monitoring after page is created
startWindowMonitoring();
} else {
debugLog(id, "No pages available to open URL");
startWindowMonitoring();
}
} catch (urlError) {
debugLog(id, "Failed to open URL", {
error:
urlError instanceof Error ? urlError.message : String(urlError),
});
console.error({
message: "Failed to open URL",
error: urlError,
@@ -263,15 +376,18 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
startWindowMonitoring();
}
} else {
debugLog(id, "No URL provided, starting monitoring");
// Start monitoring after page is created
startWindowMonitoring();
}
// Monitor browser/context connection
debugLog(id, "Starting keep-alive monitoring");
const keepAlive = setInterval(async () => {
try {
// Check if context is still active
if (!context?.pages) {
debugLog(id, "Context not active in keep-alive, shutting down");
clearInterval(keepAlive);
await gracefulShutdown();
return;
@@ -279,11 +395,15 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
// Check browser connection if available
if (browser && !browser.isConnected()) {
debugLog(id, "Browser not connected in keep-alive, shutting down");
clearInterval(keepAlive);
await gracefulShutdown();
return;
}
} catch (error) {
debugLog(id, "Error in keep-alive check", {
error: error instanceof Error ? error.message : String(error),
});
console.error({
message: "Error in keepAlive check",
error,
@@ -293,6 +413,9 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
}
}, 2000);
} catch (error) {
debugLog(id, "Failed to launch Camoufox", {
error: error instanceof Error ? error.message : String(error),
});
console.error({
message: "Failed to launch Camoufox",
error,
+2 -4
View File
@@ -57,10 +57,8 @@ program
// 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";
// Preserve provided scheme (http, https, socks4, socks5)
const protocol = String(options.type).toLowerCase();
const auth =
options.username && options.password
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
+40 -37
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.9.1",
"version": "0.12.3",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -21,57 +21,60 @@
"format": "pnpm format:js && pnpm format:rust",
"cargo": "cd src-tauri && cargo",
"unused-exports:js": "ts-unused-exports tsconfig.json",
"check-unused-commands": "cd src-tauri && cargo run --bin check_unused_commands"
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.7.0",
"@tauri-apps/plugin-deep-link": "^2.4.1",
"@tauri-apps/plugin-dialog": "^2.3.2",
"@tauri-apps/plugin-fs": "~2.4.1",
"@tauri-apps/plugin-opener": "^2.4.0",
"ahooks": "^3.9.0",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "~2.4.4",
"@tauri-apps/plugin-opener": "^2.5.2",
"ahooks": "^3.9.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"motion": "^12.23.12",
"next": "^15.4.6",
"color": "^5.0.2",
"motion": "^12.23.24",
"next": "^15.5.6",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.1.4",
"@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2.7.1",
"@types/node": "^24.2.1",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"@biomejs/biome": "2.2.3",
"@tailwindcss/postcss": "^4.1.17",
"@tauri-apps/cli": "^2.9.4",
"@types/color": "^4.2.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
"tailwindcss": "^4.1.11",
"lint-staged": "^16.2.6",
"tailwindcss": "^4.1.17",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.3.6",
"typescript": "~5.9.2"
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
},
"packageManager": "pnpm@10.13.1",
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+2360 -2482
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,8 +1,10 @@
packages:
- nodecar
onlyBuiltDependencies:
- '@biomejs/biome'
- '@tailwindcss/oxide'
- better-sqlite3
- esbuild
- sharp
- sqlite3
+1084 -966
View File
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.9.1"
version = "0.12.3"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -14,6 +14,7 @@ default-run = "donutbrowser"
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "donutbrowser"
crate-type = ["staticlib", "cdylib", "rlib"]
doctest = false
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -28,24 +29,32 @@ 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", "sync"] }
sysinfo = "0.36"
sysinfo = "0.37"
lazy_static = "1.4"
base64 = "0.22"
async-trait = "0.1"
futures-util = "0.3"
zip = "4"
zip = "5"
tar = "0"
bzip2 = "0"
flate2 = "1"
lzma-rs = "0"
msi-extract = "0"
uuid = { version = "1.0", features = ["v4", "serde"] }
uuid = { version = "1.18", features = ["v4", "serde"] }
url = "2.5"
chrono = { version = "0.4", features = ["serde"] }
axum = "0.8.4"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
argon2 = "0.5"
aes-gcm = "0.10"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
@@ -57,7 +66,7 @@ objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
windows = { version = "0.61", features = [
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
@@ -70,9 +79,9 @@ windows = { version = "0.61", features = [
] }
[dev-dependencies]
tempfile = "3.13.0"
tempfile = "3.21.0"
wiremock = "0.6"
hyper = { version = "1.0", features = ["full"] }
hyper = { version = "1.7", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
tower = "0.5"
+6 -6
View File
@@ -3,15 +3,15 @@
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Donut Browser needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually.</string>
<string>Donut needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Donut Browser needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
<string>Donut needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Donut Browser has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
<string>Donut has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
<key>CFBundleDisplayName</key>
<string>Donut Browser</string>
<string>Donut</string>
<key>CFBundleName</key>
<string>Donut Browser</string>
<string>Donut</string>
<key>CFBundleIdentifier</key>
<string>com.donutbrowser</string>
<key>CFBundleURLName</key>
@@ -25,7 +25,7 @@
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Donut Browser</string>
<string>Copyright © 2025 Donut</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
+8
View File
@@ -26,5 +26,13 @@ fn main() {
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
}
// Inject vault password at build time
if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") {
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}");
} else {
// Use default password if environment variable is not set
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
}
tauri_build::build()
}
+4 -1
View File
@@ -1,7 +1,10 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Donut Browser
Name=Donut
Name[en]=Donut
GenericName=Web Browser
X-GNOME-FullName=Donut
Comment=Simple Yet Powerful Anti-Detect Browser
Exec=donutbrowser %u
Icon=donutbrowser
+6
View File
@@ -28,5 +28,11 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
+34 -10
View File
@@ -221,6 +221,20 @@ pub fn sort_versions(versions: &mut [String]) {
});
}
// Helper function to compare two versions
pub fn compare_versions(version1: &str, version2: &str) -> std::cmp::Ordering {
let version_a = VersionComponent::parse(version1);
let version_b = VersionComponent::parse(version2);
version_a.cmp(&version_b)
}
pub fn is_version_newer(version1: &str, version2: &str) -> bool {
// Use the proper VersionComponent comparison from api_client.rs
let version_a = VersionComponent::parse(version1);
let version_b = VersionComponent::parse(version2);
version_a > version_b
}
// Helper function to sort GitHub releases
pub fn sort_github_releases(releases: &mut [GithubRelease]) {
releases.sort_by(|a, b| {
@@ -268,7 +282,12 @@ pub fn is_browser_version_nightly(
// Last resort: when no name available, treat as nightly (non-Release)
true
}
"firefox" | "firefox-developer" => {
"firefox-developer" => {
// For Firefox Developer Edition, always treat as nightly/prerelease
// This ensures consistent behavior regardless of cache state or API response parsing
true
}
"firefox" => {
// For Firefox, use the category from the API response to determine stability
// This will be handled in the API parsing, so this fallback is for cached versions
is_nightly_version(version)
@@ -334,9 +353,14 @@ pub struct ApiClient {
}
impl ApiClient {
fn new() -> Self {
pub fn new() -> Self {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new());
Self {
client: Client::new(),
client,
firefox_api_base: "https://product-details.mozilla.org/1.0".to_string(),
firefox_dev_api_base: "https://product-details.mozilla.org/1.0".to_string(),
github_api_base: "https://api.github.com".to_string(),
@@ -647,19 +671,19 @@ impl ApiClient {
let response = self
.client
.get(url)
.get(&url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
if !response.status().is_success() {
return Err(
format!(
"Failed to fetch Firefox Developer Edition versions: {}",
response.status()
)
.into(),
let error_msg = format!(
"Failed to fetch Firefox Developer Edition versions: {} - URL: {}",
response.status(),
url
);
eprintln!("{error_msg}");
return Err(error_msg.into());
}
let firefox_response: FirefoxApiResponse = response.json().await?;
+872
View File
@@ -0,0 +1,872 @@
use crate::camoufox_manager::CamoufoxConfig;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{Json, Response},
routing::{delete, get, post, put},
Router,
};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::Emitter;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tower_http::cors::CorsLayer;
// API Types
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiProfile {
pub id: String,
pub name: String,
pub browser: String,
pub version: String,
pub proxy_id: Option<String>,
pub process_id: Option<u32>,
pub last_launch: Option<u64>,
pub release_type: String,
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Vec<String>,
pub is_running: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiProfilesResponse {
pub profiles: Vec<ApiProfile>,
pub total: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiProfileResponse {
pub profile: ApiProfile,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateProfileRequest {
pub name: String,
pub browser: String,
pub version: String,
pub proxy_id: Option<String>,
pub release_type: Option<String>,
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateProfileRequest {
pub name: Option<String>,
pub browser: Option<String>,
pub version: Option<String>,
pub proxy_id: Option<String>,
pub release_type: Option<String>,
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Clone)]
struct ApiServerState {
app_handle: tauri::AppHandle,
}
#[derive(Debug, Serialize, Deserialize)]
struct ApiGroupResponse {
id: String,
name: String,
profile_count: usize,
}
#[derive(Debug, Deserialize)]
struct CreateGroupRequest {
name: String,
}
#[derive(Debug, Deserialize)]
struct UpdateGroupRequest {
name: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ApiProxyResponse {
id: String,
name: String,
proxy_settings: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct CreateProxyRequest {
name: String,
proxy_settings: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct UpdateProxyRequest {
name: Option<String>,
proxy_settings: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct DownloadBrowserRequest {
browser: String,
version: String,
}
#[derive(Debug, Serialize)]
struct DownloadBrowserResponse {
browser: String,
version: String,
status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastPayload {
pub message: String,
pub variant: String,
pub title: String,
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
struct RunProfileResponse {
profile_id: String,
remote_debugging_port: u16,
headless: bool,
}
pub struct ApiServer {
port: Option<u16>,
shutdown_tx: Option<mpsc::Sender<()>>,
task_handle: Option<tokio::task::JoinHandle<()>>,
}
impl ApiServer {
fn new() -> Self {
Self {
port: None,
shutdown_tx: None,
task_handle: None,
}
}
fn get_port(&self) -> Option<u16> {
self.port
}
async fn start(
&mut self,
app_handle: tauri::AppHandle,
preferred_port: u16,
) -> Result<u16, String> {
// Stop existing server if running
self.stop().await.ok();
let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1);
let state = ApiServerState {
app_handle: app_handle.clone(),
};
// Try preferred port first, then random port
let listener = match TcpListener::bind(format!("127.0.0.1:{preferred_port}")).await {
Ok(listener) => listener,
Err(_) => {
// Port conflict, try random port
let random_port = rand::random::<u16>().saturating_add(10000);
match TcpListener::bind(format!("127.0.0.1:{random_port}")).await {
Ok(listener) => {
let _ = app_handle.emit(
"api-port-conflict",
format!("API server using fallback port {random_port}"),
);
listener
}
Err(e) => return Err(format!("Failed to bind to any port: {e}")),
}
}
};
let actual_port = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {e}"))?
.port();
// Create router with CORS, authentication, and versioning
let v1_routes = Router::new()
.route("/profiles", get(get_profiles))
.route("/profiles", post(create_profile))
.route("/profiles/{id}", get(get_profile))
.route("/profiles/{id}", put(update_profile))
.route("/profiles/{id}", delete(delete_profile))
.route("/profiles/{id}/run", post(run_profile))
.route("/groups", get(get_groups).post(create_group))
.route(
"/groups/{id}",
get(get_group).put(update_group).delete(delete_group),
)
.route("/tags", get(get_tags))
.route("/proxies", get(get_proxies).post(create_proxy))
.route(
"/proxies/{id}",
get(get_proxy).put(update_proxy).delete(delete_proxy),
)
.route("/browsers/download", post(download_browser_api))
.route("/browsers/{browser}/versions", get(get_browser_versions))
.route(
"/browsers/{browser}/versions/{version}/downloaded",
get(check_browser_downloaded),
)
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.nest("/v1", v1_routes)
.layer(CorsLayer::permissive())
.with_state(state);
// Start server task
let task_handle = tokio::spawn(async move {
let server = axum::serve(listener, app);
tokio::select! {
_ = server => {},
_ = shutdown_rx.recv() => {},
}
});
self.port = Some(actual_port);
self.shutdown_tx = Some(shutdown_tx);
self.task_handle = Some(task_handle);
Ok(actual_port)
}
async fn stop(&mut self) -> Result<(), String> {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(()).await;
}
if let Some(handle) = self.task_handle.take() {
handle.abort();
}
self.port = None;
Ok(())
}
}
// Authentication middleware
async fn auth_middleware(
State(state): State<ApiServerState>,
headers: HeaderMap,
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
// Get the Authorization header
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
let token = match auth_header {
Some(token) => token,
None => return Err(StatusCode::UNAUTHORIZED),
};
// Get the stored token
let settings_manager = crate::settings_manager::SettingsManager::instance();
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Compare tokens
if token != stored_token {
return Err(StatusCode::UNAUTHORIZED);
}
// Token is valid, continue with the request
Ok(next.run(request).await)
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
}
// Tauri commands
#[tauri::command]
pub async fn start_api_server_internal(
port: u16,
app_handle: &tauri::AppHandle,
) -> Result<u16, String> {
let mut server_guard = API_SERVER.lock().await;
server_guard.start(app_handle.clone(), port).await
}
#[tauri::command]
pub async fn stop_api_server() -> Result<(), String> {
let mut server_guard = API_SERVER.lock().await;
server_guard.stop().await
}
#[tauri::command]
pub async fn start_api_server(
port: Option<u16>,
app_handle: tauri::AppHandle,
) -> Result<u16, String> {
let actual_port = port.unwrap_or(10108);
start_api_server_internal(actual_port, &app_handle).await
}
#[tauri::command]
pub async fn get_api_server_status() -> Result<Option<u16>, String> {
let server_guard = API_SERVER.lock().await;
Ok(server_guard.get_port())
}
// API Handlers - Profiles
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
match profile_manager.list_profiles() {
Ok(profiles) => {
let api_profiles: Vec<ApiProfile> = profiles
.iter()
.map(|profile| ApiProfile {
id: profile.id.to_string(),
name: profile.name.clone(),
browser: profile.browser.clone(),
version: profile.version.clone(),
proxy_id: profile.proxy_id.clone(),
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
})
.collect();
Ok(Json(ApiProfilesResponse {
profiles: api_profiles,
total: profiles.len(),
}))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
async fn get_profile(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
match profile_manager.list_profiles() {
Ok(profiles) => {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
Ok(Json(ApiProfileResponse {
profile: ApiProfile {
id: profile.id.to_string(),
name: profile.name.clone(),
browser: profile.browser.clone(),
version: profile.version.clone(),
proxy_id: profile.proxy_id.clone(),
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
},
}))
} else {
Err(StatusCode::NOT_FOUND)
}
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
async fn create_profile(
State(state): State<ApiServerState>,
Json(request): Json<CreateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
// Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config {
serde_json::from_value(config.clone()).ok()
} else {
None
};
// Create profile using the async create_profile_with_group method
match profile_manager
.create_profile_with_group(
&state.app_handle,
&request.name,
&request.browser,
&request.version,
request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(),
camoufox_config,
request.group_id.clone(),
)
.await
{
Ok(mut profile) => {
// Apply tags if provided
if let Some(tags) = &request.tags {
if profile_manager
.update_profile_tags(&state.app_handle, &profile.name, tags.clone())
.is_err()
{
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
profile.tags = tags.clone();
}
// Update tag manager with new tags
if let Ok(profiles) = profile_manager.list_profiles() {
let _ = crate::tag_manager::TAG_MANAGER
.lock()
.map(|manager| manager.rebuild_from_profiles(&profiles));
}
Ok(Json(ApiProfileResponse {
profile: ApiProfile {
id: profile.id.to_string(),
name: profile.name,
browser: profile.browser,
version: profile.version,
proxy_id: profile.proxy_id,
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type,
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
},
}))
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
async fn update_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
// Update profile fields
if let Some(new_name) = request.name {
if profile_manager
.rename_profile(&state.app_handle, &id, &new_name)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(version) = request.version {
if profile_manager
.update_profile_version(&state.app_handle, &id, &version)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(proxy_id) = request.proxy_id {
if profile_manager
.update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(camoufox_config) = request.camoufox_config {
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
match config {
Ok(config) => {
if profile_manager
.update_camoufox_config(state.app_handle.clone(), &id, config)
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
Err(_) => return Err(StatusCode::BAD_REQUEST),
}
}
if let Some(group_id) = request.group_id {
if profile_manager
.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(tags) = request.tags {
if profile_manager
.update_profile_tags(&state.app_handle, &id, tags)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
// Update tag manager with new tags from all profiles
if let Ok(profiles) = profile_manager.list_profiles() {
let _ = crate::tag_manager::TAG_MANAGER
.lock()
.map(|manager| manager.rebuild_from_profiles(&profiles));
}
}
// Return updated profile
get_profile(Path(id), State(state)).await
}
async fn delete_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
let profile_manager = ProfileManager::instance();
match profile_manager.delete_profile(&state.app_handle, &id) {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
// API Handlers - Groups
async fn get_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiGroupResponse>>, StatusCode> {
match GROUP_MANAGER.lock() {
Ok(manager) => {
match manager.get_all_groups() {
Ok(groups) => {
let api_groups = groups
.into_iter()
.map(|group| ApiGroupResponse {
id: group.id,
name: group.name,
profile_count: 0, // Would need profile list to calculate this
})
.collect();
Ok(Json(api_groups))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
async fn get_group(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<ApiGroupResponse>, StatusCode> {
match GROUP_MANAGER.lock() {
Ok(manager) => match manager.get_all_groups() {
Ok(groups) => {
if let Some(group) = groups.into_iter().find(|g| g.id == id) {
Ok(Json(ApiGroupResponse {
id: group.id,
name: group.name,
profile_count: 0,
}))
} else {
Err(StatusCode::NOT_FOUND)
}
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
},
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
async fn create_group(
State(state): State<ApiServerState>,
Json(request): Json<CreateGroupRequest>,
) -> Result<Json<ApiGroupResponse>, StatusCode> {
match GROUP_MANAGER.lock() {
Ok(manager) => match manager.create_group(&state.app_handle, request.name) {
Ok(group) => Ok(Json(ApiGroupResponse {
id: group.id,
name: group.name,
profile_count: 0,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
},
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
async fn update_group(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateGroupRequest>,
) -> Result<Json<ApiGroupResponse>, StatusCode> {
match GROUP_MANAGER.lock() {
Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) {
Ok(group) => Ok(Json(ApiGroupResponse {
id: group.id,
name: group.name,
profile_count: 0,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
},
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
async fn delete_group(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
match GROUP_MANAGER.lock() {
Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(_) => Err(StatusCode::BAD_REQUEST),
},
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handlers - Tags
async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<String>>, StatusCode> {
match TAG_MANAGER.lock() {
Ok(manager) => match manager.get_all_tags() {
Ok(tags) => Ok(Json(tags)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
},
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handlers - Proxies
async fn get_proxies(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiProxyResponse>>, StatusCode> {
let proxies = PROXY_MANAGER.get_stored_proxies();
Ok(Json(
proxies
.into_iter()
.map(|p| ApiProxyResponse {
id: p.id,
name: p.name,
proxy_settings: serde_json::to_value(p.proxy_settings).unwrap_or_default(),
})
.collect(),
))
}
async fn get_proxy(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: serde_json::to_value(proxy.proxy_settings).unwrap_or_default(),
}))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn create_proxy(
State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
// Convert JSON value to ProxySettings
match serde_json::from_value(request.proxy_settings.clone()) {
Ok(proxy_settings) => {
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
proxy_settings,
) {
Ok(_) => {
// Find the created proxy to return it
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.name == request.name) {
Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: request.proxy_settings,
}))
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
async fn update_proxy(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
let new_name = request.name.unwrap_or(proxy.name.clone());
let new_proxy_settings = if let Some(settings_json) = request.proxy_settings {
match serde_json::from_value(settings_json) {
Ok(settings) => settings,
Err(_) => return Err(StatusCode::BAD_REQUEST),
}
} else {
proxy.proxy_settings.clone()
};
match PROXY_MANAGER.update_stored_proxy(
&state.app_handle,
&id,
Some(new_name.clone()),
Some(new_proxy_settings.clone()),
) {
Ok(_) => Ok(Json(ApiProxyResponse {
id,
name: new_name,
proxy_settings: serde_json::to_value(new_proxy_settings).unwrap_or_default(),
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn delete_proxy(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
// API Handler - Run Profile with Remote Debugging
async fn run_profile(
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
State(state): State<ApiServerState>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
let headless = params
.get("headless")
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
// Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging(
state.app_handle.clone(),
profile.clone(),
None,
Some(remote_debugging_port),
headless,
)
.await
{
Ok(updated_profile) => Ok(Json(RunProfileResponse {
profile_id: updated_profile.id.to_string(),
remote_debugging_port,
headless,
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Download Browser
async fn download_browser_api(
State(state): State<ApiServerState>,
Json(request): Json<DownloadBrowserRequest>,
) -> Result<Json<DownloadBrowserResponse>, StatusCode> {
match crate::downloader::download_browser(
state.app_handle.clone(),
request.browser.clone(),
request.version.clone(),
)
.await
{
Ok(_) => Ok(Json(DownloadBrowserResponse {
browser: request.browser,
version: request.version,
status: "downloaded".to_string(),
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Get Browser Versions
async fn get_browser_versions(
Path(browser): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<String>>, StatusCode> {
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
match version_manager
.fetch_browser_versions_with_count(&browser, false)
.await
{
Ok(result) => Ok(Json(result.versions)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Check if Browser is Downloaded
async fn check_browser_downloaded(
Path((browser, version)): Path<(String, String)>,
State(_state): State<ApiServerState>,
) -> Result<Json<bool>, StatusCode> {
let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version);
Ok(Json(is_downloaded))
}
+9 -17
View File
@@ -120,12 +120,14 @@ pub struct AppUpdateProgress {
pub struct AppAutoUpdater {
client: Client,
extractor: &'static crate::extraction::Extractor,
}
impl AppAutoUpdater {
fn new() -> Self {
Self {
client: Client::new(),
extractor: crate::extraction::Extractor::instance(),
}
}
@@ -829,8 +831,6 @@ impl AppAutoUpdater {
archive_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let extractor = crate::extraction::Extractor::instance();
let file_name = archive_path
.file_name()
.and_then(|name| name.to_str())
@@ -838,7 +838,7 @@ impl AppAutoUpdater {
// Handle compound extensions like .tar.gz
if file_name.ends_with(".tar.gz") {
return extractor.extract_tar_gz(archive_path, dest_dir).await;
return self.extractor.extract_tar_gz(archive_path, dest_dir).await;
}
let extension = archive_path
@@ -850,7 +850,7 @@ impl AppAutoUpdater {
"dmg" => {
#[cfg(target_os = "macos")]
{
extractor.extract_dmg(archive_path, dest_dir).await
self.extractor.extract_dmg(archive_path, dest_dir).await
}
#[cfg(not(target_os = "macos"))]
{
@@ -914,7 +914,7 @@ impl AppAutoUpdater {
Err("AppImage installation is only supported on Linux".into())
}
}
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
"zip" => self.extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
}
}
@@ -1083,8 +1083,8 @@ impl AppAutoUpdater {
fs::create_dir_all(&temp_extract_dir)?;
// Extract ZIP file
let extractor = crate::extraction::Extractor::instance();
let extracted_path = extractor
let extracted_path = self
.extractor
.extract_zip(installer_path, &temp_extract_dir)
.await?;
@@ -1314,8 +1314,8 @@ impl AppAutoUpdater {
fs::create_dir_all(&temp_extract_dir)?;
// Extract tarball
let extractor = crate::extraction::Extractor::instance();
let extracted_path = extractor
let extracted_path = self
.extractor
.extract_tar_gz(tarball_path, &temp_extract_dir)
.await?;
@@ -1614,14 +1614,6 @@ pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, Str
.map_err(|e| format!("Failed to check for app updates: {e}"))
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub installation_method: String,
pub supported_formats: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
+74 -78
View File
@@ -1,6 +1,5 @@
use crate::api_client::is_browser_version_nightly;
use crate::browser_version_manager::{BrowserVersionInfo, BrowserVersionManager};
use crate::profile::BrowserProfile;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::settings_manager::SettingsManager;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
@@ -29,15 +28,17 @@ pub struct AutoUpdateState {
}
pub struct AutoUpdater {
version_service: &'static BrowserVersionManager,
browser_version_manager: &'static BrowserVersionManager,
settings_manager: &'static SettingsManager,
profile_manager: &'static ProfileManager,
}
impl AutoUpdater {
fn new() -> Self {
Self {
version_service: BrowserVersionManager::instance(),
browser_version_manager: BrowserVersionManager::instance(),
settings_manager: SettingsManager::instance(),
profile_manager: ProfileManager::instance(),
}
}
@@ -53,8 +54,8 @@ impl AutoUpdater {
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
// Group profiles by browser
let profile_manager = crate::profile::ProfileManager::instance();
let profiles = profile_manager
let profiles = self
.profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
@@ -62,7 +63,7 @@ impl AutoUpdater {
for profile in profiles {
// Only check supported browsers
if !self
.version_service
.browser_version_manager
.is_browser_supported(&profile.browser)
.unwrap_or(false)
{
@@ -78,14 +79,14 @@ impl AutoUpdater {
for (browser, profiles) in browser_profiles {
// Get cached versions first, then try to fetch if needed
let versions = if let Some(cached) = self
.version_service
.browser_version_manager
.get_cached_browser_versions_detailed(&browser)
{
cached
} else if self.version_service.should_update_cache(&browser) {
} else if self.browser_version_manager.should_update_cache(&browser) {
// Try to fetch fresh versions
match self
.version_service
.browser_version_manager
.fetch_browser_versions_detailed(&browser, false)
.await
{
@@ -148,16 +149,17 @@ impl AutoUpdater {
);
// 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();
let app_handle_clone = app_handle.clone();
// Spawn async task to handle the download and auto-update
tokio::spawn(async move {
// TODO: update the logic to use the downloaded browsers registry instance instead of the static method
// First, check if browser already exists
match crate::browser_runner::is_browser_downloaded(
match crate::downloaded_browsers_registry::is_browser_downloaded(
browser.clone(),
new_version.clone(),
) {
@@ -165,11 +167,13 @@ impl AutoUpdater {
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
match AutoUpdater::instance()
.complete_browser_update_with_auto_update(
&app_handle_clone,
&browser.clone(),
&new_version.clone(),
)
.await
{
Ok(updated_profiles) => {
println!(
@@ -222,7 +226,8 @@ impl AutoUpdater {
available_versions: &[BrowserVersionInfo],
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = &profile.version;
let is_current_nightly = is_browser_version_nightly(&profile.browser, current_version, None);
let is_current_nightly =
crate::api_client::is_browser_version_nightly(&profile.browser, current_version, None);
// Find the best available update
let best_update = available_versions
@@ -230,7 +235,8 @@ impl AutoUpdater {
.filter(|v| {
// Only consider versions newer than current
self.is_version_newer(&v.version, current_version)
&& is_browser_version_nightly(&profile.browser, &v.version, None) == is_current_nightly
&& crate::api_client::is_browser_version_nightly(&profile.browser, &v.version, None)
== is_current_nightly
})
.max_by(|a, b| self.compare_versions(&a.version, &b.version));
@@ -293,11 +299,12 @@ impl AutoUpdater {
/// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions(
&self,
app_handle: &tauri::AppHandle,
browser: &str,
new_version: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let profile_manager = crate::profile::ProfileManager::instance();
let profiles = profile_manager
let profiles = self
.profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
@@ -314,7 +321,11 @@ impl AutoUpdater {
// Check if this is an update (newer version)
if self.is_version_newer(new_version, &profile.version) {
// Update the profile version
match profile_manager.update_profile_version(&profile.name, new_version) {
match self.profile_manager.update_profile_version(
app_handle,
&profile.id.to_string(),
new_version,
) {
Ok(_) => {
updated_profiles.push(profile.name);
}
@@ -332,12 +343,13 @@ impl AutoUpdater {
/// Complete browser update process with auto-update of profile versions
pub async fn complete_browser_update_with_auto_update(
&self,
app_handle: &tauri::AppHandle,
browser: &str,
new_version: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// Auto-update profile versions first
let updated_profiles = self
.auto_update_profile_versions(browser, new_version)
.auto_update_profile_versions(app_handle, browser, new_version)
.await?;
// Remove browser from disabled list and clean up auto-update tracking
@@ -347,46 +359,9 @@ 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 profile_manager = crate::profile::ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to load profiles: {e}"))?;
// Get registry instance
let registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::instance();
// Get active browser versions (all profiles)
let active_versions = registry.get_active_browser_versions(&profiles);
// Get running browser versions (only running profiles)
let running_versions = registry.get_running_browser_versions(&profiles);
// Cleanup unused binaries (but keep running ones)
let cleaned_up = registry
.cleanup_unused_binaries(&active_versions, &running_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,
@@ -408,17 +383,11 @@ impl AutoUpdater {
}
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
// 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
crate::api_client::is_version_newer(version1, version2)
}
fn compare_versions(&self, version1: &str, version2: &str) -> std::cmp::Ordering {
// 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)
crate::api_client::compare_versions(version1, version2)
}
fn get_auto_update_state_file(&self) -> PathBuf {
@@ -455,6 +424,39 @@ impl AutoUpdater {
Ok(())
}
/// Get pending update versions for a specific browser
/// Returns a set of (browser, version) pairs that have pending updates
pub fn get_pending_update_versions(
&self,
) -> Result<std::collections::HashSet<(String, String)>, Box<dyn std::error::Error + Send + Sync>>
{
let state = self.load_auto_update_state()?;
let mut pending_versions = std::collections::HashSet::new();
for update in &state.pending_updates {
pending_versions.insert((update.browser.clone(), update.new_version.clone()));
}
Ok(pending_versions)
}
/// Get pending update for a specific browser version if it exists
pub fn get_pending_update(
&self,
browser: &str,
current_version: &str,
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let state = self.load_auto_update_state()?;
for update in &state.pending_updates {
if update.browser == browser && update.current_version == current_version {
return Ok(Some(update.clone()));
}
}
Ok(None)
}
}
// Tauri commands
@@ -470,14 +472,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
Ok(grouped)
}
#[tauri::command]
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
let updater = AutoUpdater::instance();
updater
.is_browser_disabled(&browser)
.map_err(|e| format!("Failed to check browser status: {e}"))
}
#[tauri::command]
pub async fn dismiss_update_notification(notification_id: String) -> Result<(), String> {
let updater = AutoUpdater::instance();
@@ -488,12 +482,13 @@ pub async fn dismiss_update_notification(notification_id: String) -> Result<(),
#[tauri::command]
pub async fn complete_browser_update_with_auto_update(
app_handle: tauri::AppHandle,
browser: String,
new_version: String,
) -> Result<Vec<String>, String> {
let updater = AutoUpdater::instance();
updater
.complete_browser_update_with_auto_update(&browser, &new_version)
.complete_browser_update_with_auto_update(&app_handle, &browser, &new_version)
.await
.map_err(|e| format!("Failed to complete browser update: {e}"))
}
@@ -520,6 +515,7 @@ mod tests {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
tags: Vec::new(),
}
}
@@ -883,7 +879,7 @@ mod tests {
use tempfile::TempDir;
// Create a temporary directory for testing
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let temp_dir = TempDir::new().unwrap();
// Create a mock settings manager that uses the temp directory
struct TestSettingsManager {
+160 -17
View File
@@ -58,6 +58,8 @@ pub trait Browser: Send + Sync {
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>>;
@@ -239,12 +241,29 @@ mod linux {
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let possible_executables = match browser_type {
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
BrowserType::Chromium => vec![
// Direct paths (for manual installations)
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("chromium-browser"),
// Subdirectory paths (for downloaded archives)
install_dir.join("chrome-linux").join("chrome"),
install_dir.join("chrome-linux").join("chromium"),
install_dir.join("chromium").join("chromium"),
install_dir.join("chromium").join("chrome"),
// Binary subdirectory
install_dir.join("bin").join("chromium"),
install_dir.join("bin").join("chrome"),
],
BrowserType::Brave => vec![
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
// Subdirectory paths
install_dir.join("brave").join("brave"),
install_dir.join("brave-browser").join("brave"),
install_dir.join("bin").join("brave"),
],
_ => vec![],
};
@@ -320,12 +339,29 @@ mod linux {
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
let possible_executables = match browser_type {
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
BrowserType::Chromium => vec![
// Direct paths (for manual installations)
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("chromium-browser"),
// Subdirectory paths (for downloaded archives)
install_dir.join("chrome-linux").join("chrome"),
install_dir.join("chrome-linux").join("chromium"),
install_dir.join("chromium").join("chromium"),
install_dir.join("chromium").join("chrome"),
// Binary subdirectory
install_dir.join("bin").join("chromium"),
install_dir.join("bin").join("chrome"),
],
BrowserType::Brave => vec![
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
// Subdirectory paths
install_dir.join("brave").join("brave"),
install_dir.join("brave-browser").join("brave"),
install_dir.join("bin").join("brave"),
],
_ => vec![],
};
@@ -413,11 +449,18 @@ mod windows {
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
// Common archive extraction patterns
install_dir.join("chrome-win").join("chrome.exe"),
install_dir.join("chromium").join("chromium.exe"),
install_dir.join("chromium").join("chrome.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
// Subdirectory patterns
install_dir.join("brave").join("brave.exe"),
install_dir.join("brave-browser").join("brave.exe"),
],
_ => vec![],
};
@@ -489,11 +532,18 @@ mod windows {
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
// Common archive extraction patterns
install_dir.join("chrome-win").join("chrome.exe"),
install_dir.join("chromium").join("chromium.exe"),
install_dir.join("chromium").join("chrome.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
// Subdirectory patterns
install_dir.join("brave").join("brave.exe"),
install_dir.join("brave-browser").join("brave.exe"),
],
_ => vec![],
};
@@ -557,11 +607,23 @@ impl Browser for FirefoxBrowser {
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec!["-profile".to_string(), profile_path.to_string()];
// Only use -no-remote for browsers that require it for security (Mullvad, Tor)
// Regular Firefox browsers can use remote commands for better URL handling
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--start-debugger-server".to_string());
args.push(port.to_string());
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Use -no-remote for browsers that require it for security (Mullvad, Tor) or when remote debugging
match self.browser_type {
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
args.push("-no-remote".to_string());
@@ -570,7 +632,11 @@ impl Browser for FirefoxBrowser {
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::Camoufox => {
// Don't use -no-remote so we can communicate with existing instances
// Use -no-remote when remote debugging to avoid conflicts
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Don't use -no-remote for normal launches so we can communicate with existing instances
}
_ => {}
}
@@ -659,6 +725,8 @@ impl Browser for ChromiumBrowser {
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec![
format!("--user-data-dir={}", profile_path),
@@ -670,9 +738,19 @@ impl Browser for ChromiumBrowser {
"--disable-updater".to_string(),
];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--remote-debugging-address=0.0.0.0".to_string());
args.push(format!("--remote-debugging-port={port}"));
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Add proxy configuration if provided
if let Some(proxy) = proxy_settings {
// Apply proxy settings
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
@@ -758,6 +836,8 @@ impl Browser for CamoufoxBrowser {
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// For Camoufox, we handle launching through the camoufox launcher
// This method won't be used directly, but we provide basic Firefox args as fallback
@@ -767,6 +847,17 @@ impl Browser for CamoufoxBrowser {
"-no-remote".to_string(),
];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--start-debugger-server".to_string());
args.push(port.to_string());
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
if let Some(url) = url {
args.push(url);
}
@@ -962,15 +1053,15 @@ mod tests {
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote)
// Test regular Firefox (should not use -no-remote for normal launch)
let browser = FirefoxBrowser::new(BrowserType::Firefox);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Firefox");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Firefox should not use -no-remote"
"Firefox should not use -no-remote for normal launch"
);
let args = browser
@@ -978,6 +1069,8 @@ mod tests {
"/path/to/profile",
None,
Some("https://example.com".to_string()),
None,
false,
)
.expect("Failed to create launch args for Firefox with URL");
assert_eq!(
@@ -985,29 +1078,55 @@ mod tests {
vec!["-profile", "/path/to/profile", "https://example.com"]
);
// Test Mullvad Browser (should use -no-remote)
// Test Firefox with remote debugging (should use -no-remote)
let args = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Firefox with remote debugging");
assert!(
args.contains(&"-no-remote".to_string()),
"Firefox should use -no-remote for remote debugging"
);
assert!(
args.contains(&"--start-debugger-server".to_string()),
"Firefox should include debugger server arg"
);
assert!(
args.contains(&"9222".to_string()),
"Firefox should include debugging port"
);
// Test Mullvad Browser (should always use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Mullvad Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Tor Browser (should use -no-remote)
// Test Tor Browser (should always use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Tor Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Zen Browser (should not use -no-remote)
// Test Zen Browser (should not use -no-remote for normal launch)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Zen Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Zen Browser should not use -no-remote"
"Zen Browser should not use -no-remote for normal launch"
);
// Test headless mode
let args = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Zen Browser headless");
assert!(
args.contains(&"--headless".to_string()),
"Browser should include headless flag when requested"
);
}
@@ -1015,7 +1134,7 @@ mod tests {
fn test_chromium_launch_args() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Chromium");
// Test that basic required arguments are present
@@ -1043,6 +1162,8 @@ mod tests {
"/path/to/profile",
None,
Some("https://example.com".to_string()),
None,
false,
)
.expect("Failed to create launch args for Chromium with URL");
assert!(
@@ -1055,6 +1176,28 @@ mod tests {
args_with_url.last().expect("Args should not be empty"),
"https://example.com"
);
// Test remote debugging
let args_with_debug = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Chromium with remote debugging");
assert!(
args_with_debug.contains(&"--remote-debugging-port=9222".to_string()),
"Chromium args should contain remote debugging port"
);
assert!(
args_with_debug.contains(&"--remote-debugging-address=0.0.0.0".to_string()),
"Chromium args should contain remote debugging address"
);
// Test headless mode
let args_headless = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Chromium headless");
assert!(
args_headless.contains(&"--headless".to_string()),
"Chromium args should contain headless flag when requested"
);
}
#[test]
File diff suppressed because it is too large Load Diff
+98 -2
View File
@@ -117,7 +117,8 @@ impl BrowserVersionManager {
/// Get cached browser versions immediately (returns None if no cache exists)
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
if browser == "brave" {
return ApiClient::instance()
return self
.api_client
.get_cached_github_releases("brave")
.map(|releases| releases.into_iter().map(|r| r.tag_name).collect());
}
@@ -134,7 +135,7 @@ impl BrowserVersionManager {
browser: &str,
) -> Option<Vec<BrowserVersionInfo>> {
if browser == "brave" {
if let Some(releases) = ApiClient::instance().get_cached_github_releases("brave") {
if let Some(releases) = self.api_client.get_cached_github_releases("brave") {
let detailed_info: Vec<BrowserVersionInfo> = releases
.into_iter()
.map(|r| BrowserVersionInfo {
@@ -1274,6 +1275,101 @@ mod tests {
}
}
#[tauri::command]
pub fn get_supported_browsers() -> Result<Vec<String>, String> {
let service = BrowserVersionManager::instance();
Ok(service.get_supported_browsers())
}
#[tauri::command]
pub fn is_browser_supported_on_platform(browser_str: String) -> Result<bool, String> {
let service = BrowserVersionManager::instance();
service
.is_browser_supported(&browser_str)
.map_err(|e| format!("Failed to check browser support: {e}"))
}
#[tauri::command]
pub async fn fetch_browser_versions_cached_first(
browser_str: String,
) -> Result<Vec<BrowserVersionInfo>, String> {
let service = BrowserVersionManager::instance();
// Get cached versions immediately if available
if let Some(cached_versions) = service.get_cached_browser_versions_detailed(&browser_str) {
// Check if we should update cache in background
if service.should_update_cache(&browser_str) {
// Start background update but return cached data immediately
let service_clone = BrowserVersionManager::instance();
let browser_str_clone = browser_str.clone();
tokio::spawn(async move {
if let Err(e) = service_clone
.fetch_browser_versions_detailed(&browser_str_clone, false)
.await
{
eprintln!("Background version update failed for {browser_str_clone}: {e}");
}
});
}
Ok(cached_versions)
} else {
// No cache available, fetch fresh
service
.fetch_browser_versions_detailed(&browser_str, false)
.await
.map_err(|e| format!("Failed to fetch detailed browser versions: {e}"))
}
}
#[tauri::command]
pub async fn fetch_browser_versions_with_count_cached_first(
browser_str: String,
) -> Result<BrowserVersionsResult, String> {
let service = BrowserVersionManager::instance();
// Get cached versions immediately if available
if let Some(cached_versions) = service.get_cached_browser_versions(&browser_str) {
// Check if we should update cache in background
if service.should_update_cache(&browser_str) {
// Start background update but return cached data immediately
let service_clone = BrowserVersionManager::instance();
let browser_str_clone = browser_str.clone();
tokio::spawn(async move {
if let Err(e) = service_clone
.fetch_browser_versions_with_count(&browser_str_clone, false)
.await
{
eprintln!("Background version update failed for {browser_str_clone}: {e}");
}
});
}
// Return cached data in the expected format
Ok(BrowserVersionsResult {
versions: cached_versions.clone(),
new_versions_count: None, // No new versions when returning cached data
total_versions_count: cached_versions.len(),
})
} else {
// No cache available, fetch fresh
service
.fetch_browser_versions_with_count(&browser_str, false)
.await
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
}
}
#[tauri::command]
pub async fn fetch_browser_versions_with_count(
browser_str: String,
) -> Result<BrowserVersionsResult, String> {
let service = BrowserVersionManager::instance();
service
.fetch_browser_versions_with_count(&browser_str, false)
.await
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
}
// Global singleton instance
lazy_static::lazy_static! {
static ref BROWSER_VERSION_SERVICE: BrowserVersionManager = BrowserVersionManager::new();
@@ -1,8 +1,10 @@
use crate::browser_runner::BrowserRunner;
use crate::profile::BrowserProfile;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
use tokio::sync::Mutex as AsyncMutex;
@@ -60,27 +62,40 @@ struct CamoufoxInstance {
url: Option<String>,
}
struct CamoufoxNodecarLauncherInner {
struct CamoufoxManagerInner {
instances: HashMap<String, CamoufoxInstance>,
}
pub struct CamoufoxNodecarLauncher {
inner: Arc<AsyncMutex<CamoufoxNodecarLauncherInner>>,
pub struct CamoufoxManager {
inner: Arc<AsyncMutex<CamoufoxManagerInner>>,
base_dirs: BaseDirs,
}
impl CamoufoxNodecarLauncher {
impl CamoufoxManager {
fn new() -> Self {
Self {
inner: Arc::new(AsyncMutex::new(CamoufoxNodecarLauncherInner {
inner: Arc::new(AsyncMutex::new(CamoufoxManagerInner {
instances: HashMap::new(),
})),
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
}
}
pub fn instance() -> &'static CamoufoxNodecarLauncher {
pub fn instance() -> &'static CamoufoxManager {
&CAMOUFOX_NODECAR_LAUNCHER
}
pub fn get_profiles_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("profiles");
path
}
/// Generate Camoufox fingerprint configuration during profile creation
pub async fn generate_fingerprint_config(
&self,
@@ -95,8 +110,8 @@ impl CamoufoxNodecarLauncher {
path.clone()
} else {
// Use the browser runner helper with the real profile
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
// Use self.browser_runner instead of instance()
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
.to_string_lossy()
@@ -202,8 +217,8 @@ impl CamoufoxNodecarLauncher {
path.clone()
} else {
// Use the browser runner helper with the real profile
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
// Use self.browser_runner instead of instance()
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
.to_string_lossy()
@@ -431,7 +446,7 @@ impl CamoufoxNodecarLauncher {
}
}
impl CamoufoxNodecarLauncher {
impl CamoufoxManager {
pub async fn launch_camoufox_profile_nodecar(
&self,
app_handle: AppHandle,
@@ -440,8 +455,7 @@ impl CamoufoxNodecarLauncher {
url: Option<String>,
) -> Result<CamoufoxLaunchResult, String> {
// Get profile path
let browser_runner = crate::browser_runner::BrowserRunner::instance();
let profiles_dir = browser_runner.get_profiles_dir();
let profiles_dir = self.get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_path.to_string_lossy();
@@ -484,5 +498,5 @@ mod tests {
// Global singleton instance
lazy_static::lazy_static! {
static ref CAMOUFOX_NODECAR_LAUNCHER: CamoufoxNodecarLauncher = CamoufoxNodecarLauncher::new();
static ref CAMOUFOX_NODECAR_LAUNCHER: CamoufoxManager = CamoufoxManager::new();
}
+2 -46
View File
@@ -1,10 +1,10 @@
use tauri::command;
pub struct DefaultBrowser;
pub struct DefaultBrowser {}
impl DefaultBrowser {
fn new() -> Self {
Self
Self {}
}
pub fn instance() -> &'static DefaultBrowser {
@@ -38,38 +38,6 @@ impl DefaultBrowser {
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
}
pub async fn open_url_with_profile(
&self,
app_handle: tauri::AppHandle,
profile_name: String,
url: String,
) -> Result<(), String> {
let runner = crate::browser_runner::BrowserRunner::instance();
// Get the profile by name
let profiles = runner
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
println!("Opening URL '{url}' with profile '{profile_name}'");
// 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()), None)
.await
.map_err(|e| {
println!("Failed to open URL with profile '{profile_name}': {e}");
format!("Failed to open URL with profile: {e}")
})?;
println!("Successfully opened URL '{url}' with profile '{profile_name}'");
Ok(())
}
}
#[cfg(target_os = "macos")]
@@ -570,15 +538,3 @@ pub async fn set_as_default_browser() -> Result<(), String> {
let default_browser = DefaultBrowser::instance();
default_browser.set_as_default_browser().await
}
#[tauri::command]
pub async fn open_url_with_profile(
app_handle: tauri::AppHandle,
profile_name: String,
url: String,
) -> Result<(), String> {
let default_browser = DefaultBrowser::instance();
default_browser
.open_url_with_profile(app_handle, profile_name, url)
.await
}
-555
View File
@@ -1,555 +0,0 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadedBrowserInfo {
pub browser: String,
pub version: String,
pub file_path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct RegistryData {
pub browsers: HashMap<String, HashMap<String, DownloadedBrowserInfo>>, // browser -> version -> info
}
pub struct DownloadedBrowsersRegistry {
data: Mutex<RegistryData>,
}
impl DownloadedBrowsersRegistry {
fn new() -> Self {
Self {
data: Mutex::new(RegistryData::default()),
}
}
pub fn instance() -> &'static DownloadedBrowsersRegistry {
&DOWNLOADED_BROWSERS_REGISTRY
}
pub fn load(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let registry_path = Self::get_registry_path()?;
if !registry_path.exists() {
return Ok(());
}
let content = fs::read_to_string(&registry_path)?;
let registry_data: RegistryData = serde_json::from_str(&content)?;
let mut data = self.data.lock().unwrap();
*data = registry_data;
Ok(())
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let registry_path = Self::get_registry_path()?;
// Ensure parent directory exists
if let Some(parent) = registry_path.parent() {
fs::create_dir_all(parent)?;
}
let data = self.data.lock().unwrap();
let content = serde_json::to_string_pretty(&*data)?;
fs::write(&registry_path, content)?;
Ok(())
}
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) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("data");
path.push("downloaded_browsers.json");
Ok(path)
}
pub fn add_browser(&self, info: DownloadedBrowserInfo) {
let mut data = self.data.lock().unwrap();
data
.browsers
.entry(info.browser.clone())
.or_default()
.insert(info.version.clone(), info);
}
pub fn remove_browser(&self, browser: &str, version: &str) -> Option<DownloadedBrowserInfo> {
let mut data = self.data.lock().unwrap();
data.browsers.get_mut(browser)?.remove(version)
}
pub fn is_browser_downloaded(&self, browser: &str, version: &str) -> bool {
let data = self.data.lock().unwrap();
data
.browsers
.get(browser)
.and_then(|versions| versions.get(version))
.is_some()
}
pub fn get_downloaded_versions(&self, browser: &str) -> Vec<String> {
let data = self.data.lock().unwrap();
data
.browsers
.get(browser)
.map(|versions| versions.keys().cloned().collect())
.unwrap_or_default()
}
pub fn mark_download_started(&self, browser: &str, version: &str, file_path: PathBuf) {
let info = DownloadedBrowserInfo {
browser: browser.to_string(),
version: version.to_string(),
file_path,
};
self.add_browser(info);
}
pub fn mark_download_completed(&self, browser: &str, version: &str) -> Result<(), String> {
let data = self.data.lock().unwrap();
if data
.browsers
.get(browser)
.and_then(|versions| versions.get(version))
.is_some()
{
Ok(())
} else {
Err(format!("Browser {browser}:{version} not found in registry"))
}
}
pub fn cleanup_failed_download(
&self,
browser: &str,
version: &str,
) -> 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() {
if info.file_path.is_dir() {
fs::remove_dir_all(&info.file_path)?;
} else {
fs::remove_file(&info.file_path)?;
}
}
// Also clean up the browser directory if it exists
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let mut browser_dir = base_dirs.data_local_dir().to_path_buf();
browser_dir.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
browser_dir.push("binaries");
browser_dir.push(browser);
browser_dir.push(version);
if browser_dir.exists() {
fs::remove_dir_all(&browser_dir)?;
}
}
Ok(())
}
/// Find and remove unused browser binaries that are not referenced by any active profiles
pub fn cleanup_unused_binaries(
&self,
active_profiles: &[(String, String)], // (browser, version) pairs
running_profiles: &[(String, String)], // (browser, version) pairs for running profiles
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let active_set: std::collections::HashSet<(String, String)> =
active_profiles.iter().cloned().collect();
let running_set: std::collections::HashSet<(String, String)> =
running_profiles.iter().cloned().collect();
let mut cleaned_up = Vec::new();
// Collect all downloaded browsers that are not in active profiles
let mut to_remove = Vec::new();
{
let data = self.data.lock().unwrap();
for (browser, versions) in &data.browsers {
for version in versions.keys() {
let browser_version = (browser.clone(), version.clone());
// Don't remove if it's used by any active profile
if active_set.contains(&browser_version) {
println!("Keeping: {browser} {version} (in use by profile)");
continue;
}
// Don't remove if it's currently running (even if not in active profiles)
if running_set.contains(&browser_version) {
println!("Keeping: {browser} {version} (currently running)");
continue;
}
// Mark for removal
to_remove.push(browser_version);
println!("Marking for removal: {browser} {version} (not used by any profile)");
}
}
}
// 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::profile::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(
&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)> = {
let data = self.data.lock().unwrap();
data
.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)
}
/// Get all browsers and versions that are currently running
pub fn get_running_browser_versions(
&self,
profiles: &[crate::profile::BrowserProfile],
) -> Vec<(String, String)> {
profiles
.iter()
.filter(|profile| profile.process_id.is_some())
.map(|profile| (profile.browser.clone(), profile.version.clone()))
.collect()
}
/// Scan the binaries directory and sync with registry
/// This ensures the registry reflects what's actually on disk
pub fn sync_with_binaries_directory(
&self,
binaries_dir: &std::path::Path,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let mut changes = Vec::new();
if !binaries_dir.exists() {
return Ok(changes);
}
// Scan for actual browser directories
for browser_entry in fs::read_dir(binaries_dir)? {
let browser_entry = browser_entry?;
let browser_path = browser_entry.path();
if !browser_path.is_dir() {
continue;
}
let browser_name = browser_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if browser_name.is_empty() || browser_name.starts_with('.') {
continue;
}
// Scan for version directories within this browser
for version_entry in fs::read_dir(&browser_path)? {
let version_entry = version_entry?;
let version_path = version_entry.path();
if !version_path.is_dir() {
continue;
}
let version_name = version_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if version_name.is_empty() || version_name.starts_with('.') {
continue;
}
// Check if this browser/version is already in registry
if !self.is_browser_downloaded(browser_name, version_name) {
// Add to registry
let info = DownloadedBrowserInfo {
browser: browser_name.to_string(),
version: version_name.to_string(),
file_path: version_path.clone(),
};
self.add_browser(info);
changes.push(format!("Added {browser_name} {version_name} to registry"));
}
}
}
if !changes.is_empty() {
self.save()?;
}
Ok(changes)
}
/// Comprehensive cleanup that removes unused binaries and syncs registry
pub fn comprehensive_cleanup(
&self,
binaries_dir: &std::path::Path,
active_profiles: &[(String, String)],
running_profiles: &[(String, String)],
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let mut cleanup_results = Vec::new();
// First, sync registry with actual binaries on disk
let sync_results = self.sync_with_binaries_directory(binaries_dir)?;
cleanup_results.extend(sync_results);
// Then perform the regular cleanup
let regular_cleanup = self.cleanup_unused_binaries(active_profiles, running_profiles)?;
cleanup_results.extend(regular_cleanup);
// Finally, verify and cleanup stale entries
let stale_cleanup = self.verify_and_cleanup_stale_entries_simple(binaries_dir)?;
cleanup_results.extend(stale_cleanup);
if !cleanup_results.is_empty() {
self.save()?;
}
Ok(cleanup_results)
}
/// Simplified version of verify_and_cleanup_stale_entries that doesn't need BrowserRunner
pub fn verify_and_cleanup_stale_entries_simple(
&self,
binaries_dir: &std::path::Path,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let mut cleaned_up = Vec::new();
let mut browsers_to_remove = Vec::new();
{
let data = self.data.lock().unwrap();
for (browser_str, versions) in &data.browsers {
for version in versions.keys() {
// Check if the browser directory actually exists
let browser_dir = binaries_dir.join(browser_str).join(version);
if !browser_dir.exists() {
browsers_to_remove.push((browser_str.clone(), version.clone()));
}
}
}
}
// Remove stale entries
for (browser_str, version) in browsers_to_remove {
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
cleaned_up.push(format!(
"Removed stale registry entry for {browser_str} {version}"
));
}
}
Ok(cleaned_up)
}
}
// Global singleton instance
lazy_static::lazy_static! {
static ref DOWNLOADED_BROWSERS_REGISTRY: DownloadedBrowsersRegistry = {
let registry = DownloadedBrowsersRegistry::new();
if let Err(e) = registry.load() {
eprintln!("Warning: Failed to load downloaded browsers registry: {e}");
}
registry
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_creation() {
let registry = DownloadedBrowsersRegistry::new();
let data = registry.data.lock().unwrap();
assert!(data.browsers.is_empty());
}
#[test]
fn test_add_and_get_browser() {
let registry = DownloadedBrowsersRegistry::new();
let info = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
file_path: PathBuf::from("/test/path"),
};
registry.add_browser(info.clone());
assert!(registry.is_browser_downloaded("firefox", "139.0"));
assert!(!registry.is_browser_downloaded("firefox", "140.0"));
assert!(!registry.is_browser_downloaded("chrome", "139.0"));
}
#[test]
fn test_get_downloaded_versions() {
let registry = DownloadedBrowsersRegistry::new();
let info1 = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
file_path: PathBuf::from("/test/path1"),
};
let info2 = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "140.0".to_string(),
file_path: PathBuf::from("/test/path2"),
};
let info3 = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "141.0".to_string(),
file_path: PathBuf::from("/test/path3"),
};
registry.add_browser(info1);
registry.add_browser(info2);
registry.add_browser(info3);
let versions = registry.get_downloaded_versions("firefox");
assert_eq!(versions.len(), 3);
assert!(versions.contains(&"139.0".to_string()));
assert!(versions.contains(&"140.0".to_string()));
assert!(versions.contains(&"141.0".to_string()));
}
#[test]
fn test_mark_download_lifecycle() {
let registry = DownloadedBrowsersRegistry::new();
// Mark download started
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
// Should be considered downloaded immediately
assert!(
registry.is_browser_downloaded("firefox", "139.0"),
"Browser should be considered downloaded after marking as started"
);
// Mark as completed
registry
.mark_download_completed("firefox", "139.0")
.expect("Failed to mark download as completed");
// Should still be considered downloaded
assert!(
registry.is_browser_downloaded("firefox", "139.0"),
"Browser should still be considered downloaded after completion"
);
}
#[test]
fn test_remove_browser() {
let registry = DownloadedBrowsersRegistry::new();
let info = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
file_path: PathBuf::from("/test/path"),
};
registry.add_browser(info);
assert!(
registry.is_browser_downloaded("firefox", "139.0"),
"Browser should be downloaded after adding"
);
let removed = registry.remove_browser("firefox", "139.0");
assert!(
removed.is_some(),
"Remove operation should return the removed browser info"
);
assert!(
!registry.is_browser_downloaded("firefox", "139.0"),
"Browser should not be downloaded after removal"
);
}
#[test]
fn test_twilight_download() {
let registry = DownloadedBrowsersRegistry::new();
// Mark twilight download started
registry.mark_download_started("zen", "twilight", PathBuf::from("/test/zen-twilight"));
// Check that it's registered
assert!(
registry.is_browser_downloaded("zen", "twilight"),
"Zen twilight version should be registered as downloaded"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -2,12 +2,19 @@ use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Emitter;
use crate::api_client::ApiClient;
use crate::browser::BrowserType;
use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
std::sync::Arc::new(Mutex::new(std::collections::HashSet::new()));
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadProgress {
pub browser: String,
@@ -23,6 +30,10 @@ pub struct DownloadProgress {
pub struct Downloader {
client: Client,
api_client: &'static ApiClient,
registry: &'static crate::downloaded_browsers_registry::DownloadedBrowsersRegistry,
version_service: &'static crate::browser_version_manager::BrowserVersionManager,
extractor: &'static crate::extraction::Extractor,
geoip_downloader: &'static crate::geoip_downloader::GeoIPDownloader,
}
impl Downloader {
@@ -30,6 +41,10 @@ impl Downloader {
Self {
client: Client::new(),
api_client: ApiClient::instance(),
registry: crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(),
version_service: crate::browser_version_manager::BrowserVersionManager::instance(),
extractor: crate::extraction::Extractor::instance(),
geoip_downloader: crate::geoip_downloader::GeoIPDownloader::instance(),
}
}
@@ -42,6 +57,10 @@ impl Downloader {
Self {
client: Client::new(),
api_client: ApiClient::instance(),
registry: crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(),
version_service: crate::browser_version_manager::BrowserVersionManager::instance(),
extractor: crate::extraction::Extractor::instance(),
geoip_downloader: crate::geoip_downloader::GeoIPDownloader::instance(),
}
}
@@ -402,23 +421,44 @@ impl Downloader {
existing_size = meta.len();
}
// Build request, add Range only if we have bytes
let mut request = self
.client
.get(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
);
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
// Satisfiable), delete the partial file and retry once without the Range header.
let response = {
let mut request = self
.client
.get(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
);
if existing_size > 0 {
request = request.header("Range", format!("bytes={existing_size}-"));
}
if existing_size > 0 {
request = request.header("Range", format!("bytes={existing_size}-"));
}
// Start download (or resume)
let response = request.send().await?;
let first = request.send().await?;
// Check if the response is successful
if first.status().as_u16() == 416 && existing_size > 0 {
// Partial file on disk is not acceptable to the server — remove it and retry from scratch
let _ = std::fs::remove_file(&file_path);
existing_size = 0;
let retry = self
.client
.get(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
)
.send()
.await?;
retry
} else {
first
}
};
// Check if the response is successful (200 OK or 206 Partial Content)
if !(response.status().is_success() || response.status().as_u16() == 206) {
return Err(format!("Download failed with status: {}", response.status()).into());
}
@@ -552,6 +592,327 @@ impl Downloader {
Ok(file_path)
}
/// Download a browser binary, verify it, and register it in the downloaded browsers registry
pub async fn download_browser_full(
&self,
app_handle: &tauri::AppHandle,
browser_str: String,
version: String,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Check if this browser-version pair is already being downloaded
let download_key = format!("{browser_str}-{version}");
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
if downloading.contains(&download_key) {
return Err(format!("Browser '{browser_str}' version '{version}' is already being downloaded. Please wait for the current download to complete.").into());
}
// Mark this browser-version pair as being downloaded
downloading.insert(download_key.clone());
}
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
let browser = create_browser(browser_type.clone());
// Use injected registry instance
// Get binaries directory - we need to get it from somewhere
// This is a bit tricky since we don't have access to BrowserRunner's get_binaries_dir
// We'll need to replicate this logic
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
} else {
return Err("Failed to get base directories".into());
};
// Check if registry thinks it's downloaded, but also verify files actually exist
if self.registry.is_browser_downloaded(&browser_str, &version) {
let actually_exists = browser.is_version_downloaded(&version, &binaries_dir);
if actually_exists {
// Remove from downloading set since it's already downloaded
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
return Ok(version);
} else {
// Registry says it's downloaded but files don't exist - clean up registry
println!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry.");
self.registry.remove_browser(&browser_str, &version);
self
.registry
.save()
.map_err(|e| format!("Failed to save cleaned registry: {e}"))?;
}
}
// Check if browser is supported on current platform before attempting download
if !self
.version_service
.is_browser_supported(&browser_str)
.unwrap_or(false)
{
// Remove from downloading set on error
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
return Err(
format!(
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
browser_str,
std::env::consts::OS,
std::env::consts::ARCH,
self.version_service.get_supported_browsers().join(", ")
)
.into(),
);
}
let download_info = self
.version_service
.get_download_info(&browser_str, &version)
.map_err(|e| format!("Failed to get download info: {e}"))?;
// Create browser directory
let mut browser_dir = binaries_dir.clone();
browser_dir.push(&browser_str);
browser_dir.push(&version);
std::fs::create_dir_all(&browser_dir)
.map_err(|e| format!("Failed to create browser directory: {e}"))?;
// Mark download as started (but don't add to registry yet)
self
.registry
.mark_download_started(&browser_str, &version, browser_dir.clone());
// Attempt to download the archive. If the download fails but an archive with the
// expected filename already exists (manual download), continue using that file.
let download_path: PathBuf = match self
.download_browser(
app_handle,
browser_type.clone(),
&version,
&download_info,
&browser_dir,
)
.await
{
Ok(path) => path,
Err(e) => {
// Do NOT continue with extraction on failed downloads. Partial files may exist but are invalid.
// Clean registry entry and stop here so the UI can show a single, clear error.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
return Err(format!("Failed to download browser: {e}").into());
}
};
// Use the extraction module
if download_info.is_archive {
match self
.extractor
.extract_browser(
app_handle,
browser_type.clone(),
&version,
&download_path,
&browser_dir,
)
.await
{
Ok(_) => {
// Do not remove the archive here. We keep it until verification succeeds.
}
Err(e) => {
// Do not remove the archive or extracted files. Just drop the registry entry
// so it won't be reported as downloaded.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Remove browser-version pair from downloading set on error
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
return Err(format!("Failed to extract browser: {e}").into());
}
}
// Give filesystem a moment to settle after extraction
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
// Emit verification progress
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 100.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "verifying".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
// Verify the browser was downloaded correctly
println!("Verifying download for browser: {browser_str}, version: {version}");
// Use the browser's own verification method
if !browser.is_version_downloaded(&version, &binaries_dir) {
// Provide detailed error information for debugging
let browser_dir = binaries_dir.join(&browser_str).join(&version);
let mut error_details = format!(
"Browser download completed but verification failed for {} {}. Expected directory: {}",
browser_str,
version,
browser_dir.display()
);
// List what files actually exist
if browser_dir.exists() {
error_details.push_str("\nFiles found in directory:");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
let path = entry.path();
let file_type = if path.is_dir() { "DIR" } else { "FILE" };
error_details.push_str(&format!("\n {} {}", file_type, path.display()));
}
} else {
error_details.push_str("\n (Could not read directory contents)");
}
} else {
error_details.push_str("\nDirectory does not exist!");
}
// For Camoufox on Linux, provide specific expected files
if browser_str == "camoufox" && cfg!(target_os = "linux") {
let camoufox_subdir = browser_dir.join("camoufox");
error_details.push_str("\nExpected Camoufox executable locations:");
error_details.push_str(&format!("\n {}/camoufox-bin", camoufox_subdir.display()));
error_details.push_str(&format!("\n {}/camoufox", camoufox_subdir.display()));
if camoufox_subdir.exists() {
error_details.push_str(&format!(
"\nCamoufox subdirectory exists: {}",
camoufox_subdir.display()
));
if let Ok(entries) = std::fs::read_dir(&camoufox_subdir) {
error_details.push_str("\nFiles in camoufox subdirectory:");
for entry in entries.flatten() {
let path = entry.path();
let file_type = if path.is_dir() { "DIR" } else { "FILE" };
error_details.push_str(&format!("\n {} {}", file_type, path.display()));
}
}
} else {
error_details.push_str(&format!(
"\nCamoufox subdirectory does not exist: {}",
camoufox_subdir.display()
));
}
}
// Do not delete files on verification failure; keep archive for manual retry.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
return Err(error_details.into());
}
// Mark completion in registry - only now add to registry after verification
if let Err(e) =
self
.registry
.mark_download_completed(&browser_str, &version, browser_dir.clone())
{
eprintln!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
}
self
.registry
.save()
.map_err(|e| format!("Failed to save registry: {e}"))?;
// Now that verification succeeded, remove the archive file if it exists
if download_info.is_archive {
let archive_path = browser_dir.join(&download_info.filename);
if archive_path.exists() {
if let Err(e) = std::fs::remove_file(&archive_path) {
println!("Warning: Could not delete archive file after verification: {e}");
}
}
}
// If this is Camoufox, automatically download GeoIP database
if browser_str == "camoufox" {
// Check if GeoIP database is already available
if !crate::geoip_downloader::GeoIPDownloader::is_geoip_database_available() {
println!("Downloading GeoIP database for Camoufox...");
match self
.geoip_downloader
.download_geoip_database(app_handle)
.await
{
Ok(_) => {
println!("GeoIP database downloaded successfully");
}
Err(e) => {
eprintln!("Failed to download GeoIP database: {e}");
// Don't fail the browser download if GeoIP download fails
}
}
} else {
println!("GeoIP database already available");
}
}
// Emit completion
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 100.0,
speed_bytes_per_sec: 0.0,
eta_seconds: Some(0.0),
stage: "completed".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
// Remove browser-version pair from downloading set
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
Ok(version)
}
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
browser_str: String,
version: String,
) -> Result<String, String> {
let downloader = Downloader::instance();
downloader
.download_browser_full(&app_handle, browser_str, version)
.await
.map_err(|e| format!("Failed to download browser: {e}"))
}
#[cfg(test)]
+1 -1
View File
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use tauri::Emitter;
use crate::browser::BrowserType;
use crate::download::DownloadProgress;
use crate::downloader::DownloadProgress;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::process::Command;
+33 -5
View File
@@ -1,4 +1,5 @@
use crate::browser::GithubRelease;
use crate::profile::manager::ProfileManager;
use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
@@ -75,6 +76,25 @@ impl GeoIPDownloader {
false
}
}
/// Check if GeoIP database is missing for Camoufox profiles
pub fn check_missing_geoip_database(
&self,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
// Get all profiles
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
// Check if there are any Camoufox profiles
let has_camoufox_profiles = profiles.iter().any(|profile| profile.browser == "camoufox");
if has_camoufox_profiles {
// Check if GeoIP database is available
return Ok(!Self::is_geoip_database_available());
}
Ok(false)
}
fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option<String> {
for asset in &release.assets {
@@ -218,6 +238,19 @@ impl GeoIPDownloader {
}
}
#[tauri::command]
pub fn check_missing_geoip_database() -> Result<bool, String> {
let geoip_downloader = GeoIPDownloader::instance();
geoip_downloader
.check_missing_geoip_database()
.map_err(|e| format!("Failed to check missing GeoIP database: {e}"))
}
// Global singleton instance
lazy_static::lazy_static! {
static ref GEOIP_DOWNLOADER: GeoIPDownloader = GeoIPDownloader::new();
}
#[cfg(test)]
mod tests {
use super::*;
@@ -353,8 +386,3 @@ mod tests {
);
}
}
// Global singleton instance
lazy_static::lazy_static! {
static ref GEOIP_DOWNLOADER: GeoIPDownloader = GeoIPDownloader::new();
}
+89 -282
View File
@@ -2,8 +2,9 @@ use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Emitter;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileGroup {
@@ -25,16 +26,37 @@ struct GroupsData {
pub struct GroupManager {
base_dirs: BaseDirs,
data_dir_override: Option<PathBuf>,
}
impl GroupManager {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
.ok()
.map(PathBuf::from),
}
}
// Helper for tests to override data directory without global env var
#[allow(dead_code)]
pub fn with_data_dir_override(dir: &Path) -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: Some(dir.to_path_buf()),
}
}
fn get_groups_file_path(&self) -> PathBuf {
if let Some(dir) = &self.data_dir_override {
let mut override_path = dir.clone();
// Ensure the directory exists before returning the path
let _ = fs::create_dir_all(&override_path);
override_path.push("groups.json");
return override_path;
}
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -76,7 +98,11 @@ impl GroupManager {
Ok(groups_data.groups)
}
pub fn create_group(&self, name: String) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
pub fn create_group(
&self,
app_handle: &tauri::AppHandle,
name: String,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
let mut groups_data = self.load_groups_data()?;
// Check if group with this name already exists
@@ -92,11 +118,17 @@ impl GroupManager {
groups_data.groups.push(group.clone());
self.save_groups_data(&groups_data)?;
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
eprintln!("Failed to emit groups-changed event: {e}");
}
Ok(group)
}
pub fn update_group(
&self,
app_handle: &tauri::AppHandle,
id: String,
name: String,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
@@ -121,10 +153,20 @@ impl GroupManager {
let updated_group = group.clone();
self.save_groups_data(&groups_data)?;
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
eprintln!("Failed to emit groups-changed event: {e}");
}
Ok(updated_group)
}
pub fn delete_group(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
pub fn delete_group(
&self,
app_handle: &tauri::AppHandle,
id: String,
) -> Result<(), Box<dyn std::error::Error>> {
let mut groups_data = self.load_groups_data()?;
let initial_len = groups_data.groups.len();
@@ -135,6 +177,12 @@ impl GroupManager {
}
self.save_groups_data(&groups_data)?;
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
eprintln!("Failed to emit groups-changed event: {e}");
}
Ok(())
}
@@ -152,29 +200,26 @@ impl GroupManager {
}
}
// Create result with counts
// Create result including all groups (even those with 0 count)
let mut result = Vec::new();
for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0);
if count > 0 {
result.push(GroupWithCount {
id: group.id,
name: group.name,
count,
});
}
result.push(GroupWithCount {
id: group.id,
name: group.name,
count,
});
}
// Add default group count (profiles without group_id)
// Add default group count (profiles without group_id), always include even if 0
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
if default_count > 0 {
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
};
result.insert(0, default_group);
}
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
};
// Insert at the beginning for consistent ordering with UI expectations
result.insert(0, default_group);
Ok(result)
}
@@ -185,258 +230,6 @@ lazy_static::lazy_static! {
pub static ref GROUP_MANAGER: Mutex<GroupManager> = Mutex::new(GroupManager::new());
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use tempfile::TempDir;
fn create_test_group_manager() -> (GroupManager, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
// Set up a temporary home directory for testing
env::set_var("HOME", temp_dir.path());
let manager = GroupManager::new();
(manager, temp_dir)
}
#[test]
fn test_group_manager_creation() {
let (_manager, _temp_dir) = create_test_group_manager();
// Test passes if no panic occurs
}
#[test]
fn test_create_and_get_groups() {
let (manager, _temp_dir) = create_test_group_manager();
// Initially should have no groups
let groups = manager
.get_all_groups()
.expect("Should be able to get groups");
assert!(groups.is_empty(), "Should start with no groups");
// Create a group
let group_name = "Test Group".to_string();
let created_group = manager
.create_group(group_name.clone())
.expect("Should create group successfully");
assert_eq!(
created_group.name, group_name,
"Created group should have correct name"
);
assert!(
!created_group.id.is_empty(),
"Created group should have an ID"
);
// Verify group was saved
let groups = manager
.get_all_groups()
.expect("Should be able to get groups");
assert_eq!(groups.len(), 1, "Should have one group");
assert_eq!(
groups[0].name, group_name,
"Retrieved group should have correct name"
);
assert_eq!(
groups[0].id, created_group.id,
"Retrieved group should have correct ID"
);
}
#[test]
fn test_create_duplicate_group_fails() {
let (manager, _temp_dir) = create_test_group_manager();
let group_name = "Duplicate Group".to_string();
// Create first group
let _first_group = manager
.create_group(group_name.clone())
.expect("Should create first group");
// Try to create duplicate group
let result = manager.create_group(group_name.clone());
assert!(result.is_err(), "Should fail to create duplicate group");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("already exists"),
"Error should mention group already exists"
);
}
#[test]
fn test_update_group() {
let (manager, _temp_dir) = create_test_group_manager();
// Create a group
let original_name = "Original Name".to_string();
let created_group = manager
.create_group(original_name)
.expect("Should create group");
// Update the group
let new_name = "Updated Name".to_string();
let updated_group = manager
.update_group(created_group.id.clone(), new_name.clone())
.expect("Should update group successfully");
assert_eq!(
updated_group.name, new_name,
"Updated group should have new name"
);
assert_eq!(
updated_group.id, created_group.id,
"Updated group should keep same ID"
);
// Verify update was persisted
let groups = manager.get_all_groups().expect("Should get groups");
assert_eq!(groups.len(), 1, "Should still have one group");
assert_eq!(
groups[0].name, new_name,
"Persisted group should have updated name"
);
}
#[test]
fn test_update_nonexistent_group_fails() {
let (manager, _temp_dir) = create_test_group_manager();
let result = manager.update_group("nonexistent-id".to_string(), "New Name".to_string());
assert!(result.is_err(), "Should fail to update nonexistent group");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("not found"),
"Error should mention group not found"
);
}
#[test]
fn test_delete_group() {
let (manager, _temp_dir) = create_test_group_manager();
// Create a group
let group_name = "To Delete".to_string();
let created_group = manager
.create_group(group_name)
.expect("Should create group");
// Verify group exists
let groups = manager.get_all_groups().expect("Should get groups");
assert_eq!(groups.len(), 1, "Should have one group");
// Delete the group
manager
.delete_group(created_group.id)
.expect("Should delete group successfully");
// Verify group was deleted
let groups = manager.get_all_groups().expect("Should get groups");
assert!(groups.is_empty(), "Should have no groups after deletion");
}
#[test]
fn test_delete_nonexistent_group_fails() {
let (manager, _temp_dir) = create_test_group_manager();
let result = manager.delete_group("nonexistent-id".to_string());
assert!(result.is_err(), "Should fail to delete nonexistent group");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("not found"),
"Error should mention group not found"
);
}
#[test]
fn test_get_groups_with_profile_counts() {
let (manager, _temp_dir) = create_test_group_manager();
// Create test groups
let group1 = manager
.create_group("Group 1".to_string())
.expect("Should create group 1");
let _group2 = manager
.create_group("Group 2".to_string())
.expect("Should create group 2");
// Create mock profiles
let profiles = vec![
crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "Profile 1".to_string(),
browser: "firefox".to_string(),
version: "1.0".to_string(),
proxy_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
group_id: Some(group1.id.clone()),
},
crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "Profile 2".to_string(),
browser: "firefox".to_string(),
version: "1.0".to_string(),
proxy_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
group_id: Some(group1.id.clone()),
},
crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "Profile 3".to_string(),
browser: "firefox".to_string(),
version: "1.0".to_string(),
proxy_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None, // Default group
},
];
let groups_with_counts = manager
.get_groups_with_profile_counts(&profiles)
.expect("Should get groups with counts");
// Should have default group + group1 (_group2 has no profiles so shouldn't appear)
assert_eq!(
groups_with_counts.len(),
2,
"Should have 2 groups with profiles"
);
// Check default group
let default_group = groups_with_counts
.iter()
.find(|g| g.id == "default")
.expect("Should have default group");
assert_eq!(
default_group.count, 1,
"Default group should have 1 profile"
);
// Check group1
let group1_with_count = groups_with_counts
.iter()
.find(|g| g.id == group1.id)
.expect("Should have group1");
assert_eq!(group1_with_count.count, 2, "Group1 should have 2 profiles");
}
}
// Helper function to get groups with counts
pub fn get_groups_with_counts(profiles: &[crate::profile::BrowserProfile]) -> Vec<GroupWithCount> {
let group_manager = GROUP_MANAGER.lock().unwrap();
@@ -464,44 +257,58 @@ pub async fn get_groups_with_profile_counts() -> Result<Vec<GroupWithCount>, Str
}
#[tauri::command]
pub async fn create_profile_group(name: String) -> Result<ProfileGroup, String> {
pub async fn create_profile_group(
app_handle: tauri::AppHandle,
name: String,
) -> Result<ProfileGroup, String> {
let group_manager = GROUP_MANAGER.lock().unwrap();
group_manager
.create_group(name)
.create_group(&app_handle, name)
.map_err(|e| format!("Failed to create group: {e}"))
}
#[tauri::command]
pub async fn update_profile_group(group_id: String, name: String) -> Result<ProfileGroup, String> {
pub async fn update_profile_group(
app_handle: tauri::AppHandle,
group_id: String,
name: String,
) -> Result<ProfileGroup, String> {
let group_manager = GROUP_MANAGER.lock().unwrap();
group_manager
.update_group(group_id, name)
.update_group(&app_handle, group_id, name)
.map_err(|e| format!("Failed to update group: {e}"))
}
#[tauri::command]
pub async fn delete_profile_group(group_id: String) -> Result<(), String> {
pub async fn delete_profile_group(
app_handle: tauri::AppHandle,
group_id: String,
) -> Result<(), String> {
let group_manager = GROUP_MANAGER.lock().unwrap();
group_manager
.delete_group(group_id)
.delete_group(&app_handle, group_id)
.map_err(|e| format!("Failed to delete group: {e}"))
}
#[tauri::command]
pub async fn assign_profiles_to_group(
profile_names: Vec<String>,
app_handle: tauri::AppHandle,
profile_ids: Vec<String>,
group_id: Option<String>,
) -> Result<(), String> {
let profile_manager = crate::profile::ProfileManager::instance();
profile_manager
.assign_profiles_to_group(profile_names, group_id)
.assign_profiles_to_group(&app_handle, profile_ids, group_id)
.map_err(|e| format!("Failed to assign profiles to group: {e}"))
}
#[tauri::command]
pub async fn delete_selected_profiles(profile_names: Vec<String>) -> Result<(), String> {
pub async fn delete_selected_profiles(
app_handle: tauri::AppHandle,
profile_ids: Vec<String>,
) -> Result<(), String> {
let profile_manager = crate::profile::ProfileManager::instance();
profile_manager
.delete_multiple_profiles(profile_names)
.delete_multiple_profiles(&app_handle, profile_ids)
.map_err(|e| format!("Failed to delete profiles: {e}"))
}
+202 -55
View File
@@ -8,15 +8,16 @@ use tauri_plugin_deep_link::DeepLinkExt;
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
mod api_client;
mod api_server;
mod app_auto_updater;
mod auto_updater;
mod browser;
mod browser_runner;
mod browser_version_manager;
mod camoufox;
mod camoufox_manager;
mod default_browser;
mod download;
mod downloaded_browsers;
mod downloaded_browsers_registry;
mod downloader;
mod extraction;
mod geoip_downloader;
mod group_manager;
@@ -26,34 +27,46 @@ mod profile_importer;
mod proxy_manager;
mod settings_manager;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
mod tag_manager;
mod version_updater;
extern crate lazy_static;
use browser_runner::{
check_browser_exists, check_browser_status, check_missing_binaries, check_missing_geoip_database,
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_downloaded_browser_versions,
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
launch_browser_profile, list_browser_profiles, rename_profile, update_camoufox_config,
update_profile_proxy,
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
};
use profile::manager::{
check_browser_status, create_browser_profile_new, delete_profile, list_browser_profiles,
rename_profile, update_camoufox_config, update_profile_proxy, update_profile_tags,
};
use browser_version_manager::{
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_supported_browsers,
is_browser_supported_on_platform,
};
use downloaded_browsers_registry::{
check_missing_binaries, ensure_all_binaries_exist, get_downloaded_browser_versions,
};
use downloader::download_browser;
use settings_manager::{
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,
get_app_settings, get_table_sorting_settings, save_app_settings, save_table_sorting_settings,
should_show_settings_on_startup,
};
use default_browser::{is_default_browser, open_url_with_profile, set_as_default_browser};
use tag_manager::get_all_tags;
use default_browser::{is_default_browser, set_as_default_browser};
use version_updater::{
get_version_update_status, get_version_updater, trigger_manual_version_update,
clear_all_version_cache_and_refetch, get_version_update_status, get_version_updater,
trigger_manual_version_update,
};
use auto_updater::{
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
is_browser_disabled_for_update,
};
use app_auto_updater::{
@@ -62,17 +75,17 @@ use app_auto_updater::{
use profile_importer::{detect_existing_profiles, import_browser_profile};
// use theme_detector::get_system_theme;
use group_manager::{
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
};
use geoip_downloader::GeoIPDownloader;
use geoip_downloader::{check_missing_geoip_database, GeoIPDownloader};
use browser_version_manager::get_browser_release_types;
use api_server::{get_api_server_status, start_api_server, stop_api_server};
// Trait to extend WebviewWindow with transparent titlebar functionality
pub trait WindowExt {
#[cfg(target_os = "macos")]
@@ -118,6 +131,35 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
}
}
#[tauri::command]
async fn warm_up_nodecar(app: tauri::AppHandle) -> Result<(), String> {
use tauri_plugin_shell::ShellExt;
use tokio::time::{timeout, Duration};
let start_time = std::time::Instant::now();
// Use sidecar to execute a fast, harmless command that ensures the binary is loaded
let cmd = app
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("help");
let exec_future = async { cmd.output().await };
match timeout(Duration::from_secs(120), exec_future).await {
Ok(Ok(_output)) => {
let duration = start_time.elapsed();
println!(
"Nodecar warm-up (frontend-triggered) completed in {:.2}s",
duration.as_secs_f64()
);
Ok(())
}
Ok(Err(e)) => Err(format!("Failed to execute nodecar for warm-up: {e}")),
Err(_) => Err("Nodecar warm-up timed out after 120s".to_string()),
}
}
#[tauri::command]
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
println!("handle_url_open called with URL: {url}");
@@ -146,11 +188,12 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
#[tauri::command]
async fn create_stored_proxy(
app_handle: tauri::AppHandle,
name: String,
proxy_settings: crate::browser::ProxySettings,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(name, proxy_settings)
.create_stored_proxy(&app_handle, name, proxy_settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
}
@@ -161,19 +204,20 @@ async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>,
#[tauri::command]
async fn update_stored_proxy(
app_handle: tauri::AppHandle,
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)
.update_stored_proxy(&app_handle, &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> {
async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) -> Result<(), String> {
crate::proxy_manager::PROXY_MANAGER
.delete_stored_proxy(&proxy_id)
.delete_stored_proxy(&app_handle, &proxy_id)
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
}
@@ -349,8 +393,9 @@ pub fn run() {
loop {
interval.tick().await;
let browser_runner = crate::browser_runner::BrowserRunner::instance();
if let Err(e) = browser_runner.cleanup_unused_binaries_internal() {
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
eprintln!("Periodic cleanup failed: {e}");
} else {
println!("Periodic cleanup completed successfully");
@@ -387,14 +432,14 @@ pub fn run() {
// Start Camoufox cleanup task
let _app_handle_cleanup = app.handle().clone();
tauri::async_runtime::spawn(async move {
let launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
let camoufox_manager = crate::camoufox_manager::CamoufoxManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
loop {
interval.tick().await;
match launcher.cleanup_dead_instances().await {
Ok(_dead_instances) => {
match camoufox_manager.cleanup_dead_instances().await {
Ok(_) => {
// Cleanup completed silently
}
Err(e) => {
@@ -410,8 +455,8 @@ pub fn run() {
// Wait a bit for the app to fully initialize
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let browser_runner = crate::browser_runner::BrowserRunner::instance();
match browser_runner.check_missing_geoip_database() {
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
println!("GeoIP database is missing for Camoufox profiles, downloading at startup...");
let geoip_downloader = GeoIPDownloader::instance();
@@ -460,33 +505,131 @@ pub fn run() {
}
});
// Warm up nodecar binary in the background
// Periodically broadcast browser running status to the frontend
let app_handle_status = app.handle().clone();
tauri::async_runtime::spawn(async move {
println!("Starting nodecar warm-up...");
let start_time = std::time::Instant::now();
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500));
let mut last_running_states: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
// Send a ping request to nodecar to trigger unpacking/warm-up
match tokio::process::Command::new("nodecar").output().await {
Ok(output) => {
let duration = start_time.elapsed();
if output.status.success() {
println!(
"Nodecar warm-up completed successfully in {:.2}s",
duration.as_secs_f64()
);
} else {
println!(
"Nodecar warm-up completed with non-zero exit code in {:.2}s",
duration.as_secs_f64()
);
loop {
interval.tick().await;
let runner = crate::browser_runner::BrowserRunner::instance();
// If listing profiles fails, skip this tick
let profiles = match runner.profile_manager.list_profiles() {
Ok(p) => p,
Err(e) => {
println!("Warning: Failed to list profiles in status checker: {e}");
continue;
}
};
for profile in profiles {
// Check browser status and track changes
match runner
.check_browser_status(app_handle_status.clone(), &profile)
.await
{
Ok(is_running) => {
let profile_id = profile.id.to_string();
let last_state = last_running_states
.get(&profile_id)
.copied()
.unwrap_or(false);
// Only emit event if state actually changed
if last_state != is_running {
println!(
"Status checker detected change for profile {}: {} -> {}",
profile.name, last_state, is_running
);
#[derive(serde::Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: profile_id.clone(),
is_running,
};
if let Err(e) = app_handle_status.emit("profile-running-changed", &payload) {
println!("Warning: Failed to emit profile running changed event: {e}");
} else {
println!(
"Status checker emitted profile-running-changed event for {}: running={}",
profile.name, is_running
);
}
last_running_states.insert(profile_id, is_running);
} else {
// Update the state even if unchanged to ensure we have it tracked
last_running_states.insert(profile_id, is_running);
}
}
Err(e) => {
println!(
"Warning: Status check failed for profile {}: {}",
profile.name, e
);
continue;
}
}
}
}
});
// Nodecar warm-up is now triggered from the frontend to allow UI blocking overlay
// Start API server if enabled in settings
let app_handle_api = app.handle().clone();
tauri::async_runtime::spawn(async move {
match crate::settings_manager::get_app_settings(app_handle_api.clone()).await {
Ok(settings) => {
if settings.api_enabled {
println!("API is enabled in settings, starting API server...");
match crate::api_server::start_api_server_internal(settings.api_port, &app_handle_api)
.await
{
Ok(port) => {
println!("API server started successfully on port {port}");
// Emit success toast to frontend
if let Err(e) = app_handle_api.emit(
"show-toast",
crate::api_server::ToastPayload {
message: "API server started successfully".to_string(),
variant: "success".to_string(),
title: "Local API Started".to_string(),
description: Some(format!("API server running on port {port}")),
},
) {
eprintln!("Failed to emit API start toast: {e}");
}
}
Err(e) => {
eprintln!("Failed to start API server at startup: {e}");
// Emit error toast to frontend
if let Err(toast_err) = app_handle_api.emit(
"show-toast",
crate::api_server::ToastPayload {
message: "Failed to start API server".to_string(),
variant: "error".to_string(),
title: "Failed to Start Local API".to_string(),
description: Some(format!("Error: {e}")),
},
) {
eprintln!("Failed to emit API error toast: {toast_err}");
}
}
}
}
}
Err(e) => {
let duration = start_time.elapsed();
println!(
"Nodecar warm-up failed after {:.2}s: {e}",
duration.as_secs_f64()
);
eprintln!("Failed to load app settings for API startup: {e}");
}
}
});
@@ -506,8 +649,10 @@ pub fn run() {
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_downloaded_browser_versions,
get_all_tags,
get_browser_release_types,
update_profile_proxy,
update_profile_tags,
check_browser_status,
kill_browser_profile,
rename_profile,
@@ -523,13 +668,11 @@ pub fn run() {
trigger_manual_version_update,
get_version_update_status,
check_for_browser_updates,
is_browser_disabled_for_update,
dismiss_update_notification,
complete_browser_update_with_auto_update,
check_for_app_updates,
check_for_app_updates_manual,
download_and_install_app_update,
// get_system_theme, // removed
detect_existing_profiles,
import_browser_profile,
check_missing_binaries,
@@ -549,6 +692,10 @@ pub fn run() {
delete_selected_profiles,
is_geoip_database_available,
download_geoip_database,
warm_up_nodecar,
start_api_server,
stop_api_server,
get_api_server_status
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+257 -69
View File
@@ -48,7 +48,38 @@ pub mod macos {
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
println!("Launching browser on macOS: {executable_path:?} with args: {args:?}");
Ok(Command::new(executable_path).args(args).spawn()?)
// If the executable is inside an app bundle, launch via Launch Services so
// macOS recognizes the real application for privacy permissions (e.g. Screen Recording).
// This ensures TCC prompts are attributed to the browser app, not our launcher.
let mut current = Some(executable_path);
let mut app_bundle: Option<std::path::PathBuf> = None;
while let Some(path) = current {
if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
if file_name.ends_with(".app") {
app_bundle = Some(path.to_path_buf());
break;
}
}
current = path.parent();
}
if let Some(app_path) = app_bundle {
// Use `open -n -a <App>.app --args ...` to launch the app bundle.
// Note: The returned child PID will belong to `open`, not the browser.
// The caller should resolve the actual browser PID after launch.
let mut cmd = Command::new("open");
cmd.arg("-n");
cmd.arg("-a");
cmd.arg(app_path);
cmd.arg("--args");
for a in args {
cmd.arg(a);
}
Ok(cmd.spawn()?)
} else {
// Fallback: direct spawn if this is not an app bundle
Ok(Command::new(executable_path).args(args).spawn()?)
}
}
pub async fn open_url_in_existing_browser_firefox_like(
@@ -183,6 +214,227 @@ end try
Ok(())
}
pub async fn kill_browser_process_impl(
pid: u32,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Attempting to kill browser process with PID: {pid}");
// For Chromium-based browsers, use immediate aggressive termination
// Chromium browsers are notoriously difficult to kill on macOS due to process spawning
// Step 1: Immediate SIGKILL on main process (no graceful shutdown for Chromium)
println!("Starting immediate SIGKILL for PID: {pid}");
let _ = Command::new("kill")
.args(["-KILL", &pid.to_string()])
.output();
// Step 2: Comprehensive process tree termination using multiple methods simultaneously
let _ = kill_chromium_process_tree_aggressive(pid).await;
// Step 2.5: Nuclear option - kill all Chromium processes by name pattern
let _ = kill_all_chromium_processes_by_name().await;
// Step 3: Use multiple kill strategies in parallel
let pid_str = pid.to_string();
// Kill by parent PID with SIGKILL
let _ = Command::new("pkill")
.args(["-KILL", "-P", &pid_str])
.output();
// Kill by process group with SIGKILL
let _ = Command::new("pkill")
.args(["-KILL", "-g", &pid_str])
.output();
// Kill by session ID
let _ = Command::new("pkill")
.args(["-KILL", "-s", &pid_str])
.output();
// Wait briefly for initial termination
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
// Step 4: Verify and retry with pattern-based killing for common Chromium process names
use sysinfo::{Pid, System};
let system = System::new_all();
// Check if main process still exists
if system.process(Pid::from(pid as usize)).is_some() {
println!("Main process {pid} still running, using pattern-based termination");
// Kill by common Chromium process patterns
let chromium_patterns = [
"Chrome",
"Chromium",
"Brave",
"chrome",
"chromium",
"brave",
"Google Chrome",
"Brave Browser",
"Chrome Helper",
"Chromium Helper",
];
for pattern in &chromium_patterns {
let _ = Command::new("pkill")
.args(["-KILL", "-f", pattern])
.output();
}
}
// Step 5: Final aggressive cleanup - kill any remaining processes
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
// One more round of comprehensive killing
let _ = Command::new("pkill")
.args(["-KILL", "-P", &pid_str])
.output();
let _ = Command::new("pkill")
.args(["-KILL", "-g", &pid_str])
.output();
// Final verification with extended wait
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let system = System::new_all();
if system.process(Pid::from(pid as usize)).is_some() {
// Last resort: try system kill command with different signals
println!("Process {pid} extremely persistent, trying system-level termination");
let _ = Command::new("/bin/kill").args(["-KILL", &pid_str]).output();
let _ = Command::new("/usr/bin/killall")
.args(["-KILL", "-m", "Chrome"])
.output();
let _ = Command::new("/usr/bin/killall")
.args(["-KILL", "-m", "Chromium"])
.output();
let _ = Command::new("/usr/bin/killall")
.args(["-KILL", "-m", "Brave"])
.output();
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let system = System::new_all();
if system.process(Pid::from(pid as usize)).is_some() {
println!("WARNING: Process {pid} could not be terminated despite aggressive attempts");
// Don't return error - let the UI update anyway since we tried everything
}
}
println!("Aggressive browser termination completed for PID: {pid}");
Ok(())
}
// Helper function to kill process tree (Chromium browsers often spawn child processes)
async fn kill_chromium_process_tree_aggressive(
pid: u32,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Killing comprehensive process tree for PID: {pid}");
// Get all descendant processes using recursive process tree discovery
let descendant_pids = get_all_descendant_pids(pid).await;
println!(
"Found {} descendant processes to terminate",
descendant_pids.len()
);
// Kill all descendants first (reverse order - children before parents)
for &desc_pid in descendant_pids.iter().rev() {
if desc_pid != pid {
println!("Terminating descendant process: {desc_pid}");
let _ = Command::new("kill")
.args(["-KILL", &desc_pid.to_string()])
.output();
}
}
// No delay for initial termination
// Force kill any remaining descendants
for &desc_pid in descendant_pids.iter().rev() {
if desc_pid != pid {
let _ = Command::new("kill")
.args(["-KILL", &desc_pid.to_string()])
.output();
}
}
// Also use pkill as a backup to catch any processes we might have missed
let _ = Command::new("pkill")
.args(["-KILL", "-P", &pid.to_string()])
.output();
// On macOS, also try killing by process group for Chromium browsers
let _ = Command::new("pkill")
.args(["-KILL", "-g", &pid.to_string()])
.output();
Ok(())
}
// Helper function to kill all Chromium-related processes by name patterns
async fn kill_all_chromium_processes_by_name(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Killing all Chromium-related processes by name patterns");
let chromium_patterns = [
"Chrome",
"Chromium",
"Brave",
"chrome",
"chromium",
"brave",
"Google Chrome",
"Brave Browser",
"Chrome Helper",
"Chromium Helper",
];
for pattern in &chromium_patterns {
let _ = Command::new("pkill")
.args(["-KILL", "-f", pattern])
.output();
}
Ok(())
}
// Recursively find all descendant processes
async fn get_all_descendant_pids(parent_pid: u32) -> Vec<u32> {
use sysinfo::System;
let system = System::new_all();
let mut descendants = Vec::new();
let mut to_check = vec![parent_pid];
let mut checked = std::collections::HashSet::new();
while let Some(current_pid) = to_check.pop() {
if checked.contains(&current_pid) {
continue;
}
checked.insert(current_pid);
// Find direct children of current_pid
for (pid, process) in system.processes() {
let pid_u32 = pid.as_u32();
if let Some(parent) = process.parent() {
if parent.as_u32() == current_pid && !checked.contains(&pid_u32) {
descendants.push(pid_u32);
to_check.push(pid_u32);
}
}
}
}
descendants
}
pub async fn open_url_in_existing_browser_tor_mullvad(
profile: &BrowserProfile,
url: &str,
@@ -424,39 +676,6 @@ end try
Ok(())
}
pub async fn kill_browser_process_impl(
pid: u32,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Attempting to kill browser process with PID: {pid}");
// First try SIGTERM (graceful shutdown)
let output = Command::new("kill")
.args(["-TERM", &pid.to_string()])
.output()
.map_err(|e| format!("Failed to execute kill command: {e}"))?;
if !output.status.success() {
// If SIGTERM fails, try SIGKILL (force kill)
let output = Command::new("kill")
.args(["-KILL", &pid.to_string()])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to kill process {}: {}",
pid,
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
}
println!("Successfully killed browser process with PID: {pid}");
Ok(())
}
}
#[cfg(target_os = "windows")]
@@ -696,41 +915,10 @@ pub mod windows {
cmd.current_dir(parent_dir);
}
let output = cmd.output()?;
if !output.status.success() {
// Try fallback without --new-window
let mut fallback_cmd = Command::new(&executable_path);
fallback_cmd.args([
&format!(
"--user-data-dir={}",
profile
.get_profile_data_path(profiles_dir)
.to_string_lossy()
),
url,
]);
if let Some(parent_dir) = browser_dir
.parent()
.or_else(|| browser_dir.ancestors().nth(1))
{
fallback_cmd.current_dir(parent_dir);
}
let fallback_output = fallback_cmd.output()?;
if !fallback_output.status.success() {
return Err(
format!(
"Failed to open URL in existing Chromium-based browser: {}",
String::from_utf8_lossy(&fallback_output.stderr)
)
.into(),
);
}
}
// Do not call output() to avoid blocking the UI thread while the browser processes the request.
// Spawn the helper process and return immediately. This applies to Chromium-based browsers
// including Brave to prevent UI freezes observed in production.
let _child = cmd.spawn()?;
Ok(())
}
+489 -202
View File
@@ -1,5 +1,7 @@
use crate::api_client::is_browser_version_nightly;
use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::camoufox::CamoufoxConfig;
use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::profile::types::BrowserProfile;
use crate::proxy_manager::PROXY_MANAGER;
use directories::BaseDirs;
@@ -10,12 +12,14 @@ use tauri::Emitter;
pub struct ProfileManager {
base_dirs: BaseDirs,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
}
impl ProfileManager {
fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
}
}
@@ -34,6 +38,17 @@ impl ProfileManager {
path
}
pub fn get_binaries_dir(&self) -> PathBuf {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
}
#[allow(clippy::too_many_arguments)]
pub async fn create_profile_with_group(
&self,
@@ -72,13 +87,12 @@ impl ProfileManager {
let final_camoufox_config = if browser == "camoufox" {
let mut config = camoufox_config.unwrap_or_else(|| {
println!("Creating default Camoufox config for profile: {name}");
crate::camoufox::CamoufoxConfig::default()
crate::camoufox_manager::CamoufoxConfig::default()
});
// Always ensure executable_path is set to the user's binary location
if config.executable_path.is_none() {
let browser_runner = crate::browser_runner::BrowserRunner::instance();
let mut browser_dir = browser_runner.get_binaries_dir();
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(browser);
browser_dir.push(version);
@@ -137,7 +151,6 @@ impl ProfileManager {
println!("Generating fingerprint for Camoufox profile: {name}");
// Use the camoufox launcher to generate the config
let camoufox_launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
// Create a temporary profile for fingerprint generation
let temp_profile = BrowserProfile {
@@ -151,9 +164,11 @@ impl ProfileManager {
release_type: release_type.to_string(),
camoufox_config: None,
group_id: group_id.clone(),
tags: Vec::new(),
};
match camoufox_launcher
match self
.camoufox_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
@@ -191,6 +206,7 @@ impl ProfileManager {
release_type: release_type.to_string(),
camoufox_config: final_camoufox_config,
group_id: group_id.clone(),
tags: Vec::new(),
};
// Save profile info
@@ -216,6 +232,11 @@ impl ProfileManager {
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
}
// Emit profile creation event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
@@ -230,6 +251,11 @@ impl ProfileManager {
let json = serde_json::to_string_pretty(profile)?;
fs::write(profile_file, json)?;
// Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
});
Ok(())
}
@@ -260,7 +286,8 @@ impl ProfileManager {
pub fn rename_profile(
&self,
old_name: &str,
app_handle: &tauri::AppHandle,
profile_id: &str,
new_name: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Check if new name already exists (case insensitive)
@@ -272,11 +299,13 @@ impl ProfileManager {
return Err(format!("Profile with name '{new_name}' already exists").into());
}
// Find the profile by old name
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let mut profile = existing_profiles
.into_iter()
.find(|p| p.name == old_name)
.ok_or_else(|| format!("Profile '{old_name}' not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
@@ -284,18 +313,34 @@ impl ProfileManager {
// Save profile with new name
self.save_profile(&profile)?;
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
});
// Emit profile rename event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn delete_profile(&self, profile_name: &str) -> Result<(), Box<dyn std::error::Error>> {
println!("Attempting to delete profile: {profile_name}");
pub fn delete_profile(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Attempting to delete profile with ID: {profile_id}");
// Find the profile by name
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
@@ -316,25 +361,46 @@ impl ProfileManager {
// Verify deletion was successful
if profile_uuid_dir.exists() {
return Err(format!("Failed to completely delete profile '{profile_name}'").into());
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
}
println!("Profile '{profile_name}' deleted successfully");
println!(
"Profile '{}' (ID: {}) deleted successfully",
profile.name, profile_id
);
// Rebuild tag suggestions after deletion
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
});
// Always perform cleanup after profile deletion to remove unused binaries
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
println!("Warning: Failed to cleanup unused binaries after profile deletion: {e}");
}
// Emit profile deletion event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
}
pub fn update_profile_version(
&self,
profile_name: &str,
app_handle: &tauri::AppHandle,
profile_id: &str,
version: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Find the profile by name
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if the browser is currently running
if profile.process_id.is_some() {
@@ -357,37 +423,44 @@ impl ProfileManager {
profile.version = version.to_string();
// Update the release_type based on the version and browser
profile.release_type =
if crate::api_client::is_browser_version_nightly(&profile.browser, version, None) {
"nightly".to_string()
} else {
"stable".to_string()
};
profile.release_type = if is_browser_version_nightly(&profile.browser, version, None) {
"nightly".to_string()
} else {
"stable".to_string()
};
// Save the updated profile
self.save_profile(&profile)?;
// Emit profile update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn assign_profiles_to_group(
&self,
profile_names: Vec<String>,
app_handle: &tauri::AppHandle,
profile_ids: Vec<String>,
group_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles = self.list_profiles()?;
for profile_name in profile_names {
for profile_id in profile_ids {
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let mut profile = profiles
.iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?
.clone();
// Check if browser is running
if profile.process_id.is_some() {
return Err(format!(
"Cannot modify group for profile '{profile_name}' while browser is running. Please stop the browser first."
"Cannot modify group for profile '{}' while browser is running. Please stop the browser first.", profile.name
).into());
}
@@ -395,26 +468,80 @@ impl ProfileManager {
self.save_profile(&profile)?;
}
// Rebuild tag suggestions after group changes just in case
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
});
// Emit profile group assignment event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
}
pub fn update_profile_tags(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
tags: Vec<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
let mut seen = std::collections::HashSet::new();
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
for t in tags.into_iter() {
if seen.insert(t.clone()) {
deduped.push(t);
}
}
profile.tags = deduped;
// Save profile
self.save_profile(&profile)?;
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
});
// Emit profile tags update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn delete_multiple_profiles(
&self,
profile_names: Vec<String>,
app_handle: &tauri::AppHandle,
profile_ids: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles = self.list_profiles()?;
for profile_name in profile_names {
for profile_id in profile_ids {
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profile = profiles
.iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
return Err(
format!(
"Cannot delete profile '{profile_name}' while browser is running. Please stop the browser first."
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
profile.name
)
.into(),
);
@@ -429,16 +556,26 @@ impl ProfileManager {
}
}
// Emit profile deletion event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
}
pub async fn update_camoufox_config(
&self,
app_handle: tauri::AppHandle,
profile_name: &str,
profile_id: &str,
config: CamoufoxConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by name
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Invalid profile ID: {profile_id}").into()
},
)?;
let profiles =
self
.list_profiles()
@@ -447,13 +584,15 @@ impl ProfileManager {
})?;
let mut profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.find(|p| p.id == profile_uuid)
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
format!("Profile {profile_name} not found").into()
format!("Profile with ID '{profile_id}' not found").into()
})?;
// Check if the browser is currently running using the comprehensive status check
let is_running = self.check_browser_status(app_handle, &profile).await?;
let is_running = self
.check_browser_status(app_handle.clone(), &profile)
.await?;
if is_running {
return Err(
@@ -471,7 +610,15 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
println!("Camoufox configuration updated for profile '{profile_name}'.");
println!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name, profile_id
);
// Emit profile config update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(())
}
@@ -479,10 +626,15 @@ impl ProfileManager {
pub async fn update_profile_proxy(
&self,
app_handle: tauri::AppHandle,
profile_name: &str,
profile_id: &str,
proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by name
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Invalid profile ID: {profile_id}").into()
},
)?;
let profiles =
self
.list_profiles()
@@ -492,24 +644,11 @@ impl ProfileManager {
let mut profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.find(|p| p.id == profile_uuid)
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
format!("Profile {profile_name} not found").into()
format!("Profile with ID '{profile_id}' not found").into()
})?;
// Check if browser is running to manage proxy accordingly
let browser_is_running = profile.process_id.is_some()
&& self
.check_browser_status(app_handle.clone(), &profile)
.await?;
// If browser is running, stop existing proxy
if browser_is_running && profile.proxy_id.is_some() {
if let Some(pid) = profile.process_id {
let _ = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await;
}
}
// Update proxy settings
profile.proxy_id = proxy_id.clone();
@@ -520,68 +659,16 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
// Handle proxy startup/configuration
// Update on-disk browser profile config immediately
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
if browser_is_running {
// Browser is running and proxy is enabled, start new proxy
if let Some(pid) = profile.process_id {
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
Some(&proxy_settings),
pid,
Some(profile_name),
)
.await
{
Ok(internal_proxy_settings) => {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
// Apply the proxy settings with the internal proxy to the profile directory
self
.apply_proxy_settings_to_profile(
&profile_path,
&proxy_settings,
Some(&internal_proxy_settings),
)
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
println!("Successfully started proxy for profile: {}", profile.name);
}
Err(e) => {
eprintln!("Failed to start proxy: {e}");
// Apply proxy settings without internal proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
}
}
} else {
// No PID available, apply proxy settings without internal proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
}
} else {
// Proxy disabled or browser not running, just apply settings
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
}
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
} else {
// Proxy ID provided but proxy not found, disable proxy
let profiles_dir = self.get_profiles_dir();
@@ -608,6 +695,11 @@ impl ProfileManager {
println!("Warning: Failed to emit profile update event: {e}");
}
// Emit general profiles changed event for profile list updates
if let Err(e) = app_handle.emit("profiles-changed", ()) {
println!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
@@ -624,7 +716,7 @@ impl ProfileManager {
}
// For non-camoufox browsers, use the existing PID-based logic
let mut inner_profile = profile.clone();
let inner_profile = profile.clone();
let system = System::new_all();
let mut is_running = false;
let mut found_pid: Option<u32> = None;
@@ -741,25 +833,51 @@ impl ProfileManager {
}
}
// Update the process ID if we found a different one
if let Some(pid) = found_pid {
if inner_profile.process_id != Some(pid) {
inner_profile.process_id = Some(pid);
if let Err(e) = self.save_profile(&inner_profile) {
println!("Warning: Failed to update profile with new PID: {e}");
// Only persist status changes if the profile metadata still exists on disk
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
if metadata_exists {
// Load the latest profile from disk to avoid overwriting fields like proxy_id
let latest_profile: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => inner_profile.clone(),
};
let previous_pid = latest_profile.process_id;
let mut merged = latest_profile.clone();
if let Some(pid) = found_pid {
if merged.process_id != Some(pid) {
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
println!("Warning: Failed to update profile with new PID: {e}");
}
}
} else if merged.process_id.is_some() {
// Clear the PID if no process found
merged.process_id = None;
if let Err(e) = self.save_profile(&merged) {
println!("Warning: Failed to clear profile PID: {e}");
}
// Stop any associated proxy immediately when the browser stops
if let Some(old_pid) = previous_pid {
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
}
}
} else if inner_profile.process_id.is_some() {
// Clear the PID if no process found
inner_profile.process_id = None;
if let Err(e) = self.save_profile(&inner_profile) {
println!("Warning: Failed to clear profile PID: {e}");
}
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &inner_profile) {
println!("Warning: Failed to emit profile update event: {e}");
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &merged) {
println!("Warning: Failed to emit profile update event: {e}");
}
}
Ok(is_running)
@@ -771,9 +889,7 @@ impl ProfileManager {
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
use crate::camoufox::CamoufoxNodecarLauncher;
let launcher = CamoufoxNodecarLauncher::instance();
let launcher = self.camoufox_manager;
let profiles_dir = self.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy();
@@ -782,38 +898,71 @@ impl ProfileManager {
match launcher.find_camoufox_by_profile(&profile_path_str).await {
Ok(Some(camoufox_process)) => {
// Found a running instance, update profile with process info if changed
let process_id_changed = profile.process_id != camoufox_process.processId;
if process_id_changed {
let mut updated_profile = profile.clone();
updated_profile.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to update Camoufox profile with process info: {e}");
}
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
if metadata_exists {
// Load latest to avoid overwriting other fields
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => profile.clone(),
};
println!(
"Camoufox process has started for profile '{}' with PID: {:?}",
profile.name, camoufox_process.processId
);
if latest.process_id != camoufox_process.processId {
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
println!("Warning: Failed to update Camoufox profile with process info: {e}");
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e}");
}
println!(
"Camoufox process has started for profile '{}' with PID: {:?}",
profile.name, camoufox_process.processId
);
}
}
Ok(true)
}
Ok(None) => {
// No running instance found, clear process ID if set
if profile.process_id.is_some() {
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to clear Camoufox profile process info: {e}");
}
// No running instance found, clear process ID if set and stop proxy
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
if metadata_exists {
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => profile.clone(),
};
if let Some(old_pid) = latest.process_id {
latest.process_id = None;
if let Err(e) = self.save_profile(&latest) {
println!("Warning: Failed to clear Camoufox profile process info: {e}");
}
// Stop any proxy tied to this old PID immediately
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e}");
}
}
}
Ok(false)
@@ -821,16 +970,35 @@ impl ProfileManager {
Err(e) => {
// Error checking status, assume not running and clear process ID
println!("Warning: Failed to check Camoufox status via nodecar: {e}");
if profile.process_id.is_some() {
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to clear Camoufox profile process info after error: {e}");
}
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
if metadata_exists {
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => profile.clone(),
};
if let Some(old_pid) = latest.process_id {
latest.process_id = None;
if let Err(e2) = self.save_profile(&latest) {
println!("Warning: Failed to clear Camoufox profile process info after error: {e2}");
}
// Best-effort stop of proxy tied to old PID
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
// Emit profile update event to frontend
if let Err(e3) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e3}");
}
}
}
Ok(false)
@@ -865,17 +1033,6 @@ impl ProfileManager {
}
}
fn get_binaries_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("binaries");
path
}
fn get_common_firefox_preferences(&self) -> Vec<String> {
vec![
// Disable default browser updates
@@ -884,6 +1041,15 @@ impl ProfileManager {
"user_pref(\"browser.preferences.moreFromMozilla\", false);".to_string(),
"user_pref(\"services.sync.prefs.sync.browser.startup.upgradeDialog.enabled\", false);"
.to_string(),
// Disable welcome / first-run screens
"user_pref(\"browser.aboutwelcome.enabled\", false);".to_string(),
"user_pref(\"browser.startup.homepage_override.mstone\", \"ignore\");".to_string(),
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
// Keep extension updates enabled
"user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
"user_pref(\"app.update.timerFirstInterval\", -1);".to_string(),
@@ -899,6 +1065,12 @@ impl ProfileManager {
"user_pref(\"app.update.interval\", -1);".to_string(),
"user_pref(\"app.update.background.interval\", -1);".to_string(),
"user_pref(\"app.update.idletime\", -1);".to_string(),
// Suppress additional update UI/prompts
"user_pref(\"app.update.doorhanger\", false);".to_string(),
"user_pref(\"app.update.badge\", false);".to_string(),
"user_pref(\"app.update.background.scheduling.enabled\", false);".to_string(),
// Suppress upgrade dialogs on startup
"user_pref(\"browser.startup.upgradeDialog.enabled\", false);".to_string(),
]
}
@@ -1040,23 +1212,6 @@ mod tests {
);
}
#[test]
fn test_list_profiles_empty() {
let (manager, _temp_dir) = create_test_profile_manager();
let result = manager.list_profiles();
assert!(
result.is_ok(),
"Should successfully list profiles even when empty"
);
let profiles = result.unwrap();
assert!(
profiles.is_empty(),
"Should return empty vector when no profiles exist"
);
}
#[test]
fn test_get_common_firefox_preferences() {
let (manager, _temp_dir) = create_test_profile_manager();
@@ -1162,7 +1317,139 @@ mod tests {
}
}
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn create_browser_profile_with_group(
app_handle: tauri::AppHandle,
name: String,
browser: String,
version: String,
release_type: String,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
group_id: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.create_profile_with_group(
&app_handle,
&name,
&browser,
&version,
&release_type,
proxy_id,
camoufox_config,
group_id,
)
.await
.map_err(|e| format!("Failed to create profile: {e}"))
}
#[tauri::command]
pub fn list_browser_profiles() -> Result<Vec<BrowserProfile>, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))
}
#[tauri::command]
pub async fn update_profile_proxy(
app_handle: tauri::AppHandle,
profile_id: String,
proxy_id: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_proxy(app_handle, &profile_id, proxy_id)
.await
.map_err(|e| format!("Failed to update profile: {e}"))
}
#[tauri::command]
pub fn update_profile_tags(
app_handle: tauri::AppHandle,
profile_id: String,
tags: Vec<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_tags(&app_handle, &profile_id, tags)
.map_err(|e| format!("Failed to update profile tags: {e}"))
}
#[tauri::command]
pub async fn check_browser_status(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
) -> Result<bool, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.check_browser_status(app_handle, &profile)
.await
.map_err(|e| format!("Failed to check browser status: {e}"))
}
#[tauri::command]
pub fn rename_profile(
app_handle: tauri::AppHandle,
profile_id: String,
new_name: String,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.rename_profile(&app_handle, &profile_id, &new_name)
.map_err(|e| format!("Failed to rename profile: {e}"))
}
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn create_browser_profile_new(
app_handle: tauri::AppHandle,
name: String,
browser_str: String,
version: String,
release_type: String,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
group_id: Option<String>,
) -> Result<BrowserProfile, String> {
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group(
app_handle,
name,
browser_type.as_str().to_string(),
version,
release_type,
proxy_id,
camoufox_config,
group_id,
)
.await
}
#[tauri::command]
pub async fn update_camoufox_config(
app_handle: tauri::AppHandle,
profile_id: String,
config: CamoufoxConfig,
) -> Result<(), String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_camoufox_config(app_handle, &profile_id, config)
.await
.map_err(|e| format!("Failed to update Camoufox config: {e}"))
}
// Global singleton instance
#[tauri::command]
pub fn delete_profile(app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> {
ProfileManager::instance()
.delete_profile(&app_handle, &profile_id)
.map_err(|e| format!("Failed to delete profile: {e}"))
}
lazy_static::lazy_static! {
static ref PROFILE_MANAGER: ProfileManager = ProfileManager::new();
}
+3 -1
View File
@@ -1,4 +1,4 @@
use crate::camoufox::CamoufoxConfig;
use crate::camoufox_manager::CamoufoxConfig;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -20,6 +20,8 @@ pub struct BrowserProfile {
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
#[serde(default)]
pub group_id: Option<String>, // Reference to profile group
#[serde(default)]
pub tags: Vec<String>, // Free-form tags
}
pub fn default_release_type() -> String {
+13 -6
View File
@@ -5,7 +5,8 @@ use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use crate::browser::BrowserType;
use crate::browser_runner::BrowserRunner;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::profile::ProfileManager;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedProfile {
@@ -17,12 +18,16 @@ pub struct DetectedProfile {
pub struct ProfileImporter {
base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
profile_manager: &'static ProfileManager,
}
impl ProfileImporter {
fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
profile_manager: ProfileManager::instance(),
}
}
@@ -520,7 +525,7 @@ impl ProfileImporter {
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
// Check if a profile with this name already exists
let existing_profiles = BrowserRunner::instance().list_profiles()?;
let existing_profiles = self.profile_manager.list_profiles()?;
if existing_profiles
.iter()
.any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase())
@@ -530,7 +535,7 @@ impl ProfileImporter {
// Generate UUID for new profile and create the directory structure
let profile_id = uuid::Uuid::new_v4();
let profiles_dir = BrowserRunner::instance().get_profiles_dir();
let profiles_dir = self.profile_manager.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");
@@ -555,10 +560,11 @@ impl ProfileImporter {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
tags: Vec::new(),
};
// Save the profile metadata
BrowserRunner::instance().save_profile(&profile)?;
self.profile_manager.save_profile(&profile)?;
println!(
"Successfully imported profile '{}' from '{}'",
@@ -575,8 +581,9 @@ impl ProfileImporter {
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::instance();
let downloaded_versions = registry.get_downloaded_versions(browser_type);
let downloaded_versions = self
.downloaded_browsers_registry
.get_downloaded_versions(browser_type);
if let Some(version) = downloaded_versions.first() {
return Ok(version.clone());
+167 -42
View File
@@ -5,6 +5,7 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Emitter;
use tauri_plugin_shell::ShellExt;
use crate::browser::ProxySettings;
@@ -18,6 +19,8 @@ pub struct ProxyInfo {
pub upstream_port: u16,
pub upstream_type: String,
pub local_port: u16,
// Optional profile name to which this proxy instance is logically tied
pub profile_name: Option<String>,
}
// Stored proxy configuration with name and ID for reuse
@@ -51,7 +54,9 @@ 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, ProxySettings>>, // Maps profile name to proxy settings
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
// Track active proxy IDs by profile name for targeted cleanup
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
base_dirs: BaseDirs,
}
@@ -61,6 +66,7 @@ impl ProxyManager {
let manager = Self {
active_proxies: Mutex::new(HashMap::new()),
profile_proxies: Mutex::new(HashMap::new()),
profile_active_proxy_ids: Mutex::new(HashMap::new()),
stored_proxies: Mutex::new(HashMap::new()),
base_dirs,
};
@@ -141,6 +147,7 @@ impl ProxyManager {
// Create a new stored proxy
pub fn create_stored_proxy(
&self,
app_handle: &tauri::AppHandle,
name: String,
proxy_settings: ProxySettings,
) -> Result<StoredProxy, String> {
@@ -163,13 +170,21 @@ impl ProxyManager {
eprintln!("Warning: Failed to save proxy: {e}");
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
eprintln!("Failed to emit proxies-changed event: {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()
let mut list: Vec<StoredProxy> = stored_proxies.values().cloned().collect();
// Sort case-insensitively by name for consistent ordering across UI/API consumers
list.sort_by_key(|p| p.name.to_lowercase());
list
}
// Get a stored proxy by ID
@@ -177,6 +192,7 @@ impl ProxyManager {
// Update a stored proxy
pub fn update_stored_proxy(
&self,
app_handle: &tauri::AppHandle,
proxy_id: &str,
name: Option<String>,
proxy_settings: Option<ProxySettings>,
@@ -221,11 +237,20 @@ impl ProxyManager {
eprintln!("Warning: Failed to save proxy: {e}");
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
eprintln!("Failed to emit proxies-changed event: {e}");
}
Ok(updated_proxy)
}
// Delete a stored proxy
pub fn delete_stored_proxy(&self, proxy_id: &str) -> Result<(), String> {
pub fn delete_stored_proxy(
&self,
app_handle: &tauri::AppHandle,
proxy_id: &str,
) -> Result<(), String> {
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.remove(proxy_id).is_none() {
@@ -237,6 +262,11 @@ impl ProxyManager {
eprintln!("Warning: Failed to delete proxy file: {e}");
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
eprintln!("Failed to emit proxies-changed event: {e}");
}
Ok(())
}
@@ -257,42 +287,93 @@ impl ProxyManager {
browser_pid: u32,
profile_name: Option<&str>,
) -> Result<ProxySettings, String> {
// Check if we already have a proxy for this browser
// First, proactively cleanup any dead proxies so we don't accidentally reuse stale ones
let _ = self.cleanup_dead_proxies(app_handle.clone()).await;
// If we have a previous proxy tied to this profile, and the upstream settings are changing,
// stop it before starting a new one so the change takes effect immediately.
if let Some(name) = profile_name {
// Check if we have an active proxy recorded for this profile
let maybe_existing_id = {
let map = self.profile_active_proxy_ids.lock().unwrap();
map.get(name).cloned()
};
if let Some(existing_id) = maybe_existing_id {
// Find the existing proxy info
let existing_info = {
let proxies = self.active_proxies.lock().unwrap();
proxies.values().find(|p| p.id == existing_id).cloned()
};
if let Some(existing) = existing_info {
let desired_type = proxy_settings
.map(|p| p.proxy_type.as_str())
.unwrap_or("DIRECT");
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
let is_same_upstream = existing.upstream_type == desired_type
&& existing.upstream_host == desired_host
&& existing.upstream_port == desired_port;
if !is_same_upstream {
// Stop the previous proxy tied to this profile (best effort)
// We don't know the original PID mapping that created it; iterate to find its key
let pid_to_stop = {
let proxies = self.active_proxies.lock().unwrap();
proxies.iter().find_map(|(pid, info)| {
if info.id == existing_id {
Some(*pid)
} else {
None
}
})
};
if let Some(pid) = pid_to_stop {
let _ = self.stop_proxy(app_handle.clone(), pid).await;
}
}
}
}
}
// Check if we already have a proxy for this browser PID. If it exists but the upstream
// settings don't match the newly requested ones, stop it and create a new proxy so that
// changes take effect immediately.
let mut needs_restart = false;
{
let proxies = self.active_proxies.lock().unwrap();
if let Some(proxy) = proxies.get(&browser_pid) {
return Ok(ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy.local_port,
username: None,
password: None,
});
if let Some(existing) = proxies.get(&browser_pid) {
let desired_type = proxy_settings
.map(|p| p.proxy_type.as_str())
.unwrap_or("DIRECT");
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
let is_same_upstream = existing.upstream_type == desired_type
&& existing.upstream_host == desired_host
&& existing.upstream_port == desired_port;
if is_same_upstream {
// Reuse existing local proxy
return Ok(ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
password: None,
});
} else {
// Upstream changed; we must restart the local proxy so that traffic is routed correctly
needs_restart = true;
}
}
}
// Check if 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).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| {
if let Some(proxy_settings) = proxy_settings {
p.upstream_host == proxy_settings.host
&& p.upstream_port == proxy_settings.port
&& p.upstream_type == proxy_settings.proxy_type
} else {
p.upstream_type == "DIRECT"
}
})
.map(|p| p.local_port)
})
} else {
None
};
if needs_restart {
// Best-effort stop of the old proxy for this PID before starting a new one
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
}
// Start a new proxy using the nodecar binary with the correct CLI interface
let mut nodecar = app_handle
@@ -321,11 +402,6 @@ impl ProxyManager {
}
}
// If we have a preferred port, use it
if let Some(port) = preferred_port {
nodecar = nodecar.arg("--port").arg(port.to_string());
}
// Execute the command and wait for it to complete
// The nodecar binary should start the worker and then exit
let output = nodecar
@@ -367,8 +443,33 @@ impl ProxyManager {
.map(|p| p.proxy_type.clone())
.unwrap_or_else(|| "DIRECT".to_string()),
local_port,
profile_name: profile_name.map(|s| s.to_string()),
};
// Wait for the local proxy port to be ready to accept connections
{
use tokio::net::TcpStream;
use tokio::time::{sleep, Duration};
let mut ready = false;
for _ in 0..50 {
match TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, proxy_info.local_port)).await {
Ok(_stream) => {
ready = true;
break;
}
Err(_) => {
sleep(Duration::from_millis(100)).await;
}
}
}
if !ready {
return Err(format!(
"Local proxy on 127.0.0.1:{} did not become ready in time",
proxy_info.local_port
));
}
}
// Store the proxy info
{
let mut proxies = self.active_proxies.lock().unwrap();
@@ -381,6 +482,9 @@ impl ProxyManager {
let mut profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.insert(name.to_string(), proxy_settings.clone());
}
// Also record the active proxy id for this profile for quick cleanup on changes
let mut map = self.profile_active_proxy_ids.lock().unwrap();
map.insert(name.to_string(), proxy_info.id.clone());
}
// Return proxy settings for the browser
@@ -399,10 +503,10 @@ impl ProxyManager {
app_handle: tauri::AppHandle,
browser_pid: u32,
) -> Result<(), String> {
let proxy_id = {
let (proxy_id, profile_name): (String, Option<String>) = {
let mut proxies = self.active_proxies.lock().unwrap();
match proxies.remove(&browser_pid) {
Some(proxy) => proxy.id,
Some(proxy) => (proxy.id, proxy.profile_name.clone()),
None => return Ok(()), // No proxy to stop
}
};
@@ -415,7 +519,7 @@ impl ProxyManager {
.arg("proxy")
.arg("stop")
.arg("--id")
.arg(proxy_id);
.arg(&proxy_id);
let output = nodecar.output().await.unwrap();
@@ -425,6 +529,21 @@ impl ProxyManager {
// We still return Ok since we've already removed the proxy from our tracking
}
// Clear profile-to-proxy mapping if it references this proxy
if let Some(name) = profile_name {
let mut map = self.profile_active_proxy_ids.lock().unwrap();
if let Some(current_id) = map.get(&name) {
if current_id == &proxy_id {
map.remove(&name);
}
}
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
eprintln!("Failed to emit proxies-changed event: {e}");
}
Ok(())
}
@@ -465,6 +584,11 @@ impl ProxyManager {
let _ = self.stop_proxy(app_handle.clone(), *dead_pid).await;
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
eprintln!("Failed to emit proxies-changed event: {e}");
}
Ok(dead_pids)
}
}
@@ -624,6 +748,7 @@ mod tests {
upstream_port: 3128,
upstream_type: "http".to_string(),
local_port: (8000 + i) as u16,
profile_name: None,
};
// Add proxy
+266 -56
View File
@@ -3,8 +3,11 @@ use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
use crate::api_client::ApiClient;
use crate::version_updater;
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
@@ -27,17 +30,33 @@ pub struct AppSettings {
pub set_as_default_browser: bool,
#[serde(default = "default_theme")]
pub theme: String, // "light", "dark", or "system"
#[serde(default)]
pub custom_theme: Option<std::collections::HashMap<String, String>>, // CSS var name -> value (e.g., "--background": "#1a1b26")
#[serde(default)]
pub api_enabled: bool,
#[serde(default = "default_api_port")]
pub api_port: u16,
#[serde(default)]
pub api_token: Option<String>, // Displayed token for user to copy
}
fn default_theme() -> String {
"system".to_string()
}
fn default_api_port() -> u16 {
10108
}
impl Default for AppSettings {
fn default() -> Self {
Self {
set_as_default_browser: false,
theme: "system".to_string(),
custom_theme: None,
api_enabled: false,
api_port: 10108,
api_token: None,
}
}
}
@@ -151,22 +170,257 @@ impl SettingsManager {
// Always return false - we don't show settings on startup anymore
Ok(false)
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
pub async fn generate_api_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
// Generate a secure random token (base64 encoded for URL safety)
let token_bytes: [u8; 32] = {
use rand::RngCore;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
bytes
};
use base64::{engine::general_purpose, Engine as _};
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
// Store token securely
self.store_api_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_api_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Store token in an encrypted file using Argon2 + AES-GCM
let token_file = self.get_settings_dir().join("api_token.dat");
// Create directory if it doesn't exist
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
// Generate a random salt for Argon2
let salt = SaltString::generate(&mut OsRng);
// Use Argon2 to derive a 32-byte key from the vault password
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
// Take first 32 bytes for AES-256 key
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
// Generate a random nonce
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
// Encrypt the token
let ciphertext = cipher
.encrypt(&nonce, token.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
// Create file data with header, salt, nonce, and encrypted data
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBAPI"); // 5-byte header
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
// Store salt length and salt
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
// Store nonce (12 bytes for AES-GCM)
file_data.extend_from_slice(&nonce);
// Store ciphertext length and ciphertext
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(token_file, file_data)?;
Ok(())
}
pub async fn get_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
// Validate header
if file_data.len() < 6 || &file_data[0..5] != b"DBAPI" {
return Ok(None);
}
let version = file_data[5];
// Only support Argon2 + AES-GCM (version 2)
if version != 2 {
return Ok(None);
}
// Argon2 + AES-GCM decryption
let mut offset = 6;
// Read salt
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
offset += salt_len;
// Read nonce (12 bytes)
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce length")?;
let nonce = Nonce::from(nonce_bytes);
offset += 12;
// Read ciphertext
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len = u32::from_le_bytes([
file_data[offset],
file_data[offset + 1],
file_data[offset + 2],
file_data[offset + 3],
]) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
// Derive key using Argon2
let vault_password = Self::get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
// Decrypt the token
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
match String::from_utf8(plaintext) {
Ok(token) => Ok(Some(token)),
Err(_) => Ok(None),
}
}
pub async fn remove_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
}
Ok(())
}
}
#[tauri::command]
pub async fn get_app_settings() -> Result<AppSettings, String> {
pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
manager
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))
.map_err(|e| format!("Failed to load settings: {e}"))?;
// Always load token for display purposes if it exists
settings.api_token = manager
.get_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to load API token: {e}"))?;
Ok(settings)
}
#[tauri::command]
pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
pub async fn save_app_settings(
app_handle: tauri::AppHandle,
mut settings: AppSettings,
) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
if settings.api_enabled {
if let Some(ref token) = settings.api_token {
manager
.store_api_token(&app_handle, token)
.await
.map_err(|e| format!("Failed to store API token: {e}"))?;
} else {
let token = manager
.generate_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate API token: {e}"))?;
settings.api_token = Some(token);
}
}
// If API is being disabled, remove the token
if !settings.api_enabled {
manager
.remove_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to remove API token: {e}"))?;
settings.api_token = None;
}
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
Ok(settings)
}
#[tauri::command]
@@ -193,54 +447,6 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
.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::instance();
// Clear all cache first
api_client
.clear_all_cache()
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
// Disable all browsers during the update process
let auto_updater = crate::auto_updater::AutoUpdater::instance();
let supported_browsers =
crate::browser_version_manager::BrowserVersionManager::instance().get_supported_browsers();
// Load current state and disable all browsers
let mut state = auto_updater
.load_auto_update_state()
.map_err(|e| format!("Failed to load auto update state: {e}"))?;
for browser in &supported_browsers {
state.disabled_browsers.insert(browser.clone());
}
auto_updater
.save_auto_update_state(&state)
.map_err(|e| format!("Failed to save auto update state: {e}"))?;
let updater = version_updater::get_version_updater();
let updater_guard = updater.lock().await;
let result = updater_guard
.trigger_manual_update(&app_handle)
.await
.map_err(|e| format!("Failed to trigger version update: {e}"));
// Re-enable all browsers after the update completes (regardless of success/failure)
let mut final_state = auto_updater.load_auto_update_state().unwrap_or_default();
for browser in &supported_browsers {
final_state.disabled_browsers.remove(browser);
}
if let Err(e) = auto_updater.save_auto_update_state(&final_state) {
eprintln!("Warning: Failed to re-enable browsers after cache clear: {e}");
}
result?;
Ok(())
}
// Global singleton instance
lazy_static::lazy_static! {
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
@@ -321,6 +527,10 @@ mod tests {
let test_settings = AppSettings {
set_as_default_browser: true,
theme: "dark".to_string(),
custom_theme: None,
api_enabled: false,
api_port: 10108,
api_token: None,
};
// Save settings
+114
View File
@@ -0,0 +1,114 @@
use crate::profile::BrowserProfile;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct TagsData {
tags: Vec<String>,
}
pub struct TagManager {
base_dirs: BaseDirs,
data_dir_override: Option<PathBuf>,
}
impl TagManager {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
.ok()
.map(PathBuf::from),
}
}
// Helper for tests to override data directory without global env var
#[allow(dead_code)]
pub fn with_data_dir_override(dir: &Path) -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: Some(dir.to_path_buf()),
}
}
fn get_tags_file_path(&self) -> PathBuf {
if let Some(dir) = &self.data_dir_override {
let mut override_path = dir.clone();
let _ = fs::create_dir_all(&override_path);
override_path.push("tags.json");
return override_path;
}
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("data");
path.push("tags.json");
path
}
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
let file_path = self.get_tags_file_path();
if !file_path.exists() {
return Ok(TagsData::default());
}
let content = fs::read_to_string(file_path)?;
let data: TagsData = serde_json::from_str(&content)?;
Ok(data)
}
fn save_tags_data(&self, data: &TagsData) -> Result<(), Box<dyn std::error::Error>> {
let file_path = self.get_tags_file_path();
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(data)?;
fs::write(file_path, json)?;
Ok(())
}
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut all = self.load_tags_data()?.tags;
// Ensure deterministic order
all.sort();
all.dedup();
Ok(all)
}
pub fn rebuild_from_profiles(
&self,
profiles: &[BrowserProfile],
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// Build a set of all tags currently used by any profile
let mut set: BTreeSet<String> = BTreeSet::new();
for profile in profiles {
for tag in &profile.tags {
// Store exactly as provided (no normalization) to preserve characters
set.insert(tag.clone());
}
}
let combined: Vec<String> = set.into_iter().collect();
self.save_tags_data(&TagsData {
tags: combined.clone(),
})?;
Ok(combined)
}
}
#[tauri::command]
pub fn get_all_tags() -> Result<Vec<String>, String> {
let tag_manager = crate::tag_manager::TAG_MANAGER.lock().unwrap();
tag_manager
.get_all_tags()
.map_err(|e| format!("Failed to get tags: {e}"))
}
lazy_static::lazy_static! {
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
}
+66 -5
View File
@@ -46,8 +46,9 @@ impl Default for BackgroundUpdateState {
}
}
/// Extension of auto_updater.rs for background updates
pub struct VersionUpdater {
version_service: &'static BrowserVersionManager,
browser_version_manager: &'static BrowserVersionManager,
auto_updater: &'static AutoUpdater,
app_handle: Option<tauri::AppHandle>,
}
@@ -55,7 +56,7 @@ pub struct VersionUpdater {
impl VersionUpdater {
pub fn new() -> Self {
Self {
version_service: BrowserVersionManager::instance(),
browser_version_manager: BrowserVersionManager::instance(),
auto_updater: AutoUpdater::instance(),
app_handle: None,
}
@@ -263,7 +264,7 @@ impl VersionUpdater {
&self,
app_handle: &tauri::AppHandle,
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
let supported_browsers = self.version_service.get_supported_browsers();
let supported_browsers = self.browser_version_manager.get_supported_browsers();
let total_browsers = supported_browsers.len();
let mut results = Vec::new();
let mut total_new_versions = 0;
@@ -374,7 +375,7 @@ impl VersionUpdater {
browser: &str,
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
self
.version_service
.browser_version_manager
.update_browser_versions_incrementally(browser)
.await
}
@@ -455,6 +456,63 @@ pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
Ok((last_update, time_until_next))
}
#[tauri::command]
pub async fn clear_all_version_cache_and_refetch(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let api_client = crate::api_client::ApiClient::instance();
let version_updater = VersionUpdater::new();
// Clear all cache first
api_client
.clear_all_cache()
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
// Disable all browsers during the update process
let supported_browsers = version_updater
.browser_version_manager
.get_supported_browsers();
// Load current state and disable all browsers
let mut state = version_updater
.auto_updater
.load_auto_update_state()
.map_err(|e| format!("Failed to load auto update state: {e}"))?;
for browser in &supported_browsers {
state.disabled_browsers.insert(browser.clone());
}
version_updater
.auto_updater
.save_auto_update_state(&state)
.map_err(|e| format!("Failed to save auto update state: {e}"))?;
let updater = get_version_updater();
let updater_guard = updater.lock().await;
let result = updater_guard
.trigger_manual_update(&app_handle)
.await
.map_err(|e| format!("Failed to trigger version update: {e}"));
// Re-enable all browsers after the update completes (regardless of success/failure)
let mut final_state = version_updater
.auto_updater
.load_auto_update_state()
.unwrap_or_default();
for browser in &supported_browsers {
final_state.disabled_browsers.remove(browser);
}
if let Err(e) = version_updater
.auto_updater
.save_auto_update_state(&final_state)
{
eprintln!("Warning: Failed to re-enable browsers after cache clear: {e}");
}
result?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -607,7 +665,10 @@ mod tests {
// Should have valid references to services
assert!(
!std::ptr::eq(updater.version_service as *const _, std::ptr::null()),
!std::ptr::eq(
updater.browser_version_manager as *const _,
std::ptr::null()
),
"Version service should not be null"
);
assert!(
+6 -10
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.9.1",
"productName": "Donut",
"version": "0.12.3",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -41,16 +41,12 @@
},
"linux": {
"deb": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
},
"rpm": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
},
"appimage": {
"files": {
+1 -1
View File
@@ -24,7 +24,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden bg-background`}
>
<CustomThemeProvider>
<WindowDragArea />
+230 -302
View File
@@ -3,7 +3,7 @@
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 { useCallback, useEffect, useMemo, useState } from "react";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -16,14 +16,17 @@ 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 { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useGroupEvents } from "@/hooks/use-group-events";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { showErrorToast, showToast } from "@/lib/toast-utils";
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -41,9 +44,26 @@ interface PendingUrl {
}
export default function Home() {
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
const [error, setError] = useState<string | null>(null);
const [proxyDialogOpen, setProxyDialogOpen] = useState(false);
// Mount global version update listener/toasts
useVersionUpdater();
const [isInitializing, setIsInitializing] = useState(true);
// Use the new profile events hook for centralized profile management
const {
profiles,
runningProfiles,
isLoading: profilesLoading,
error: profilesError,
} = useProfileEvents();
const {
groups: groupsData,
isLoading: groupsLoading,
error: groupsError,
} = useGroupEvents();
const { isLoading: proxiesLoading, error: proxiesError } = useProxyEvents();
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
@@ -60,15 +80,12 @@ export default function Home() {
string[]
>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState<string>("");
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
const [currentProfileForProxy, setCurrentProfileForProxy] =
useState<BrowserProfile | null>(null);
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
useState<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [groups, setGroups] = useState<GroupWithCount[]>([]);
const [areGroupsLoading, setGroupsLoading] = useState(true);
const [currentPermissionType, setCurrentPermissionType] =
useState<PermissionType>("microphone");
const [showBulkDeleteConfirmation, setShowBulkDeleteConfirmation] =
@@ -145,11 +162,6 @@ export default function Home() {
"Failed to download missing components:",
downloadError,
);
setError(
`Failed to download missing components: ${JSON.stringify(
downloadError,
)}`,
);
}
}
} catch (err: unknown) {
@@ -157,22 +169,6 @@ export default function Home() {
}
}, []);
// Simple profiles loader without updates check (for use as callback)
const loadProfiles = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"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]);
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
const handleUrlOpen = useCallback(
@@ -206,26 +202,9 @@ export default function Home() {
);
// Auto-update functionality - use the existing hook for compatibility
const updateNotifications = useUpdateNotifications(loadProfiles);
const updateNotifications = useUpdateNotifications();
const { checkForUpdates, isUpdating } = updateNotifications;
// Profiles loader with update check (for initial load and manual refresh)
const loadProfilesWithUpdateCheck = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
setProfiles(profileList);
// 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, checkMissingBinaries]);
useAppUpdateNotifications();
// Check for startup URLs but only process them once
@@ -264,6 +243,48 @@ export default function Home() {
}
}, [hasCheckedStartupPrompt]);
// Warm up nodecar at startup and block UI until complete
useEffect(() => {
let cancelled = false;
(async () => {
try {
await invoke("warm_up_nodecar");
} catch (err) {
if (!cancelled) {
// Don't set error here since useProfileEvents handles profile errors
console.error("Initialization failed:", err);
}
} finally {
if (!cancelled) setIsInitializing(false);
}
})();
return () => {
cancelled = true;
};
}, []);
// Handle profile errors from useProfileEvents hook
useEffect(() => {
if (profilesError) {
showErrorToast(profilesError);
}
}, [profilesError]);
// Handle group errors from useGroupEvents hook
useEffect(() => {
if (groupsError) {
showErrorToast(groupsError);
}
}, [groupsError]);
// Handle proxy errors from useProxyEvents hook
useEffect(() => {
if (proxiesError) {
showErrorToast(proxiesError);
}
}, [proxiesError]);
const checkAllPermissions = useCallback(async () => {
try {
// Wait for permissions to be initialized before checking
@@ -320,7 +341,7 @@ export default function Home() {
"Received show create profile dialog request:",
event.payload,
);
setError(
showErrorToast(
"No profiles available. Please create a profile first before opening URLs.",
);
setCreateProfileDialogOpen(true);
@@ -349,11 +370,6 @@ export default function Home() {
}
}, [handleUrlOpen]);
const openProxyDialog = useCallback((profile: BrowserProfile | null) => {
setCurrentProfileForProxy(profile);
setProxyDialogOpen(true);
}, []);
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
setCurrentProfileForCamoufoxConfig(profile);
setCamoufoxConfigDialogOpen(true);
@@ -361,60 +377,24 @@ export default function Home() {
const handleSaveCamoufoxConfig = useCallback(
async (profile: BrowserProfile, config: CamoufoxConfig) => {
setError(null);
try {
await invoke("update_camoufox_config", {
profileName: profile.name,
profileId: profile.id,
config,
});
await loadProfiles();
// No need to manually reload - useProfileEvents will handle the update
setCamoufoxConfigDialogOpen(false);
} catch (err: unknown) {
console.error("Failed to update camoufox config:", err);
setError(`Failed to update camoufox config: ${JSON.stringify(err)}`);
showErrorToast(
`Failed to update camoufox config: ${JSON.stringify(err)}`,
);
throw err;
}
},
[loadProfiles],
[],
);
const handleSaveProxy = useCallback(
async (proxyId: string | null) => {
setProxyDialogOpen(false);
setError(null);
try {
if (currentProfileForProxy) {
await invoke("update_profile_proxy", {
profileName: currentProfileForProxy.name,
proxyId: proxyId,
});
}
await loadProfiles();
// Trigger proxy data reload in the table
} catch (err: unknown) {
console.error("Failed to update proxy settings:", err);
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
}
},
[currentProfileForProxy, loadProfiles],
);
const loadGroups = useCallback(async () => {
setGroupsLoading(true);
try {
const groupsWithCounts = await invoke<GroupWithCount[]>(
"get_groups_with_profile_counts",
);
setGroups(groupsWithCounts);
} catch (err) {
console.error("Failed to load groups with counts:", err);
setGroups([]);
} finally {
setGroupsLoading(false);
}
}, []);
const handleCreateProfile = useCallback(
async (profileData: {
name: string;
@@ -425,29 +405,22 @@ export default function Home() {
camoufoxConfig?: CamoufoxConfig;
groupId?: string;
}) => {
setError(null);
try {
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,
groupId:
profileData.groupId ||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
},
);
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,
groupId:
profileData.groupId ||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
});
await loadProfiles();
await loadGroups();
// Trigger proxy data reload in the table
// No need to manually reload - useProfileEvents will handle the update
} catch (error) {
setError(
showErrorToast(
`Failed to create profile: ${
error instanceof Error ? error.message : String(error)
}`,
@@ -455,166 +428,102 @@ export default function Home() {
throw error;
}
},
[loadProfiles, loadGroups, selectedGroupId],
[selectedGroupId],
);
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
new Set(),
);
const launchProfile = useCallback(async (profile: BrowserProfile) => {
console.log("Starting launch for profile:", profile.name);
const runningProfilesRef = useRef<Set<string>>(new Set());
const checkBrowserStatus = useCallback(async (profile: BrowserProfile) => {
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
});
console.log("Successfully launched profile:", result.name);
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to launch browser: ${errorMessage}`);
// Re-throw the error so the table component can handle loading state cleanup
throw err;
}
}, []);
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
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,
});
const currentRunning = runningProfilesRef.current.has(profile.name);
if (isRunning !== currentRunning) {
console.log(
`Profile ${profile.name} (${profile.browser}) status changed: ${currentRunning} -> ${isRunning}`,
if (isRunning) {
showErrorToast(
"Cannot delete profile while browser is running. Please stop the browser first.",
);
setRunningProfiles((prev) => {
const next = new Set(prev);
if (isRunning) {
next.add(profile.name);
} else {
next.delete(profile.name);
}
runningProfilesRef.current = next;
return next;
});
return;
}
} catch (err) {
console.error("Failed to check browser status:", err);
// Attempt to delete the profile
await invoke("delete_profile", { profileId: profile.id });
console.log("Profile deletion command completed successfully");
// No need to manually reload - useProfileEvents will handle the update
console.log("Profile deleted successfully");
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to delete profile: ${errorMessage}`);
}
}, []);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
setError(null);
// Check if browser is disabled due to ongoing update
try {
const isDisabled = await invoke<boolean>(
"is_browser_disabled_for_update",
{
browser: profile.browser,
},
);
if (isDisabled || isUpdating(profile.browser)) {
setError(
`${profile.browser} is currently being updated. Please wait for the update to complete.`,
);
return;
}
} catch (err) {
console.error("Failed to check browser update status:", err);
}
try {
const updatedProfile = await invoke<BrowserProfile>(
"launch_browser_profile",
{ profile },
);
await loadProfiles();
await checkBrowserStatus(updatedProfile);
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
setError(`Failed to launch browser: ${JSON.stringify(err)}`);
}
},
[loadProfiles, checkBrowserStatus, isUpdating],
);
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, 500));
// Reload profiles and groups to ensure UI is updated
await loadProfiles();
await loadGroups();
console.log("Profile deleted and profiles reloaded successfully");
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(`Failed to delete profile: ${errorMessage}`);
}
},
[loadProfiles, loadGroups],
);
const handleRenameProfile = useCallback(
async (oldName: string, newName: string) => {
setError(null);
async (profileId: string, newName: string) => {
try {
await invoke("rename_profile", { oldName, newName });
await loadProfiles();
await invoke("rename_profile", { profileId, newName });
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to rename profile:", err);
setError(`Failed to rename profile: ${JSON.stringify(err)}`);
showErrorToast(`Failed to rename profile: ${JSON.stringify(err)}`);
throw err;
}
},
[loadProfiles],
[],
);
const handleKillProfile = useCallback(
async (profile: BrowserProfile) => {
setError(null);
try {
await invoke("kill_browser_profile", { profile });
await loadProfiles();
} catch (err: unknown) {
console.error("Failed to kill browser:", err);
setError(`Failed to kill browser: ${JSON.stringify(err)}`);
}
},
[loadProfiles],
);
const handleKillProfile = useCallback(async (profile: BrowserProfile) => {
console.log("Starting kill for profile:", profile.name);
try {
await invoke("kill_browser_profile", { profile });
console.log("Successfully killed profile:", profile.name);
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to kill browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to kill browser: ${errorMessage}`);
// Re-throw the error so the table component can handle loading state cleanup
throw err;
}
}, []);
const handleDeleteSelectedProfiles = useCallback(
async (profileNames: string[]) => {
setError(null);
async (profileIds: string[]) => {
try {
await invoke("delete_selected_profiles", { profileNames });
await loadProfiles();
await loadGroups();
await invoke("delete_selected_profiles", { profileIds });
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to delete selected profiles:", err);
setError(`Failed to delete selected profiles: ${JSON.stringify(err)}`);
showErrorToast(
`Failed to delete selected profiles: ${JSON.stringify(err)}`,
);
}
},
[loadProfiles, loadGroups],
[],
);
const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => {
setSelectedProfilesForGroup(profileNames);
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
setSelectedProfilesForGroup(profileIds);
setGroupAssignmentDialogOpen(true);
}, []);
@@ -629,19 +538,20 @@ export default function Home() {
setIsBulkDeleting(true);
try {
await invoke("delete_selected_profiles", {
profileNames: selectedProfiles,
profileIds: selectedProfiles,
});
await loadProfiles();
await loadGroups();
// No need to manually reload - useProfileEvents will handle the update
setSelectedProfiles([]);
setShowBulkDeleteConfirmation(false);
} catch (error) {
console.error("Failed to delete selected profiles:", error);
setError(`Failed to delete selected profiles: ${JSON.stringify(error)}`);
showErrorToast(
`Failed to delete selected profiles: ${JSON.stringify(error)}`,
);
} finally {
setIsBulkDeleting(false);
}
}, [selectedProfiles, loadProfiles, loadGroups]);
}, [selectedProfiles]);
const handleBulkGroupAssignment = useCallback(() => {
if (selectedProfiles.length === 0) return;
@@ -650,20 +560,16 @@ export default function Home() {
}, [selectedProfiles, handleAssignProfilesToGroup]);
const handleGroupAssignmentComplete = useCallback(async () => {
await loadProfiles();
await loadGroups();
// No need to manually reload - useProfileEvents will handle the update
setGroupAssignmentDialogOpen(false);
setSelectedProfilesForGroup([]);
}, [loadProfiles, loadGroups]);
}, []);
const handleGroupManagementComplete = useCallback(async () => {
await loadGroups();
}, [loadGroups]);
// No need to manually reload - useProfileEvents will handle the update
}, []);
useEffect(() => {
void loadProfilesWithUpdateCheck();
void loadGroups();
// Check for startup default browser prompt
void checkStartupPrompt();
@@ -689,6 +595,11 @@ export default function Home() {
30 * 60 * 1000,
);
// Check for missing binaries after initial profile load
if (!profilesLoading && profiles.length > 0) {
void checkMissingBinaries();
}
return () => {
clearInterval(updateInterval);
if (cleanup) {
@@ -696,12 +607,13 @@ export default function Home() {
}
};
}, [
loadProfilesWithUpdateCheck,
checkForUpdates,
checkStartupPrompt,
listenForUrlEvents,
checkCurrentUrl,
loadGroups,
checkMissingBinaries,
profilesLoading,
profiles.length,
]);
// Show deprecation warning for unsupported profiles (with names)
@@ -737,31 +649,6 @@ export default function Home() {
}
}, [profiles]);
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) {
@@ -769,8 +656,45 @@ export default function Home() {
}
}, [isInitialized, checkAllPermissions]);
// Filter data by selected group and search query
const filteredProfiles = useMemo(() => {
let filtered = profiles;
// Filter by group
if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else {
filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId,
);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim();
filtered = filtered.filter((profile) => {
// Search in profile name
if (profile.name.toLowerCase().includes(query)) return true;
// Search in browser name
if (profile.browser.toLowerCase().includes(query)) return true;
// Search in tags
if (profile.tags?.some((tag) => tag.toLowerCase().includes(query)))
return true;
return false;
});
}
return filtered;
}, [profiles, selectedGroupId, searchQuery]);
// Update loading states
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
<div className="w-full">
<HomeHeader
@@ -782,20 +706,21 @@ export default function Home() {
onImportProfileDialogOpen={setImportProfileDialogOpen}
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
onSettingsDialogOpen={setSettingsDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
</div>
<div className="space-y-4 w-full">
<GroupBadges
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
groups={groups}
isLoading={areGroupsLoading}
groups={groupsData}
isLoading={isLoading}
/>
<ProfilesDataTable
data={profiles}
profiles={filteredProfiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onProxySettings={openProxyDialog}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
@@ -810,15 +735,17 @@ export default function Home() {
</div>
</main>
<ProxySettingsDialog
isOpen={proxyDialogOpen}
onClose={() => {
setProxyDialogOpen(false);
}}
onSave={handleSaveProxy}
initialProxyId={currentProfileForProxy?.proxy_id}
browserType={currentProfileForProxy?.browser}
/>
{isInitializing && (
<div className="fixed inset-0 z-1000 backdrop-blur-sm bg-background/30 flex items-center justify-center">
<div className="bg-background rounded-xl p-6 shadow-xl border border-border/10 w-[320px] text-center">
<div className="text-lg font-medium">Initializing</div>
<div className="mt-1 mb-2 text-sm text-gray-600 dark:text-gray-300">
Please don't close the app
</div>
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
</div>
</div>
)}
<CreateProfileDialog
isOpen={createProfileDialogOpen}
@@ -841,7 +768,6 @@ export default function Home() {
onClose={() => {
setImportProfileDialogOpen(false);
}}
onImportComplete={() => void loadProfiles()}
/>
<ProxyManagementDialog
@@ -899,6 +825,7 @@ export default function Home() {
}}
selectedProfiles={selectedProfilesForGroup}
onAssignmentComplete={handleGroupAssignmentComplete}
profiles={profiles}
/>
<DeleteConfirmationDialog
@@ -909,7 +836,8 @@ export default function Home() {
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
isLoading={isBulkDeleting}
profileNames={selectedProfiles}
profileIds={selectedProfiles}
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
/>
</div>
);
+7 -13
View File
@@ -17,26 +17,20 @@ interface AppUpdateToastProps {
function getStageIcon(stage?: string, isUpdating?: boolean) {
if (!isUpdating) {
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
}
switch (stage) {
case "downloading":
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
case "extracting":
return (
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
);
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
case "installing":
return (
<LuCog className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
);
return <LuCog className="flex-shrink-0 w-5 h-5 animate-spin" />;
case "completed":
return <LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />;
return <LuCheckCheck className="flex-shrink-0 w-5 h-5" />;
default:
return (
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
);
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
}
}
@@ -79,7 +73,7 @@ export function AppUpdateToast({
updateProgress.stage === "completed");
return (
<div className="flex items-start p-4 w-full max-w-md bg-card rounded-lg border border-border shadow-lg text-card-foreground">
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
{getStageIcon(updateProgress?.stage, isUpdating)}
</div>
+2 -2
View File
@@ -103,11 +103,11 @@ export function CamoufoxConfigDialog({
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
Configure Camoufox Settings - {profile.name}
Configure Fingerprint Settings - {profile.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 h-[400px]">
<ScrollArea className="flex-1 h-[320px]">
<div className="py-4">
<SharedCamoufoxConfigForm
config={config}
+500 -259
View File
@@ -6,7 +6,7 @@ import { GoPlus } from "react-icons/go";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -25,9 +25,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
import type { BrowserReleaseTypes, CamoufoxConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
@@ -98,30 +100,45 @@ export function CreateProfileDialog({
selectedGroupId,
}: CreateProfileDialogProps) {
const [profileName, setProfileName] = useState("");
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
>("browser-selection");
const [activeTab, setActiveTab] = useState("anti-detect");
// Regular browser states
// Browser selection states
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("camoufox");
useState<BrowserTypeString | null>(null);
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const handleTabChange = (value: string) => {
if (value === "regular") {
setSelectedBrowser(null);
} else if (value === "anti-detect") {
setSelectedBrowser("camoufox");
}
setActiveTab(value);
};
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
geoip: true, // Default to automatic geoip
});
// Handle browser selection from the initial screen
const handleBrowserSelect = (browser: BrowserTypeString) => {
setSelectedBrowser(browser);
setCurrentStep("browser-config");
};
// Handle back button
const handleBack = () => {
setCurrentStep("browser-selection");
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
};
const handleTabChange = (value: string) => {
setActiveTab(value);
setCurrentStep("browser-selection");
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
};
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const { storedProxies } = useProxyEvents();
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
@@ -144,15 +161,6 @@ export function CreateProfileDialog({
}
}, []);
const loadStoredProxies = useCallback(async () => {
try {
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxies);
} catch (error) {
console.error("Failed to load stored proxies:", error);
}
}, []);
const checkAndDownloadGeoIPDatabase = useCallback(async () => {
try {
const isAvailable = await invoke<boolean>("is_geoip_database_available");
@@ -178,6 +186,8 @@ export function CreateProfileDialog({
{ browserStr: browser },
);
await loadDownloadedVersions(browser);
// Only update state if this browser is still the one we're loading
if (loadingBrowserRef.current === browser) {
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
@@ -197,12 +207,29 @@ export function CreateProfileDialog({
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
}
// Load downloaded versions for this browser
await loadDownloadedVersions(browser);
}
} catch (error) {
console.error(`Failed to load release types for ${browser}:`, error);
// Fallback: still load downloaded versions and derive release type from them if possible
try {
const downloaded = await loadDownloadedVersions(browser);
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
const latest = downloaded[0];
const fallback: BrowserReleaseTypes = {};
if (browser === "firefox-developer") {
fallback.nightly = latest;
} else {
fallback.stable = latest;
}
setReleaseTypes(fallback);
}
} catch (e) {
console.error(
`Failed to load downloaded versions for ${browser}:`,
e,
);
}
} finally {
// Clear loading state only if we're still loading this browser
if (loadingBrowserRef.current === browser) {
@@ -217,18 +244,21 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
void loadStoredProxies();
// Load camoufox release types when dialog opens
void loadReleaseTypes("camoufox");
// Load release types when a browser is selected
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
}
// Check and download GeoIP database if needed for Camoufox
void checkAndDownloadGeoIPDatabase();
if (selectedBrowser === "camoufox") {
void checkAndDownloadGeoIPDatabase();
}
}
}, [
isOpen,
loadSupportedBrowsers,
loadStoredProxies,
loadReleaseTypes,
checkAndDownloadGeoIPDatabase,
selectedBrowser,
]);
// Load release types when browser selection changes
@@ -283,7 +313,29 @@ export function CreateProfileDialog({
setIsCreating(true);
try {
if (activeTab === "regular") {
if (activeTab === "anti-detect") {
// Anti-detect browser - always use Camoufox with best available version
const bestCamoufoxVersion = getBestAvailableVersion("camoufox");
if (!bestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
// We don't need to generate it here during profile creation
const finalCamoufoxConfig = { ...camoufoxConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: selectedProxyId,
camoufoxConfig: finalCamoufoxConfig,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
});
} else {
// Regular browser
if (!selectedBrowser) {
console.error("Missing required browser selection");
return;
@@ -304,27 +356,6 @@ export function CreateProfileDialog({
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
});
} else {
// Anti-detect tab - always use Camoufox with best available version
const bestCamoufoxVersion = getBestAvailableVersion("camoufox");
if (!bestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
// We don't need to generate it here during profile creation
const finalCamoufoxConfig = { ...camoufoxConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: selectedProxyId,
camoufoxConfig: finalCamoufoxConfig,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
});
}
handleClose();
@@ -341,13 +372,14 @@ export function CreateProfileDialog({
// Reset all states
setProfileName("");
setCurrentStep("browser-selection");
setActiveTab("anti-detect");
setSelectedBrowser(null);
setSelectedProxyId(undefined);
setReleaseTypes({});
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
});
setActiveTab("anti-detect");
onClose();
};
@@ -386,25 +418,23 @@ export function CreateProfileDialog({
isBrowserVersionAvailable,
]);
useEffect(() => {
console.log(
selectedBrowser,
selectedBrowser && isBrowserCurrentlyDownloading(selectedBrowser),
selectedBrowser && isBrowserVersionAvailable(selectedBrowser),
selectedBrowser && getBestAvailableVersion(selectedBrowser),
);
}, [
selectedBrowser,
isBrowserCurrentlyDownloading,
isBrowserVersionAvailable,
getBestAvailableVersion,
]);
// Filter supported browsers for regular browsers (excluding mullvad and tor)
const regularBrowsers = browserOptions.filter(
(browser) =>
supportedBrowsers.includes(browser.value) &&
browser.value !== "mullvad-browser" &&
browser.value !== "tor-browser",
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-full max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
<DialogTitle>
{currentStep === "browser-selection"
? "Create New Profile"
: "Configure Profile"}
</DialogTitle>
</DialogHeader>
<Tabs
@@ -420,221 +450,432 @@ export function CreateProfileDialog({
<TabsTrigger value="regular">Regular</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 h-[330px] overflow-y-hidden">
<ScrollArea className="overflow-y-auto flex-1">
<div className="flex flex-col justify-center items-center w-full">
<div className="py-4 space-y-6 w-full max-w-md">
{/* 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>
{currentStep === "browser-selection" ? (
<>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
{/* Anti-Detect Browser Selection */}
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium">
Anti-Detect Browser
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Choose Firefox for anti-detection capabilities
</p>
</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) &&
browser.value !== "mullvad-browser" &&
browser.value !== "tor-browser",
)
.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>
{selectedBrowser && (
<div className="space-y-3">
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
!isBrowserVersionAvailable(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(selectedBrowser);
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
})()}
</p>
<LoadingButton
onClick={() => handleDownload(selectedBrowser)}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
className="ml-auto"
size="sm"
disabled={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
>
Download
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
isBrowserVersionAvailable(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(selectedBrowser);
return `✓ Latest version (${bestVersion?.version}) is available`;
})()}
</div>
)}
{isBrowserCurrentlyDownloading(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
<Button
onClick={() => handleBrowserSelect("camoufox")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
{(() => {
const bestVersion =
getBestAvailableVersion(selectedBrowser);
return `Downloading version (${bestVersion?.version})...`;
const IconComponent = getBrowserIcon("firefox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
</div>
)}
<div className="text-left">
<div className="font-medium">Firefox</div>
<div className="text-sm text-muted-foreground">
Anti-Detect Browser
</div>
</div>
</Button>
</div>
)}
</div>
</TabsContent>
</TabsContent>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
<div className="space-y-6">
{/* Camoufox Download Status */}
{!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion("camoufox");
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
})()}
<TabsContent value="regular" className="mt-0 space-y-6">
{/* Regular Browser Selection */}
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium">
Regular Browsers
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Choose from supported regular browsers
</p>
<LoadingButton
onClick={() => handleDownload("camoufox")}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
disabled={isBrowserCurrentlyDownloading("camoufox")}
>
{isBrowserCurrentlyDownloading("camoufox")
? "Downloading..."
: "Download"}
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion("camoufox");
return `✓ Camoufox version (${bestVersion?.version}) is available`;
})()}
<div className="space-y-3">
{regularBrowsers.map((browser) => {
if (browser.value === "camoufox") return null; // Skip camoufox as it's handled in anti-detect tab
const IconComponent = getBrowserIcon(browser.value);
return (
<Button
key={browser.value}
onClick={() =>
handleBrowserSelect(browser.value)
}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
{IconComponent && (
<IconComponent className="w-6 h-6" />
)}
</div>
<div className="text-left">
<div className="font-medium">
{browser.label}
</div>
<div className="text-sm text-muted-foreground">
Regular Browser
</div>
</div>
</Button>
);
})}
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion("camoufox");
return `Downloading Camoufox version (${bestVersion?.version})...`;
})()}
</div>
)}
</TabsContent>
</>
) : (
<>
<TabsContent value="anti-detect" className="mt-0">
{/* Anti-Detect Configuration */}
<div className="space-y-6">
{/* Profile Name */}
<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>
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
/>
</div>
</TabsContent>
{selectedBrowser === "camoufox" ? (
// Camoufox Configuration
<div className="space-y-6">
{/* Camoufox Download Status */}
{!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion("camoufox");
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
})()}
</p>
<LoadingButton
onClick={() => handleDownload("camoufox")}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? "Downloading..."
: "Download"}
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion("camoufox");
return `✓ Camoufox version (${bestVersion?.version}) is available`;
})()}
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion("camoufox");
return `Downloading Camoufox version (${bestVersion?.version})...`;
})()}
</div>
)}
{/* Proxy Selection - Common to both tabs - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>Proxy</Label>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowProxyForm(true)}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
</RippleButton>
</div>
{storedProxies.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(value === "none" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies available. Add one to route this profile's
traffic.
</div>
)}
</div>
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
/>
</div>
) : (
// Regular Browser Configuration
<div className="space-y-4">
{selectedBrowser && (
<div className="space-y-3">
{!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(
selectedBrowser,
);
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
})()}
</p>
<LoadingButton
onClick={() =>
handleDownload(selectedBrowser)
}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
className="ml-auto"
size="sm"
disabled={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
>
Download
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(
selectedBrowser,
) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(
selectedBrowser,
);
return `✓ Latest version (${bestVersion?.version}) is available`;
})()}
</div>
)}
{isBrowserCurrentlyDownloading(
selectedBrowser,
) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(
selectedBrowser,
);
return `Downloading version (${bestVersion?.version})...`;
})()}
</div>
)}
</div>
)}
</div>
)}
{/* Proxy Selection - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>Proxy</Label>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowProxyForm(true)}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
</RippleButton>
</div>
{storedProxies.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(
value === "none" ? undefined : value,
)
}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies available. Add one to route this
profile's traffic.
</div>
)}
</div>
</div>
</TabsContent>
<TabsContent value="regular" className="mt-0">
{/* Regular Browser Configuration */}
<div className="space-y-6">
{/* Profile Name */}
<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>
{/* Regular Browser Configuration */}
<div className="space-y-4">
{selectedBrowser && (
<div className="space-y-3">
{!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(
selectedBrowser,
);
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
})()}
</p>
<LoadingButton
onClick={() =>
handleDownload(selectedBrowser)
}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
className="ml-auto"
size="sm"
disabled={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
>
Download
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(
selectedBrowser,
);
return `✓ Latest version (${bestVersion?.version}) is available`;
})()}
</div>
)}
{isBrowserCurrentlyDownloading(
selectedBrowser,
) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion =
getBestAvailableVersion(selectedBrowser);
return `Downloading version (${bestVersion?.version})...`;
})()}
</div>
)}
</div>
)}
</div>
{/* Proxy Selection - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>Proxy</Label>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowProxyForm(true)}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
</RippleButton>
</div>
{storedProxies.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(
value === "none" ? undefined : value,
)
}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies available. Add one to route this
profile's traffic.
</div>
)}
</div>
</div>
</TabsContent>
</>
)}
</div>
</div>
</ScrollArea>
</Tabs>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<DialogFooter className="flex-shrink-0 pt-4 border-t">
{currentStep === "browser-config" ? (
<>
<RippleButton variant="outline" onClick={handleBack}>
Back
</RippleButton>
<LoadingButton
onClick={handleCreate}
isLoading={isCreating}
disabled={isCreateDisabled}
>
Create
</LoadingButton>
</>
) : (
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<LoadingButton
onClick={handleCreate}
isLoading={isCreating}
disabled={isCreateDisabled}
>
Create
</LoadingButton>
</DialogFooter>
</Tabs>
)}
</DialogFooter>
</DialogContent>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={() => setShowProxyForm(false)}
onSave={(proxy) => {
setStoredProxies((prev) => [...prev, proxy]);
setSelectedProxyId(proxy.id);
}}
/>
</Dialog>
);
+27 -54
View File
@@ -53,7 +53,6 @@ import {
LuCheckCheck,
LuDownload,
LuRefreshCw,
LuRocket,
LuTriangleAlert,
} from "react-icons/lu";
import type { ExternalToast } from "sonner";
@@ -127,36 +126,38 @@ type ToastProps =
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />;
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />;
case "error":
return <LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-red-500" />;
return (
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
);
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />
);
}
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
return <LuDownload className="flex-shrink-0 w-4 h-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -166,33 +167,11 @@ 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 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 items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium leading-tight ${
isAutoUpdate
? "text-emerald-900 dark:text-emerald-100"
: "text-gray-900 dark:text-white"
}`}
>
<p className="text-sm font-semibold leading-tight text-foreground">
{title}
</p>
@@ -203,15 +182,15 @@ export function UnifiedToast(props: ToastProps) {
stage === "downloading" && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
{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="w-full bg-muted rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
@@ -223,21 +202,21 @@ export function UnifiedToast(props: ToastProps) {
progress &&
"current_browser" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-gray-600 dark:text-gray-300">
<p className="text-xs text-muted-foreground">
{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="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
className="bg-foreground 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">
<span className="w-8 text-xs text-right whitespace-nowrap text-muted-foreground shrink-0">
{progress.current}/{progress.total}
</span>
</div>
@@ -247,13 +226,13 @@ export function UnifiedToast(props: ToastProps) {
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
<p className="text-xs text-gray-600 dark:text-gray-300">
<p className="text-xs text-muted-foreground">
{"hasUpdate" in props && props.hasUpdate
? "New twilight build available for download"
: "Checking for twilight updates..."}
</p>
{props.browserName && (
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
<p className="mt-1 text-xs text-muted-foreground">
{props.browserName} Rolling Release
</p>
)}
@@ -262,13 +241,7 @@ export function UnifiedToast(props: ToastProps) {
{/* Description */}
{description && (
<p
className={`mt-1 text-xs leading-tight ${
isAutoUpdate
? "text-emerald-700 dark:text-emerald-300"
: "text-gray-600 dark:text-gray-300"
}`}
>
<p className="mt-1 text-xs leading-tight text-muted-foreground">
{description}
</p>
)}
@@ -277,17 +250,17 @@ export function UnifiedToast(props: ToastProps) {
{type === "download" && !description && (
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
<p className="mt-1 text-xs text-muted-foreground">
Extracting browser files...
</p>
)}
{stage === "verifying" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
<p className="mt-1 text-xs text-muted-foreground">
Verifying browser files...
</p>
)}
{stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
<p className="mt-1 text-xs text-muted-foreground">
Downloading rolling release build...
</p>
)}
+14 -8
View File
@@ -19,7 +19,8 @@ interface DeleteConfirmationDialogProps {
description: string;
confirmButtonText?: string;
isLoading?: boolean;
profileNames?: string[];
profileIds?: string[];
profiles?: { id: string; name: string }[];
}
export function DeleteConfirmationDialog({
@@ -30,7 +31,8 @@ export function DeleteConfirmationDialog({
description,
confirmButtonText = "Delete",
isLoading = false,
profileNames,
profileIds,
profiles = [],
}: DeleteConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
@@ -42,18 +44,22 @@ export function DeleteConfirmationDialog({
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
{profileNames && profileNames.length > 0 && (
{profileIds && profileIds.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Profiles to be deleted:
</p>
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
<ul className="space-y-1">
{profileNames.map((name) => (
<li key={name} className="text-sm text-muted-foreground">
{name}
</li>
))}
{profileIds.map((id) => {
const profile = profiles.find((p) => p.id === id);
const displayName = profile ? profile.name : id;
return (
<li key={id} className="text-sm text-muted-foreground">
{displayName}
</li>
);
})}
</ul>
</div>
</div>
+4 -4
View File
@@ -74,13 +74,13 @@ export function DeleteGroupDialog({
try {
if (deleteAction === "delete" && associatedProfiles.length > 0) {
// Delete all associated profiles first
const profileNames = associatedProfiles.map((p) => p.name);
await invoke("delete_selected_profiles", { profileNames });
const profileIds = associatedProfiles.map((p) => p.id);
await invoke("delete_selected_profiles", { profileIds });
} else if (deleteAction === "move" && associatedProfiles.length > 0) {
// Move profiles to default group (null group_id)
const profileNames = associatedProfiles.map((p) => p.name);
const profileIds = associatedProfiles.map((p) => p.id);
await invoke("assign_profiles_to_group", {
profileNames,
profileIds,
groupId: null,
});
}
+16 -7
View File
@@ -22,7 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ProfileGroup } from "@/types";
import type { BrowserProfile, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface GroupAssignmentDialogProps {
@@ -30,6 +30,7 @@ interface GroupAssignmentDialogProps {
onClose: () => void;
selectedProfiles: string[];
onAssignmentComplete: () => void;
profiles?: BrowserProfile[];
}
export function GroupAssignmentDialog({
@@ -37,6 +38,7 @@ export function GroupAssignmentDialog({
onClose,
selectedProfiles,
onAssignmentComplete,
profiles = [],
}: GroupAssignmentDialogProps) {
const [groups, setGroups] = useState<ProfileGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
@@ -64,7 +66,7 @@ export function GroupAssignmentDialog({
setError(null);
try {
await invoke("assign_profiles_to_group", {
profileNames: selectedProfiles,
profileIds: selectedProfiles,
groupId: selectedGroupId,
});
@@ -119,11 +121,18 @@ export function GroupAssignmentDialog({
<Label>Selected Profiles:</Label>
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileName) => (
<li key={profileName} className="truncate">
{profileName}
</li>
))}
{selectedProfiles.map((profileId) => {
// Find the profile name for display
const profile = profiles.find(
(p: BrowserProfile) => p.id === profileId,
);
const displayName = profile ? profile.name : profileId;
return (
<li key={profileId} className="truncate">
{displayName}
</li>
);
})}
</ul>
</div>
</div>
-4
View File
@@ -27,10 +27,6 @@ export function GroupBadges({
);
}
if (groups.length === 0) {
return null;
}
return (
<div className="flex flex-wrap gap-2 mb-4">
{groups.map((group) => (
+33 -8
View File
@@ -1,7 +1,7 @@
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { LuTrash2, LuUsers } from "react-icons/lu";
import { LuSearch, LuTrash2, LuUsers, LuX } from "react-icons/lu";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -23,6 +24,8 @@ type Props = {
onGroupManagementDialogOpen: (open: boolean) => void;
onImportProfileDialogOpen: (open: boolean) => void;
onCreateProfileDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
};
const HomeHeader = ({
@@ -34,8 +37,10 @@ const HomeHeader = ({
onGroupManagementDialogOpen,
onImportProfileDialogOpen,
onCreateProfileDialogOpen,
searchQuery,
onSearchQueryChange,
}: Props) => {
const _handleLogoClick = () => {
const handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
detail: "https://donutbrowser.com",
@@ -44,17 +49,17 @@ const HomeHeader = ({
};
return (
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="flex gap-3 items-center">
<button
type="button"
className="p-1 cursor-pointer"
title="Open donutbrowser.com"
onClick={_handleLogoClick}
onClick={handleLogoClick}
>
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
</button>
{selectedProfiles.length > 0 ? (
<div className="flex items-center gap-3">
<div className="flex gap-3 items-center">
<span className="text-sm font-medium">
{selectedProfiles.length} profile
{selectedProfiles.length !== 1 ? "s" : ""} selected
@@ -76,7 +81,7 @@ const HomeHeader = ({
className="flex gap-2 items-center"
>
<LuTrash2 className="w-4 h-4" />
Delete Selected
Delete
</RippleButton>
</div>
</div>
@@ -85,12 +90,32 @@ const HomeHeader = ({
)}
</div>
<div className="flex gap-2 items-center">
<div className="relative">
<Input
type="text"
placeholder="Search profiles..."
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
className="pr-8 pl-10 w-48"
/>
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
{searchQuery && (
<button
type="button"
onClick={() => onSearchQueryChange("")}
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label="Clear search"
>
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center"
className="flex gap-2 items-center h-[36px]"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
@@ -138,7 +163,7 @@ const HomeHeader = ({
onClick={() => {
onCreateProfileDialogOpen(true);
}}
className="flex gap-2 items-center"
className="flex gap-2 items-center h-[36px]"
>
<GoPlus className="w-4 h-4" />
</Button>
+1 -16
View File
@@ -31,13 +31,11 @@ import { RippleButton } from "./ui/ripple";
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
onImportComplete?: () => void;
}
export function ImportProfileDialog({
isOpen,
onClose,
onImportComplete,
}: ImportProfileDialogProps) {
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[],
@@ -140,9 +138,6 @@ export function ImportProfileDialog({
toast.success(
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
);
if (onImportComplete) {
onImportComplete();
}
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
@@ -168,7 +163,6 @@ export function ImportProfileDialog({
selectedDetectedProfile,
autoDetectProfileName,
detectedProfiles,
onImportComplete,
onClose,
]);
@@ -193,9 +187,6 @@ export function ImportProfileDialog({
toast.success(
`Successfully imported profile "${manualProfileName.trim()}"`,
);
if (onImportComplete) {
onImportComplete();
}
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
@@ -217,13 +208,7 @@ export function ImportProfileDialog({
} finally {
setIsImporting(false);
}
}, [
manualBrowserType,
manualProfilePath,
manualProfileName,
onImportComplete,
onClose,
]);
}, [manualBrowserType, manualProfilePath, manualProfileName, onClose]);
const handleClose = () => {
setSelectedDetectedProfile(null);
+43 -4
View File
@@ -287,6 +287,7 @@ const MultipleSelector = React.forwardRef<
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
// biome-ignore lint/correctness/noNestedComponentDefinitions: public code, TODO: fix
const CreatableItem = () => {
if (!creatable) return undefined;
if (
@@ -352,6 +353,10 @@ const MultipleSelector = React.forwardRef<
[options, selected],
);
const hasAvailableOptions = React.useMemo(() => {
return Object.values(selectables).some((group) => group.length > 0);
}, [selectables]);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
@@ -416,7 +421,7 @@ const MultipleSelector = React.forwardRef<
<button
type="button"
className={cn(
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
"cursor-pointer ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
(disabled || option.fixed) && "hidden",
)}
onKeyDown={(e) => {
@@ -445,6 +450,40 @@ const MultipleSelector = React.forwardRef<
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onKeyDown={(e) => {
// Allow consumer to handle first
inputProps?.onKeyDown?.(
e as unknown as React.KeyboardEvent<HTMLInputElement>,
);
if (e.defaultPrevented) return;
if (e.key === "Enter") {
const value = inputValue.trim();
if (value.length === 0) return;
// If option already exists among available options, pick that; otherwise create
const entries = Object.values(options).flat();
const existing = entries.find(
(o) => o.value === value && !o.disable,
);
// Prevent duplicates in the current selection
if (
selected.some((s) => s.value === (existing?.value ?? value))
) {
e.preventDefault();
setInputValue("");
return;
}
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
e.preventDefault();
setInputValue("");
const picked = existing ?? { value, label: value };
const newOptions = [...selected, picked];
setSelected(newOptions);
onChange?.(newOptions);
}
}}
onBlur={(event) => {
setOpen(false);
inputProps?.onBlur?.(event);
@@ -465,7 +504,7 @@ const MultipleSelector = React.forwardRef<
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
{
"w-full": hidePlaceholderWhenSelected,
"px-3 py-2": selected.length === 0,
"px-3 mt-1": selected.length === 0,
"ml-1": selected.length !== 0,
},
inputProps?.className,
@@ -474,7 +513,7 @@ const MultipleSelector = React.forwardRef<
</div>
</div>
<div className="relative">
{open && (
{open && hasAvailableOptions && (
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
{isLoading ? (
loadingIndicator
@@ -489,7 +528,7 @@ const MultipleSelector = React.forwardRef<
<CommandGroup
key={key}
heading={key}
className="overflow-auto h-full"
className="overflow-auto h-24"
>
{dropdowns.map((option) => {
return (
File diff suppressed because it is too large Load Diff
+50 -64
View File
@@ -27,8 +27,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useBrowserState } from "@/hooks/use-browser-state";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import type { BrowserProfile } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProfileSelectorDialogProps {
@@ -43,14 +45,19 @@ export function ProfileSelectorDialog({
isOpen,
onClose,
url,
runningProfiles = new Set(),
runningProfiles: externalRunningProfiles,
isUpdating,
}: ProfileSelectorDialogProps) {
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
// Use the centralized profile events hook
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents();
// Use external runningProfiles if provided, otherwise use hook's runningProfiles
const runningProfiles = externalRunningProfiles || hookRunningProfiles;
const { storedProxies } = useProxyEvents();
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [launchingProfiles, setLaunchingProfiles] = useState<Set<string>>(
new Set(),
);
@@ -77,48 +84,6 @@ export function ProfileSelectorDialog({
[storedProxies],
);
const loadProfiles = useCallback(async () => {
setIsLoading(true);
try {
// 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));
// Set both profiles and proxies
setProfiles(profileList);
setStoredProxies(proxiesList);
// Auto-select first available profile for link opening
if (profileList.length > 0) {
// First, try to find a running profile that can be used for opening links
const runningAvailableProfile = profileList.find((profile) => {
const isRunning = runningProfiles.has(profile.name);
// Simple check without browserState dependency
return (
isRunning &&
profile.browser !== "tor-browser" &&
profile.browser !== "mullvad-browser"
);
});
if (runningAvailableProfile) {
setSelectedProfile(runningAvailableProfile.name);
} else {
setSelectedProfile(profileList[0].name);
}
}
} catch (err) {
console.error("Failed to load profiles:", err);
} finally {
setIsLoading(false);
}
}, [runningProfiles]);
// Helper function to get tooltip content for profiles - now uses shared hook
const getProfileTooltipContent = (profile: BrowserProfile): string | null => {
return browserState.getProfileTooltipContent(profile);
@@ -128,10 +93,13 @@ export function ProfileSelectorDialog({
if (!selectedProfile || !url) return;
setIsLaunching(true);
setLaunchingProfiles((prev) => new Set(prev).add(selectedProfile));
const selected = profiles.find((p) => p.name === selectedProfile);
if (!selected) return;
setLaunchingProfiles((prev) => new Set(prev).add(selected.id));
try {
await invoke("open_url_with_profile", {
profileName: selectedProfile,
profileId: selected.id,
url,
});
onClose();
@@ -139,13 +107,15 @@ export function ProfileSelectorDialog({
console.error("Failed to open URL with profile:", error);
} finally {
setIsLaunching(false);
setLaunchingProfiles((prev) => {
const next = new Set(prev);
next.delete(selectedProfile);
return next;
});
if (selected) {
setLaunchingProfiles((prev) => {
const next = new Set(prev);
next.delete(selected.id);
return next;
});
}
}
}, [selectedProfile, url, onClose]);
}, [selectedProfile, url, onClose, profiles]);
const handleCancel = useCallback(() => {
setSelectedProfile(null);
@@ -178,11 +148,31 @@ export function ProfileSelectorDialog({
return getProfileTooltipContent(selectedProfileData);
};
// Auto-select first available profile when dialog opens and profiles are loaded
useEffect(() => {
if (isOpen) {
void loadProfiles();
if (isOpen && profiles.length > 0 && !selectedProfile) {
// First, try to find a running profile that can be used for opening links
const runningAvailableProfile = profiles.find((profile) => {
const isRunning = runningProfiles.has(profile.id);
// Simple check without browserState dependency
return (
isRunning &&
profile.browser !== "tor-browser" &&
profile.browser !== "mullvad-browser"
);
});
if (runningAvailableProfile) {
setSelectedProfile(runningAvailableProfile.name);
} else {
// Sort profiles by name and select first
const sortedProfiles = [...profiles].sort((a, b) =>
a.name.localeCompare(b.name),
);
setSelectedProfile(sortedProfiles[0].name);
}
}
}, [isOpen, loadProfiles]);
}, [isOpen, profiles, selectedProfile, runningProfiles]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -214,11 +204,7 @@ export function ProfileSelectorDialog({
<div className="space-y-2">
<Label htmlFor="profile-select">Select Profile:</Label>
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading profiles...
</div>
) : profiles.length === 0 ? (
{profiles.length === 0 ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
No profiles available. Please create a profile first.
@@ -238,7 +224,7 @@ export function ProfileSelectorDialog({
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => {
const isRunning = runningProfiles.has(profile.name);
const isRunning = runningProfiles.has(profile.id);
const canUseForLinks =
browserState.canUseProfileForLinks(profile);
const tooltipContent = getProfileTooltipContent(profile);
+3 -8
View File
@@ -35,14 +35,12 @@ interface ProxyFormData {
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);
@@ -105,11 +103,9 @@ export function ProxyFormDialog({
password: formData.password.trim() || undefined,
};
let savedProxy: StoredProxy;
if (editingProxy) {
// Update existing proxy
savedProxy = await invoke<StoredProxy>("update_stored_proxy", {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: formData.name.trim(),
proxySettings,
@@ -117,14 +113,13 @@ export function ProxyFormDialog({
toast.success("Proxy updated successfully");
} else {
// Create new proxy
savedProxy = await invoke<StoredProxy>("create_stored_proxy", {
await invoke("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);
@@ -134,7 +129,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
}, [formData, editingProxy, onSave, onClose]);
}, [formData, editingProxy, onClose]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
+105 -146
View File
@@ -1,10 +1,11 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useState } from "react";
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -15,11 +16,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { trimName } from "@/lib/name-utils";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -33,80 +36,33 @@ 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 [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(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);
}
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
// Open in-app confirmation dialog
setProxyToDelete(proxy);
}, []);
const loadProxyUsage = useCallback(async () => {
const handleConfirmDelete = useCallback(async () => {
if (!proxyToDelete) return;
setIsDeleting(true);
try {
const profiles = await invoke<Array<{ proxy_id?: string }>>(
"list_browser_profiles",
);
const counts: Record<string, number> = {};
for (const p of profiles) {
if (p.proxy_id) counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
}
setProxyUsage(counts);
} catch (_err) {
// ignore non-critical errors
}
}, []);
useEffect(() => {
if (isOpen) {
loadStoredProxies();
void loadProxyUsage();
}
}, [isOpen, loadStoredProxies, loadProxyUsage]);
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
try {
unlisten = await listen("profile-updated", () => {
void loadProxyUsage();
});
} catch (_err) {
// ignore non-critical errors
}
};
if (isOpen) void setup();
return () => {
if (unlisten) unlisten();
};
}, [isOpen, loadProxyUsage]);
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));
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
toast.success("Proxy deleted successfully");
await emit("stored-proxies-changed");
} catch (error) {
console.error("Failed to delete proxy:", error);
toast.error("Failed to delete proxy");
} finally {
setIsDeleting(false);
setProxyToDelete(null);
}
}, []);
}, [proxyToDelete]);
const handleCreateProxy = useCallback(() => {
setEditingProxy(null);
@@ -118,23 +74,6 @@ export function ProxyManagementDialog({
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);
@@ -172,13 +111,12 @@ export function ProxyManagementDialog({
{/* 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>
{isLoading && (
<div className="flex justify-center items-center py-6">
<div className="w-8 h-8 rounded-full border-b-2 animate-spin border-primary"></div>
</div>
) : storedProxies.length === 0 ? (
)}
{storedProxies.length === 0 && !isLoading ? (
<div className="flex flex-col justify-center items-center h-32 text-center">
<FiWifi className="mx-auto mb-4 w-12 h-12 text-muted-foreground" />
<p className="mb-2 text-muted-foreground">
@@ -193,71 +131,84 @@ export function ProxyManagementDialog({
</RippleButton>
</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 ? (
<ScrollArea className="h-[240px] pr-2">
<div className="space-y-2">
{storedProxies.map((proxy) => (
<div
key={proxy.id}
className="flex justify-between items-center p-1 rounded border bg-card"
>
<div className="flex-1 ml-2 min-w-0">
{proxy.name.length > 30 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block font-medium truncate text-card-foreground">
{trimName(proxy.name)}
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
</TooltipContent>
</Tooltip>
) : (
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
)}
</div>
<div className="mr-2">
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</div>
<div className="flex flex-shrink-0 gap-1 items-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="block font-medium truncate text-card-foreground">
{trimName(proxy.name)}
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<FiEdit2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
>
<FiTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
) : (
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
)}
</div>
</div>
<div className="mr-2">
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</div>
<div className="flex flex-shrink-0 gap-1 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<FiEdit2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<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>
</ScrollArea>
)}
</div>
</div>
@@ -271,9 +222,17 @@ export function ProxyManagementDialog({
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
editingProxy={editingProxy}
/>
<DeleteConfirmationDialog
isOpen={proxyToDelete !== null}
onClose={() => setProxyToDelete(null)}
onConfirm={handleConfirmDelete}
title="Delete Proxy"
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
confirmButtonText="Delete"
isLoading={isDeleting}
/>
</>
);
}
-330
View File
@@ -1,330 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
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 { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxySettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (proxyId: string | null) => void;
initialProxyId?: string | null;
browserType?: string;
}
export function ProxySettingsDialog({
isOpen,
onClose,
onSave,
initialProxyId,
browserType,
}: ProxySettingsDialogProps) {
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(
initialProxyId || null,
);
const [loading, setLoading] = useState(false);
const [showProxyForm, setShowProxyForm] = useState(false);
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = browserType === "tor-browser";
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);
}
}, []);
const loadProxyUsage = useCallback(async () => {
try {
const profiles = await invoke<Array<{ proxy_id?: string }>>(
"list_browser_profiles",
);
const counts: Record<string, number> = {};
for (const p of profiles) {
if (p.proxy_id) {
counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
}
}
setProxyUsage(counts);
} catch (error) {
// Non-fatal
console.error("Failed to load proxy usage:", error);
}
}, []);
useEffect(() => {
if (isOpen) {
loadStoredProxies();
void loadProxyUsage();
if (isProxyDisabled) {
setSelectedProxyId(null);
} else {
// Reset to initial proxy ID when dialog opens
setSelectedProxyId(initialProxyId || null);
}
}
}, [
isOpen,
isProxyDisabled,
loadStoredProxies,
initialProxyId,
loadProxyUsage,
]);
// Refresh usage when profiles change
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
try {
unlisten = await listen("profile-updated", () => {
void loadProxyUsage();
});
} catch (e) {
console.error(e);
}
};
if (isOpen) void setup();
return () => {
if (unlisten) unlisten();
};
}, [isOpen, loadProxyUsage]);
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 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-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 && (
<>
{/* 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>
<RippleButton
variant="outline"
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create
</RippleButton>
</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>
<Badge>{proxyUsage[proxy.id] ?? 0}</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>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
Cancel
</RippleButton>
<RippleButton onClick={handleSave} disabled={!hasChanged()}>
Save
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
/>
</>
);
}
+631 -104
View File
@@ -1,13 +1,22 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import Color from "color";
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 { Checkbox } from "@/components/ui/checkbox";
import {
ColorPicker,
ColorPickerAlpha,
ColorPickerEyeDropper,
ColorPickerFormat,
ColorPickerHue,
ColorPickerOutput,
ColorPickerSelection,
} from "@/components/ui/color-picker";
import {
Dialog,
DialogContent,
@@ -16,6 +25,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -25,18 +39,27 @@ import {
} from "@/components/ui/select";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
dismissToast,
showErrorToast,
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
getThemeByColors,
getThemeById,
THEME_VARIABLES,
THEMES,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
set_as_default_browser: boolean;
theme: string;
custom_theme?: Record<string, string>;
api_enabled: boolean;
api_port: number;
api_token?: string;
}
interface CustomThemeState {
selectedThemeId: string | null;
colors: Record<string, string>;
}
interface PermissionInfo {
@@ -45,14 +68,7 @@ interface PermissionInfo {
description: string;
}
interface VersionUpdateProgress {
current_browser: string;
total_browsers: number;
completed_browsers: number;
new_versions_found: number;
browser_new_versions: number;
status: string; // "updating", "completed", "error"
}
// Version update progress toasts are handled globally via useVersionUpdater
interface SettingsDialogProps {
isOpen: boolean;
@@ -63,10 +79,22 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
theme: "system",
custom_theme: undefined,
api_enabled: false,
api_port: 10108,
api_token: undefined,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
theme: "system",
custom_theme: undefined,
api_enabled: false,
api_port: 10108,
api_token: undefined,
});
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
selectedThemeId: null,
colors: {},
});
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -78,6 +106,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [requestingPermission, setRequestingPermission] =
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
const { setTheme } = useTheme();
const {
@@ -123,12 +152,40 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
return "Access to camera for browser applications";
}
}, []);
const loadSettings = useCallback(async () => {
setIsLoading(true);
try {
const appSettings = await invoke<AppSettings>("get_app_settings");
setSettings(appSettings);
setOriginalSettings(appSettings);
const tokyoNightTheme = getThemeById("tokyo-night");
if (!tokyoNightTheme) {
throw new Error("Tokyo Night theme not found");
}
const merged: AppSettings = {
...appSettings,
custom_theme:
appSettings.custom_theme &&
Object.keys(appSettings.custom_theme).length > 0
? appSettings.custom_theme
: tokyoNightTheme.colors,
};
setSettings(merged);
setOriginalSettings(merged);
// Initialize custom theme state
if (merged.theme === "custom" && merged.custom_theme) {
const matchingTheme = getThemeByColors(merged.custom_theme);
setCustomThemeState({
selectedThemeId: matchingTheme?.id || null,
colors: merged.custom_theme,
});
} else if (merged.theme === "custom") {
// Initialize with Tokyo Night if no custom theme exists
setCustomThemeState({
selectedThemeId: "tokyo-night",
colors: tokyoNightTheme.colors,
});
}
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
@@ -136,6 +193,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
}
}, []);
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
const root = document.documentElement;
Object.entries(vars).forEach(([k, v]) =>
root.style.setProperty(k, v, "important"),
);
}, []);
const clearCustomTheme = useCallback(() => {
const root = document.documentElement;
THEME_VARIABLES.forEach(({ key }) =>
root.style.removeProperty(key as string),
);
}, []);
const loadPermissions = useCallback(async () => {
setIsLoadingPermissions(true);
try {
@@ -225,31 +296,162 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
},
[getPermissionDisplayName, requestPermission],
);
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
await invoke("save_app_settings", { settings });
setTheme(settings.theme);
setOriginalSettings(settings);
// Update settings with current custom theme state
let settingsToSave: AppSettings = {
...settings,
custom_theme:
settings.theme === "custom"
? customThemeState.colors
: settings.custom_theme,
};
const savedSettings = await invoke<AppSettings>("save_app_settings", {
settings: settingsToSave,
});
// Update settings with any generated tokens
setSettings(savedSettings);
settingsToSave = savedSettings;
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
// Apply or clear custom variables only on Save
if (settings.theme === "custom") {
if (
customThemeState.colors &&
Object.keys(customThemeState.colors).length > 0
) {
try {
const root = document.documentElement;
// Clear any previous custom vars first
THEME_VARIABLES.forEach(({ key }) =>
root.style.removeProperty(key as string),
);
Object.entries(customThemeState.colors).forEach(([k, v]) =>
root.style.setProperty(k, v, "important"),
);
} catch {}
}
} else {
try {
const root = document.documentElement;
THEME_VARIABLES.forEach(({ key }) =>
root.style.removeProperty(key as string),
);
} catch {}
}
// Handle API server start/stop based on settings
const wasApiEnabled = originalSettings.api_enabled;
const isApiEnabled = settingsToSave.api_enabled;
if (isApiEnabled && !wasApiEnabled) {
// Start API server
try {
const port = await invoke<number>("start_api_server", {
port: settingsToSave.api_port,
});
setApiServerPort(port);
showSuccessToast(`Local API started on port ${port}`);
} catch (error) {
console.error("Failed to start API server:", error);
showErrorToast("Failed to start API server", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
// Revert the API enabled setting if start failed
settingsToSave.api_enabled = false;
const revertedSettings = await invoke<AppSettings>(
"save_app_settings",
{ settings: settingsToSave },
);
setSettings(revertedSettings);
settingsToSave = revertedSettings;
}
} else if (!isApiEnabled && wasApiEnabled) {
// Stop API server
try {
await invoke("stop_api_server");
setApiServerPort(null);
showSuccessToast("Local API stopped");
} catch (error) {
console.error("Failed to stop API server:", error);
showErrorToast("Failed to stop API server", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
}
}
setOriginalSettings(settingsToSave);
onClose();
} catch (error) {
console.error("Failed to save settings:", error);
} finally {
setIsSaving(false);
}
}, [onClose, setTheme, settings]);
}, [onClose, setTheme, settings, customThemeState, originalSettings]);
const updateSetting = useCallback(
(key: keyof AppSettings, value: boolean | string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
(
key: keyof AppSettings,
value: boolean | string | Record<string, string> | undefined,
) => {
setSettings((prev) => ({ ...prev, [key]: value as unknown as never }));
},
[],
);
const loadApiServerStatus = useCallback(async () => {
try {
const port = await invoke<number | null>("get_api_server_status");
setApiServerPort(port);
} catch (error) {
console.error("Failed to load API server status:", error);
setApiServerPort(null);
}
}, []);
const handleClose = useCallback(() => {
// Restore original theme when closing without saving
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
applyCustomTheme(originalSettings.custom_theme);
} else {
clearCustomTheme();
}
// Reset custom theme state to original
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
const matchingTheme = getThemeByColors(originalSettings.custom_theme);
setCustomThemeState({
selectedThemeId: matchingTheme?.id || null,
colors: originalSettings.custom_theme,
});
}
onClose();
}, [
originalSettings.theme,
originalSettings.custom_theme,
applyCustomTheme,
clearCustomTheme,
onClose,
]);
// Only clear custom theme when switching away from custom, don't apply live changes
useEffect(() => {
if (settings.theme !== "custom") {
clearCustomTheme();
}
}, [settings.theme, clearCustomTheme]);
useEffect(() => {
if (isOpen) {
loadSettings().catch(console.error);
checkDefaultBrowserStatus().catch(console.error);
loadApiServerStatus().catch(console.error);
// Check if we're on macOS
const userAgent = navigator.userAgent;
@@ -265,86 +467,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
checkDefaultBrowserStatus().catch(console.error);
}, 500); // Check every 500ms
// Listen for version update progress events
let unlistenFn: (() => void) | null = null;
const setupVersionUpdateListener = async () => {
try {
unlistenFn = await listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
if (progress.status === "updating") {
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
showUnifiedVersionUpdateToast(
"Checking for browser updates...",
{
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
},
);
} else if (progress.status === "completed") {
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
} else if (progress.status === "error") {
dismissToast("unified-version-update");
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
} catch (error) {
console.error(
"Failed to setup version update progress listener:",
error,
);
}
};
setupVersionUpdateListener();
// Cleanup interval and listener on component unmount or dialog close
// Cleanup interval on component unmount or dialog close
return () => {
clearInterval(intervalId);
if (unlistenFn) {
try {
unlistenFn();
} catch (error) {
console.error(
"Failed to cleanup version update progress listener:",
error,
);
}
}
};
}
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
}, [
isOpen,
loadPermissions,
checkDefaultBrowserStatus,
loadSettings,
loadApiServerStatus,
]);
// Update permissions when the permission states change
useEffect(() => {
@@ -373,10 +507,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
]);
// Check if settings have changed (excluding default browser setting)
const hasChanges = settings.theme !== originalSettings.theme;
const hasChanges =
settings.theme !== originalSettings.theme ||
settings.api_enabled !== originalSettings.api_enabled ||
(settings.theme === "custom" &&
JSON.stringify(customThemeState.colors) !==
JSON.stringify(originalSettings.custom_theme ?? {})) ||
(settings.theme !== "custom" &&
JSON.stringify(settings.custom_theme ?? {}) !==
JSON.stringify(originalSettings.custom_theme ?? {}));
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Settings</DialogTitle>
@@ -395,6 +537,15 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
value={settings.theme}
onValueChange={(value) => {
updateSetting("theme", value);
if (value === "custom") {
const tokyoNightTheme = getThemeById("tokyo-night");
if (tokyoNightTheme) {
setCustomThemeState({
selectedThemeId: "tokyo-night",
colors: tokyoNightTheme.colors,
});
}
}
}}
>
<SelectTrigger id="theme-select">
@@ -404,13 +555,126 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Choose your preferred theme or follow your system settings.
Choose your preferred theme or follow your system settings. Custom
theme changes are applied only when you save.
</p>
{settings.theme === "custom" && (
<div className="space-y-3">
<div className="space-y-2">
<Label
htmlFor="theme-preset-select"
className="text-sm font-medium"
>
Theme Preset
</Label>
<Select
value={customThemeState.selectedThemeId || "custom"}
onValueChange={(value) => {
if (value === "custom") {
setCustomThemeState((prev) => ({
...prev,
selectedThemeId: null,
}));
} else {
const theme = getThemeById(value);
if (theme) {
setCustomThemeState({
selectedThemeId: value,
colors: theme.colors,
});
}
}
}}
>
<SelectTrigger id="theme-preset-select">
<SelectValue placeholder="Select a theme preset" />
</SelectTrigger>
<SelectContent>
{THEMES.map((theme) => (
<SelectItem key={theme.id} value={theme.id}>
{theme.name}
</SelectItem>
))}
<SelectItem value="custom">Your Own</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm font-medium">Custom Colors</div>
<div className="grid grid-cols-4 gap-3">
{THEME_VARIABLES.map(({ key, label }) => {
const colorValue =
customThemeState.colors[key] || "#000000";
return (
<div
key={key}
className="flex flex-col gap-1 items-center"
>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={label}
className="w-8 h-8 rounded-md border shadow-sm cursor-pointer"
style={{ backgroundColor: colorValue }}
/>
</PopoverTrigger>
<PopoverContent
className="w-[320px] p-3"
sideOffset={6}
>
<ColorPicker
className="p-3 rounded-md border shadow-sm bg-background"
value={colorValue}
onColorChange={([r, g, b, a]) => {
const next = Color({ r, g, b }).alpha(a);
const nextStr = next.hexa();
const newColors = {
...customThemeState.colors,
[key]: nextStr,
};
// Check if colors match any preset theme
const matchingTheme =
getThemeByColors(newColors);
setCustomThemeState({
selectedThemeId: matchingTheme?.id || null,
colors: newColors,
});
}}
>
<ColorPickerSelection className="h-36 rounded" />
<div className="flex gap-3 items-center mt-3">
<ColorPickerEyeDropper />
<div className="grid gap-1 w-full">
<ColorPickerHue />
<ColorPickerAlpha />
</div>
</div>
<div className="flex gap-2 items-center mt-3">
<ColorPickerOutput />
<ColorPickerFormat />
</div>
</ColorPicker>
</PopoverContent>
</Popover>
<div className="text-[10px] text-muted-foreground text-center leading-tight">
{label}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Default Browser Section */}
@@ -505,6 +769,269 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
)}
{/* Local API Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Local API</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="api-enabled"
checked={settings.api_enabled}
onCheckedChange={async (checked: boolean) => {
updateSetting("api_enabled", checked);
try {
if (checked) {
// Ask backend to enable API and return settings with token
const next = await invoke<AppSettings>(
"save_app_settings",
{
settings: { ...settings, api_enabled: true },
},
);
setSettings(next);
} else {
const next = await invoke<AppSettings>(
"save_app_settings",
{
settings: {
...settings,
api_enabled: false,
api_token: null,
},
},
);
setSettings(next);
}
} catch (e) {
console.error("Failed to toggle API:", e);
}
}}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
(ALPHA) Enable Local API Server
</Label>
<p className="text-xs text-muted-foreground">
Allow managing the application data externally via REST API.
Server will start on port 10108 or a random port if
unavailable.
{apiServerPort && (
<span className="ml-1 font-medium text-green-600">
(Currently running on port {apiServerPort})
</span>
)}
</p>
</div>
</div>
{settings.api_enabled && settings.api_token && (
<div className="space-y-2">
<Label className="text-sm font-medium">
API Authentication Token
</Label>
<div className="flex items-center space-x-2">
<input
type="text"
value={settings.api_token}
readOnly
className="flex-1 px-3 py-2 font-mono text-sm rounded-md border bg-muted"
/>
<RippleButton
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(settings.api_token || "");
showSuccessToast("API token copied to clipboard");
}}
>
Copy
</RippleButton>
</div>
<p className="text-xs text-muted-foreground">
Include this token in the Authorization header as "Bearer{" "}
{settings.api_token}" for all API requests.
</p>
{/* Temporary in-app API docs */}
<div className="p-3 mt-3 space-y-2 text-xs leading-relaxed rounded-md border bg-muted/40">
<div className="font-medium">
Temporary in-app API docs (alpha)
</div>
<div>
<div>
Base URL:{" "}
<code className="font-mono">{`http://127.0.0.1:${apiServerPort ?? settings.api_port ?? 10108}/v1`}</code>
</div>
<div>
Auth:{" "}
<code className="font-mono">
Authorization: Bearer {settings.api_token}
</code>
</div>
</div>
<div className="space-y-1">
<div className="font-medium">Profiles</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /profiles</code> list
profiles
</li>
<li>
<code className="font-mono">
GET /profiles/{"{"}id{"}"}
</code>{" "}
get one
</li>
<li>
<code className="font-mono">POST /profiles</code>
create
<span className="ml-1 text-muted-foreground">
(required: name, browser, version; optional:
release_type, proxy_id, camoufox_config, group_id,
tags)
</span>
</li>
<li>
<code className="font-mono">
PUT /profiles/{"{"}id{"}"}
</code>{" "}
update
<span className="ml-1 text-muted-foreground">
(any of: name, version, proxy_id, camoufox_config,
group_id, tags)
</span>
</li>
<li>
<code className="font-mono">
DELETE /profiles/{"{"}id{"}"}
</code>{" "}
delete
</li>
<li>
<code className="font-mono">
POST /profiles/{"{"}id{"}"}/run?headless=true|false
</code>{" "}
launch with remote debugging
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Groups</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /groups</code> list
</li>
<li>
<code className="font-mono">
GET /groups/{"{"}id{"}"}
</code>{" "}
get one
</li>
<li>
<code className="font-mono">POST /groups</code> create
<span className="ml-1 text-muted-foreground">
(required: name)
</span>
</li>
<li>
<code className="font-mono">
PUT /groups/{"{"}id{"}"}
</code>{" "}
rename
<span className="ml-1 text-muted-foreground">
(required: name)
</span>
</li>
<li>
<code className="font-mono">
DELETE /groups/{"{"}id{"}"}
</code>{" "}
delete
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Tags</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /tags</code> list
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Proxies</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /proxies</code> list
</li>
<li>
<code className="font-mono">
GET /proxies/{"{"}id{"}"}
</code>{" "}
get one
</li>
<li>
<code className="font-mono">POST /proxies</code>
create
<span className="ml-1 text-muted-foreground">
(required: name, proxy_settings object)
</span>
</li>
<li>
<code className="font-mono">
PUT /proxies/{"{"}id{"}"}
</code>{" "}
update
<span className="ml-1 text-muted-foreground">
(optional: name, proxy_settings)
</span>
</li>
<li>
<code className="font-mono">
DELETE /proxies/{"{"}id{"}"}
</code>{" "}
delete
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Browsers</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">
POST /browsers/download
</code>{" "}
download
<span className="ml-1 text-muted-foreground">
(required: browser, version)
</span>
</li>
<li>
<code className="font-mono">
GET /browsers/{"{"}browser{"}"}/versions
</code>{" "}
list versions
</li>
<li>
<code className="font-mono">
GET /browsers/{"{"}browser{"}"}/versions/{"{"}version
{"}"}/downloaded
</code>{" "}
is downloaded
</li>
</ul>
</div>
<div className="text-muted-foreground">
These docs are temporary and will be replaced with full
documentation later.
</div>
</div>
</div>
)}
</div>
{/* Advanced Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>
@@ -529,7 +1056,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
<DialogFooter className="flex-shrink-0">
<RippleButton variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<LoadingButton
+148 -124
View File
@@ -83,7 +83,7 @@ export function SharedCamoufoxConfigForm({
forceAdvanced = false,
}: SharedCamoufoxConfigFormProps) {
const [activeTab, setActiveTab] = useState(
forceAdvanced ? "advanced" : "normal",
forceAdvanced ? "manual" : "automatic",
);
const [fingerprintConfig, setFingerprintConfig] =
useState<CamoufoxFingerprintConfig>({});
@@ -285,7 +285,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"navigator.hardwareConcurrency",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 8"
@@ -300,7 +300,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"navigator.maxTouchPoints",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 0"
@@ -357,7 +357,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"screen.width",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 1920"
@@ -372,7 +372,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"screen.height",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 1080"
@@ -387,7 +387,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"screen.availWidth",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 1920"
@@ -402,7 +402,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"screen.availHeight",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 1055"
@@ -417,7 +417,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"screen.colorDepth",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 30"
@@ -432,7 +432,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"screen.pixelDepth",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 30"
@@ -454,7 +454,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"window.outerWidth",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 1512"
@@ -469,7 +469,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"window.outerHeight",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 886"
@@ -484,7 +484,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"window.innerWidth",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 1512"
@@ -499,7 +499,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"window.innerHeight",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 886"
@@ -514,7 +514,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"window.screenX",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 0"
@@ -529,7 +529,7 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
updateFingerprintConfig(
"window.screenY",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder="e.g., 0"
@@ -538,6 +538,106 @@ export function SharedCamoufoxConfigForm({
</div>
</div>
{/* Geolocation */}
<div className="space-y-3">
<Label>Geolocation</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
type="number"
step="any"
value={fingerprintConfig["geolocation:latitude"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"geolocation:latitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 41.0019"
/>
</div>
<div className="space-y-2">
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
type="number"
step="any"
value={fingerprintConfig["geolocation:longitude"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"geolocation:longitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 28.9645"
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Input
id="timezone"
type="text"
value={fingerprintConfig.timezone || ""}
onChange={(e) =>
updateFingerprintConfig("timezone", e.target.value || undefined)
}
placeholder="e.g., America/New_York"
/>
</div>
</div>
</div>
{/* Locale */}
<div className="space-y-3">
<Label>Locale</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="locale-language">Language</Label>
<Input
id="locale-language"
value={fingerprintConfig["locale:language"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"locale:language",
e.target.value || undefined,
)
}
placeholder="e.g., tr"
/>
</div>
<div className="space-y-2">
<Label htmlFor="locale-region">Region</Label>
<Input
id="locale-region"
value={fingerprintConfig["locale:region"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"locale:region",
e.target.value || undefined,
)
}
placeholder="e.g., TR"
/>
</div>
<div className="space-y-2">
<Label htmlFor="locale-script">Script</Label>
<Input
id="locale-script"
value={fingerprintConfig["locale:script"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"locale:script",
e.target.value || undefined,
)
}
placeholder="e.g., Latn"
/>
</div>
</div>
</div>
{/* WebGL Properties */}
<div className="space-y-3">
<Label>WebGL Properties</Label>
@@ -637,106 +737,6 @@ export function SharedCamoufoxConfigForm({
/>
</div>
{/* Geolocation */}
<div className="space-y-3">
<Label>Geolocation</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
type="number"
step="any"
value={fingerprintConfig["geolocation:latitude"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"geolocation:latitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 41.0019"
/>
</div>
<div className="space-y-2">
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
type="number"
step="any"
value={fingerprintConfig["geolocation:longitude"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"geolocation:longitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 28.9645"
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Input
id="timezone"
type="text"
value={fingerprintConfig.timezone || ""}
onChange={(e) =>
updateFingerprintConfig("timezone", e.target.value || undefined)
}
placeholder="e.g., America/New_York"
/>
</div>
</div>
</div>
{/* Locale */}
<div className="space-y-3">
<Label>Locale</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="locale-language">Language</Label>
<Input
id="locale-language"
value={fingerprintConfig["locale:language"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"locale:language",
e.target.value || undefined,
)
}
placeholder="e.g., tr"
/>
</div>
<div className="space-y-2">
<Label htmlFor="locale-region">Region</Label>
<Input
id="locale-region"
value={fingerprintConfig["locale:region"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"locale:region",
e.target.value || undefined,
)
}
placeholder="e.g., TR"
/>
</div>
<div className="space-y-2">
<Label htmlFor="locale-script">Script</Label>
<Input
id="locale-script"
value={fingerprintConfig["locale:script"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"locale:script",
e.target.value || undefined,
)
}
placeholder="e.g., Latn"
/>
</div>
</div>
</div>
{/* Fonts */}
<div className="space-y-3">
<Label>Fonts</Label>
@@ -808,6 +808,23 @@ export function SharedCamoufoxConfigForm({
</div>
</div>
</div>
{/* Browser Behavior */}
{/* <div className="space-y-3">
<Label>Browser Behavior</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="allow-addon-new-tab"
checked={fingerprintConfig.allowAddonNewTab}
onCheckedChange={(checked) =>
updateFingerprintConfig("allowAddonNewTab", checked)
}
/>
<Label htmlFor="allow-addon-new-tab">
Allow browser addons to open new tabs automatically
</Label>
</div>
</div> */}
</div>
);
@@ -817,14 +834,13 @@ export function SharedCamoufoxConfigForm({
// Advanced mode only (for editing)
renderAdvancedForm()
) : (
// Normal/Advanced tabs for creation
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="normal">Normal</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
<TabsTrigger value="automatic">Automatic</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="normal" className="space-y-6">
<TabsContent value="automatic" className="space-y-6">
{/* Automatic Location Configuration */}
<div className="mt-4 space-y-3">
<div className="flex items-center space-x-2">
@@ -853,7 +869,9 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
onConfigChange(
"screen_max_width",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value
? parseInt(e.target.value, 10)
: undefined,
)
}
placeholder="e.g., 1920"
@@ -868,7 +886,9 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
onConfigChange(
"screen_max_height",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value
? parseInt(e.target.value, 10)
: undefined,
)
}
placeholder="e.g., 1080"
@@ -883,7 +903,9 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
onConfigChange(
"screen_min_width",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value
? parseInt(e.target.value, 10)
: undefined,
)
}
placeholder="e.g., 800"
@@ -898,7 +920,9 @@ export function SharedCamoufoxConfigForm({
onChange={(e) =>
onConfigChange(
"screen_min_height",
e.target.value ? parseInt(e.target.value) : undefined,
e.target.value
? parseInt(e.target.value, 10)
: undefined,
)
}
placeholder="e.g., 600"
@@ -908,7 +932,7 @@ export function SharedCamoufoxConfigForm({
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-6">
<TabsContent value="manual" className="space-y-6">
{renderAdvancedForm()}
</TabsContent>
</Tabs>
+43 -4
View File
@@ -2,10 +2,12 @@
import { ThemeProvider } from "next-themes";
import { useEffect, useState } from "react";
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
interface AppSettings {
set_as_default_browser: boolean;
theme: string;
custom_theme?: Record<string, string>;
}
interface CustomThemeProviderProps {
@@ -27,12 +29,26 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
// Lazy import to avoid pulling Tauri API on SSR
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
const themeValue = settings?.theme ?? "system";
if (
settings?.theme === "light" ||
settings?.theme === "dark" ||
settings?.theme === "system"
themeValue === "light" ||
themeValue === "dark" ||
themeValue === "system"
) {
setDefaultTheme(settings.theme);
setDefaultTheme(themeValue);
} else if (themeValue === "custom") {
setDefaultTheme("light");
if (
settings.custom_theme &&
Object.keys(settings.custom_theme).length > 0
) {
try {
applyThemeColors(settings.custom_theme);
} catch (error) {
console.warn("Failed to apply custom theme variables:", error);
}
}
} else {
setDefaultTheme("system");
}
@@ -51,6 +67,29 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
void loadTheme();
}, []);
// Additional effect to ensure custom theme is applied after mount
useEffect(() => {
if (!isLoading && _mounted) {
const reapplyCustomTheme = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
if (settings?.theme === "custom" && settings.custom_theme) {
applyThemeColors(settings.custom_theme);
} else {
clearThemeColors();
}
} catch (error) {
console.warn("Failed to reapply custom theme:", error);
}
};
// Apply after a short delay to ensure CSS has loaded
setTimeout(reapplyCustomTheme, 100);
}
}, [isLoading, _mounted]);
if (isLoading) {
// Keep UI simple during initial settings load to avoid flicker
return null;
+517
View File
@@ -0,0 +1,517 @@
"use client";
import Color from "color";
import { Slider } from "radix-ui";
import {
type ComponentProps,
createContext,
type HTMLAttributes,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { LuPipette } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface ColorPickerContextValue {
hue: number;
saturation: number;
lightness: number;
alpha: number;
mode: string;
setHue: (hue: number) => void;
setSaturation: (saturation: number) => void;
setLightness: (lightness: number) => void;
setAlpha: (alpha: number) => void;
setMode: (mode: string) => void;
}
const ColorPickerContext = createContext<ColorPickerContextValue | undefined>(
undefined,
);
export const useColorPicker = () => {
const context = useContext(ColorPickerContext);
if (!context) {
throw new Error("useColorPicker must be used within a ColorPickerProvider");
}
return context;
};
export type ColorPickerProps = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
> & {
value?: Parameters<typeof Color>[0];
defaultValue?: Parameters<typeof Color>[0];
onColorChange?: (value: [number, number, number, number]) => void;
};
export const ColorPicker = ({
value,
defaultValue = "#000000",
onColorChange,
className,
children,
...props
}: ColorPickerProps) => {
const selectedColor = Color(value ?? defaultValue);
const defaultColor = Color(defaultValue);
const initialHue = Number.isFinite(selectedColor.hue())
? selectedColor.hue()
: Number.isFinite(defaultColor.hue())
? defaultColor.hue()
: 0;
const initialSaturation = Number.isFinite(selectedColor.saturationl())
? selectedColor.saturationl()
: Number.isFinite(defaultColor.saturationl())
? defaultColor.saturationl()
: 100;
const initialLightness = Number.isFinite(selectedColor.lightness())
? selectedColor.lightness()
: Number.isFinite(defaultColor.lightness())
? defaultColor.lightness()
: 50;
const initialAlpha = Number.isFinite(selectedColor.alpha())
? Math.round(selectedColor.alpha() * 100)
: Math.round(defaultColor.alpha() * 100);
const [hue, setHue] = useState(initialHue);
const [saturation, setSaturation] = useState(initialSaturation);
const [lightness, setLightness] = useState(initialLightness);
const [alpha, setAlpha] = useState(initialAlpha);
const [mode, setMode] = useState("hex");
const lastEmittedRef = useRef<string>(
`${Math.round(initialHue)}|${Math.round(initialSaturation)}|${Math.round(initialLightness)}|${Math.round(initialAlpha)}`,
);
// Update color when controlled value changes
useEffect(() => {
if (value !== undefined) {
const c = Color(value).hsl();
const nextHue = Number.isFinite(c.hue()) ? c.hue() : 0;
const nextSat = Number.isFinite(c.saturationl()) ? c.saturationl() : 0;
const nextLight = Number.isFinite(c.lightness()) ? c.lightness() : 0;
const nextAlpha = Math.round(
(Number.isFinite(c.alpha()) ? c.alpha() : 1) * 100,
);
// Update internal state unconditionally when value prop changes
setHue(nextHue);
setSaturation(nextSat);
setLightness(nextLight);
setAlpha(nextAlpha);
}
}, [value]); // Remove state values from dependency array to prevent infinite loop
// Notify parent of changes
useEffect(() => {
if (onColorChange) {
const key = `${Math.round(hue)}|${Math.round(saturation)}|${Math.round(lightness)}|${Math.round(alpha)}`;
if (key === lastEmittedRef.current) {
return;
}
lastEmittedRef.current = key;
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);
const rgba = color.rgb().array();
onColorChange([rgba[0], rgba[1], rgba[2], alpha / 100]);
}
}, [hue, saturation, lightness, alpha, onColorChange]);
return (
<ColorPickerContext.Provider
value={{
hue,
saturation,
lightness,
alpha,
mode,
setHue,
setSaturation,
setLightness,
setAlpha,
setMode,
}}
>
<div
className={cn("flex flex-col gap-4 size-full", className)}
{...props}
>
{children}
</div>
</ColorPickerContext.Provider>
);
};
export type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>;
export const ColorPickerSelection = memo(
({ className, ...props }: ColorPickerSelectionProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [positionX, setPositionX] = useState(0);
const [positionY, setPositionY] = useState(0);
const { hue, saturation, lightness, setSaturation, setLightness } =
useColorPicker();
const backgroundGradient = useMemo(() => {
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)),
hsl(${hue}, 100%, 50%)`;
}, [hue]);
// Update position indicators when saturation/lightness change externally
useEffect(() => {
if (!isDragging) {
const x = saturation / 100;
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
const y = topLightness > 0 ? 1 - lightness / topLightness : 0;
setPositionX(x);
setPositionY(Math.max(0, Math.min(1, y)));
}
}, [saturation, lightness, isDragging]);
const handlePointerMove = useCallback(
(event: PointerEvent) => {
if (!(isDragging && containerRef.current)) {
return;
}
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(
0,
Math.min(1, (event.clientX - rect.left) / rect.width),
);
const y = Math.max(
0,
Math.min(1, (event.clientY - rect.top) / rect.height),
);
setPositionX(x);
setPositionY(y);
setSaturation(x * 100);
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
const lightness = topLightness * (1 - y);
setLightness(lightness);
},
[isDragging, setSaturation, setLightness],
);
useEffect(() => {
const handlePointerUp = () => setIsDragging(false);
if (isDragging) {
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [isDragging, handlePointerMove]);
return (
<div
className={cn("relative rounded cursor-pointer size-full", className)}
onPointerDown={(e) => {
e.preventDefault();
setIsDragging(true);
handlePointerMove(e.nativeEvent);
}}
ref={containerRef}
style={{
background: backgroundGradient,
}}
{...props}
>
<div
className="absolute w-4 h-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: `${positionX * 100}%`,
top: `${positionY * 100}%`,
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
}}
/>
</div>
);
},
);
ColorPickerSelection.displayName = "ColorPickerSelection";
export type ColorPickerHueProps = ComponentProps<typeof Slider.Root>;
export const ColorPickerHue = ({
className,
...props
}: ColorPickerHueProps) => {
const { hue, setHue } = useColorPicker();
return (
<Slider.Root
className={cn("flex relative w-full h-4 touch-none", className)}
max={360}
onValueChange={([hue]) => setHue(hue)}
step={1}
value={[hue]}
{...props}
>
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
<Slider.Range className="absolute h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
export type ColorPickerAlphaProps = ComponentProps<typeof Slider.Root>;
export const ColorPickerAlpha = ({
className,
...props
}: ColorPickerAlphaProps) => {
const { alpha, setAlpha } = useColorPicker();
return (
<Slider.Root
className={cn("flex relative w-full h-4 touch-none", className)}
max={100}
onValueChange={([alpha]) => setAlpha(alpha)}
step={1}
value={[alpha]}
{...props}
>
<Slider.Track
className="relative my-0.5 h-3 w-full grow rounded-full"
style={{
background:
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
export type ColorPickerEyeDropperProps = ComponentProps<typeof Button>;
export const ColorPickerEyeDropper = ({
className,
...props
}: ColorPickerEyeDropperProps) => {
const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker();
const handleEyeDropper = async () => {
try {
// @ts-expect-error - EyeDropper API is experimental
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
const color = Color(result.sRGBHex);
const [h, s, l] = color.hsl().array();
setHue(h);
setSaturation(s);
setLightness(l);
setAlpha(100);
} catch (error) {
console.error("EyeDropper failed:", error);
}
};
return (
<Button
className={cn("shrink-0 text-muted-foreground", className)}
onClick={handleEyeDropper}
size="icon"
variant="outline"
type="button"
{...props}
>
<LuPipette size={16} />
</Button>
);
};
export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;
const formats = ["hex", "rgb", "css", "hsl"];
export const ColorPickerOutput = ({
className,
...props
}: ColorPickerOutputProps) => {
const { mode, setMode } = useColorPicker();
return (
<Select onValueChange={setMode} value={mode}>
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
<SelectValue placeholder="Mode" />
</SelectTrigger>
<SelectContent>
{formats.map((format) => (
<SelectItem className="text-xs" key={format} value={format}>
{format.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
type PercentageInputProps = ComponentProps<typeof Input>;
const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
return (
<div className="relative">
<Input
readOnly
type="text"
{...props}
className={cn(
"h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none",
className,
)}
/>
<span className="absolute right-2 top-1/2 text-xs -translate-y-1/2 text-muted-foreground">
%
</span>
</div>
);
};
export type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>;
export const ColorPickerFormat = ({
className,
...props
}: ColorPickerFormatProps) => {
const { hue, saturation, lightness, alpha, mode } = useColorPicker();
const color = Color.hsl(hue, saturation, lightness, alpha / 100);
if (mode === "hex") {
const hex = color.hex();
return (
<div
className={cn(
"flex relative items-center -space-x-px w-full rounded-md shadow-sm",
className,
)}
{...props}
>
<Input
className="px-2 h-8 text-xs rounded-r-none shadow-none bg-secondary"
readOnly
type="text"
value={hex}
/>
<PercentageInput value={alpha} />
</div>
);
}
if (mode === "rgb") {
const rgb = color
.rgb()
.array()
.map((value) => Math.round(value));
return (
<div
className={cn(
"flex items-center -space-x-px rounded-md shadow-sm",
className,
)}
{...props}
>
{rgb.map((value, index) => (
<Input
className={cn(
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
index && "rounded-l-none",
className,
)}
key={`rgb-${value.toString()}`}
readOnly
type="text"
value={value}
/>
))}
<PercentageInput value={alpha} />
</div>
);
}
if (mode === "css") {
const rgb = color
.rgb()
.array()
.map((value) => Math.round(value));
return (
<div className={cn("w-full rounded-md shadow-sm", className)} {...props}>
<Input
className="px-2 w-full h-8 text-xs shadow-none bg-secondary"
readOnly
type="text"
value={`rgba(${rgb.join(", ")}, ${alpha}%)`}
{...props}
/>
</div>
);
}
if (mode === "hsl") {
const hsl = color
.hsl()
.array()
.map((value) => Math.round(value));
return (
<div
className={cn(
"flex items-center -space-x-px rounded-md shadow-sm",
className,
)}
{...props}
>
{hsl.map((value, index) => (
<Input
className={cn(
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
index && "rounded-l-none",
className,
)}
key={`hsl-${value.toString()}`}
readOnly
type="text"
value={value}
/>
))}
<PercentageInput value={alpha} />
</div>
);
}
return null;
};
+7 -1
View File
@@ -39,7 +39,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-background/50",
className,
)}
{...props}
@@ -63,6 +63,12 @@ function DialogContent({
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[10000] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
onInteractOutside={(event) => {
const target = event.target as HTMLElement | null;
if (target?.closest('[data-window-drag-area="true"]')) {
event.preventDefault();
}
}}
{...props}
>
{children}
+2 -5
View File
@@ -6,10 +6,7 @@ import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="overflow-x-auto relative w-full"
>
<div data-slot="table-container" className="overflow-visible w-full">
<table
data-slot="table"
className={cn("w-full text-sm caption-bottom", className)}
@@ -70,7 +67,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap",
"px-2 h-10 font-medium text-left align-middle whitespace-nowrap text-foreground",
className,
)}
{...props}
+6 -5
View File
@@ -16,11 +16,11 @@ export function WindowDragArea() {
checkPlatform();
}, []);
const handleMouseDown = (e: React.MouseEvent) => {
// Only handle left mouse button
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
// Start dragging asynchronously
const startDrag = async () => {
try {
const window = getCurrentWindow();
@@ -41,8 +41,9 @@ export function WindowDragArea() {
return (
<button
type="button"
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[9999] select-none"
onMouseDown={handleMouseDown}
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
data-window-drag-area="true"
onPointerDown={handlePointerDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
+23 -23
View File
@@ -35,7 +35,7 @@ export function useBrowserState(
(browserType: string): boolean => {
if (!isClient) return false;
return profiles.some(
(p) => p.browser === browserType && runningProfiles.has(p.name),
(p) => p.browser === browserType && runningProfiles.has(p.id),
);
},
[profiles, runningProfiles, isClient],
@@ -48,10 +48,10 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
const isRunning = runningProfiles.has(profile.name);
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If the profile is launching or stopping, disable the button
if (isLaunching || isStopping) {
@@ -92,10 +92,10 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
const isRunning = runningProfiles.has(profile.name);
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If this specific browser is updating, downloading, launching, or stopping, block it
if (isBrowserUpdating || isLaunching || isStopping) {
@@ -105,7 +105,7 @@ export function useBrowserState(
// For single-instance browsers (Tor and Mullvad)
if (isSingleInstanceBrowser(profile.browser)) {
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
);
// If no instances are running, any profile of this type can be used
@@ -142,10 +142,10 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
const isRunning = runningProfiles.has(profile.name);
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If profile is running, launching, stopping, or browser is updating, block selection
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
@@ -170,10 +170,10 @@ export function useBrowserState(
(profile: BrowserProfile): string => {
if (!isClient) return "Loading...";
const isRunning = runningProfiles.has(profile.name);
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
if (isLaunching) {
return "Launching browser...";
@@ -224,10 +224,10 @@ export function useBrowserState(
if (canUseForLinks) return null;
const isRunning = runningProfiles.has(profile.name);
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
if (isLaunching) {
return "Profile is currently launching. Please wait.";
@@ -245,7 +245,7 @@ export function useBrowserState(
const browserDisplayName =
profile.browser === "tor-browser" ? "TOR" : "Mullvad";
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
);
if (runningInstancesOfType.length > 0) {
+90
View File
@@ -0,0 +1,90 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { GroupWithCount } from "@/types";
/**
* Custom hook to manage group-related state and listen for backend events.
* This hook eliminates the need for manual UI refreshes by automatically
* updating state when the backend emits group change events.
*/
export function useGroupEvents() {
const [groups, setGroups] = useState<GroupWithCount[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load groups from backend
const loadGroups = useCallback(async () => {
try {
const groupsWithCounts = await invoke<GroupWithCount[]>(
"get_groups_with_profile_counts",
);
setGroups(groupsWithCounts);
setError(null);
} catch (err: unknown) {
console.error("Failed to load groups:", err);
setError(`Failed to load groups: ${JSON.stringify(err)}`);
}
}, []);
// Clear error state
const clearError = useCallback(() => {
setError(null);
}, []);
// Initial load and event listeners setup
useEffect(() => {
let groupsUnlisten: (() => void) | undefined;
const setupListeners = async () => {
try {
// Initial load
await loadGroups();
// Listen for group changes (create, delete, rename, update, etc.)
groupsUnlisten = await listen("groups-changed", () => {
console.log("Received groups-changed event, reloading groups");
void loadGroups();
});
// Also listen for profile changes since groups show profile counts
const profilesUnlisten = await listen("profiles-changed", () => {
console.log(
"Received profiles-changed event, reloading groups for updated counts",
);
void loadGroups();
});
// Store both listeners for cleanup
groupsUnlisten = () => {
groupsUnlisten?.();
profilesUnlisten();
};
console.log("Group event listeners set up successfully");
} catch (err) {
console.error("Failed to setup group event listeners:", err);
setError(
`Failed to setup group event listeners: ${JSON.stringify(err)}`,
);
} finally {
setIsLoading(false);
}
};
void setupListeners();
// Cleanup listeners on unmount
return () => {
if (groupsUnlisten) groupsUnlisten();
};
}, [loadGroups]);
return {
groups,
isLoading,
error,
loadGroups,
clearError,
};
}
+185
View File
@@ -0,0 +1,185 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { BrowserProfile, GroupWithCount } from "@/types";
interface UseProfileEventsReturn {
profiles: BrowserProfile[];
groups: GroupWithCount[];
runningProfiles: Set<string>;
isLoading: boolean;
error: string | null;
loadProfiles: () => Promise<void>;
loadGroups: () => Promise<void>;
clearError: () => void;
}
/**
* Custom hook to manage profile-related state and listen for backend events.
* This hook eliminates the need for manual UI refreshes by automatically
* updating state when the backend emits profile change events.
*/
export function useProfileEvents(): UseProfileEventsReturn {
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
const [groups, setGroups] = useState<GroupWithCount[]>([]);
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
new Set(),
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load profiles from backend
const loadProfiles = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
setProfiles(profileList);
setError(null);
} catch (err: unknown) {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, []);
// Load groups from backend
const loadGroups = useCallback(async () => {
try {
const groupsWithCounts = await invoke<GroupWithCount[]>(
"get_groups_with_profile_counts",
);
setGroups(groupsWithCounts);
setError(null);
} catch (err) {
console.error("Failed to load groups with counts:", err);
setGroups([]);
}
}, []);
// Clear error state
const clearError = useCallback(() => {
setError(null);
}, []);
// Initial load and event listeners setup
useEffect(() => {
let profilesUnlisten: (() => void) | undefined;
let runningUnlisten: (() => void) | undefined;
const setupListeners = async () => {
try {
// Initial load
await Promise.all([loadProfiles(), loadGroups()]);
// Listen for profile changes (create, delete, rename, update, etc.)
profilesUnlisten = await listen("profiles-changed", () => {
console.log(
"Received profiles-changed event, reloading profiles and groups",
);
void loadProfiles();
void loadGroups();
});
// Listen for profile running state changes
runningUnlisten = await listen<{ id: string; is_running: boolean }>(
"profile-running-changed",
(event) => {
const { id, is_running } = event.payload;
setRunningProfiles((prev) => {
const next = new Set(prev);
if (is_running) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
},
);
console.log("Profile event listeners set up successfully");
} catch (err) {
console.error("Failed to setup profile event listeners:", err);
setError(
`Failed to setup profile event listeners: ${JSON.stringify(err)}`,
);
} finally {
setIsLoading(false);
}
};
void setupListeners();
// Cleanup listeners on unmount
return () => {
if (profilesUnlisten) profilesUnlisten();
if (runningUnlisten) runningUnlisten();
};
}, [loadProfiles, loadGroups]);
// Sync profile running states periodically to ensure consistency
useEffect(() => {
const syncRunningStates = async () => {
if (profiles.length === 0) return;
try {
const statusChecks = profiles.map(async (profile) => {
try {
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
return { id: profile.id, isRunning };
} catch (error) {
console.error(
`Failed to check status for profile ${profile.name}:`,
error,
);
return { id: profile.id, isRunning: false };
}
});
const statuses = await Promise.all(statusChecks);
setRunningProfiles((prev) => {
const next = new Set(prev);
let hasChanges = false;
statuses.forEach(({ id, isRunning }) => {
if (isRunning && !prev.has(id)) {
next.add(id);
hasChanges = true;
} else if (!isRunning && prev.has(id)) {
next.delete(id);
hasChanges = true;
}
});
return hasChanges ? next : prev;
});
} catch (error) {
console.error("Failed to sync profile running states:", error);
}
};
// Initial sync
void syncRunningStates();
// Sync every 30 seconds to catch any missed events
const interval = setInterval(() => {
void syncRunningStates();
}, 30000);
return () => clearInterval(interval);
}, [profiles]);
return {
profiles,
groups,
runningProfiles,
isLoading,
error,
loadProfiles,
loadGroups,
clearError,
};
}
+112
View File
@@ -0,0 +1,112 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { StoredProxy } from "@/types";
/**
* Custom hook to manage proxy-related state and listen for backend events.
* This hook eliminates the need for manual UI refreshes by automatically
* updating state when the backend emits proxy change events.
*/
export function useProxyEvents() {
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load proxy usage (how many profiles are using each proxy)
const loadProxyUsage = useCallback(async () => {
try {
const profiles = await invoke<Array<{ proxy_id?: string }>>(
"list_browser_profiles",
);
const counts: Record<string, number> = {};
for (const p of profiles) {
if (p.proxy_id) counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
}
setProxyUsage(counts);
} catch (err) {
console.error("Failed to load proxy usage:", err);
// Don't set error for non-critical proxy usage
}
}, []);
// Load proxies from backend
const loadProxies = useCallback(async () => {
try {
const stored = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(stored);
await loadProxyUsage();
setError(null);
} catch (err: unknown) {
console.error("Failed to load proxies:", err);
setError(`Failed to load proxies: ${JSON.stringify(err)}`);
}
}, [loadProxyUsage]);
// Clear error state
const clearError = useCallback(() => {
setError(null);
}, []);
// Initial load and event listeners setup
useEffect(() => {
let proxiesUnlisten: (() => void) | undefined;
let profilesUnlisten: (() => void) | undefined;
let storedProxiesUnlisten: (() => void) | undefined;
const setupListeners = async () => {
try {
// Initial load
await loadProxies();
// Listen for proxy changes (create, delete, update, start, stop, etc.)
proxiesUnlisten = await listen("proxies-changed", () => {
console.log("Received proxies-changed event, reloading proxies");
void loadProxies();
});
// Listen for profile changes to update proxy usage counts
profilesUnlisten = await listen("profiles-changed", () => {
console.log("Received profiles-changed event, reloading proxy usage");
void loadProxyUsage();
});
// Listen for profile updates to update proxy usage counts
storedProxiesUnlisten = await listen("stored-proxies-changed", () => {
console.log(
"Received stored-proxies-changed event, reloading proxies",
);
void loadProxies();
});
console.log("Proxy event listeners set up successfully");
} catch (err) {
console.error("Failed to setup proxy event listeners:", err);
setError(
`Failed to setup proxy event listeners: ${JSON.stringify(err)}`,
);
} finally {
setIsLoading(false);
}
};
void setupListeners();
// Cleanup listeners on unmount
return () => {
if (proxiesUnlisten) proxiesUnlisten();
if (profilesUnlisten) profilesUnlisten();
if (storedProxiesUnlisten) storedProxiesUnlisten();
};
}, [loadProxies, loadProxyUsage]);
return {
storedProxies,
proxyUsage,
isLoading,
error,
loadProxies,
clearError,
};
}
+215
View File
@@ -0,0 +1,215 @@
export interface ThemeColors extends Record<string, string> {
"--background": string;
"--foreground": string;
"--card": string;
"--card-foreground": string;
"--popover": string;
"--popover-foreground": string;
"--primary": string;
"--primary-foreground": string;
"--secondary": string;
"--secondary-foreground": string;
"--muted": string;
"--muted-foreground": string;
"--accent": string;
"--accent-foreground": string;
"--destructive": string;
"--destructive-foreground": string;
"--border": string;
}
export interface Theme {
id: string;
name: string;
colors: ThemeColors;
}
export const THEMES: Theme[] = [
{
id: "tokyo-night",
name: "Tokyo Night",
colors: {
"--background": "#1a1b26",
"--foreground": "#c0caf5",
"--card": "#24283b",
"--card-foreground": "#c0caf5",
"--popover": "#24283b",
"--popover-foreground": "#c0caf5",
"--primary": "#7aa2f7",
"--primary-foreground": "#1a1b26",
"--secondary": "#2ac3de",
"--secondary-foreground": "#1a1b26",
"--muted": "#3b4261",
"--muted-foreground": "#a9b1d6",
"--accent": "#bb9af7",
"--accent-foreground": "#1a1b26",
"--destructive": "#f7768e",
"--destructive-foreground": "#1a1b26",
"--border": "#3b4261",
},
},
{
id: "dracula",
name: "Dracula",
colors: {
"--background": "#282a36",
"--foreground": "#f8f8f2",
"--card": "#44475a",
"--card-foreground": "#f8f8f2",
"--popover": "#44475a",
"--popover-foreground": "#f8f8f2",
"--primary": "#bd93f9",
"--primary-foreground": "#282a36",
"--secondary": "#8be9fd",
"--secondary-foreground": "#282a36",
"--muted": "#6272a4",
"--muted-foreground": "#f8f8f2",
"--accent": "#ff79c6",
"--accent-foreground": "#282a36",
"--destructive": "#ff5555",
"--destructive-foreground": "#f8f8f2",
"--border": "#6272a4",
},
},
{
id: "matchalk",
name: "Matchalk",
colors: {
"--background": "#273136",
"--foreground": "#D1DED3",
"--card": "#1c2427",
"--card-foreground": "#D1DED3",
"--popover": "#323e45",
"--popover-foreground": "#D1DED3",
"--primary": "#7eb08a",
"--primary-foreground": "#273136",
"--secondary": "#d2b48c",
"--secondary-foreground": "#273136",
"--muted": "#323e45",
"--muted-foreground": "#7ea4b0",
"--accent": "#d2b48c",
"--accent-foreground": "#273136",
"--destructive": "#ff819f",
"--destructive-foreground": "#273136",
"--border": "#304e37",
},
},
{
id: "houston",
name: "Houston",
colors: {
"--background": "#17191e",
"--foreground": "#f7f7f8",
"--card": "#21252e",
"--card-foreground": "#f7f7f8",
"--popover": "#21252e",
"--popover-foreground": "#f7f7f8",
"--primary": "#5755d9",
"--primary-foreground": "#f7f7f8",
"--secondary": "#f25f4c",
"--secondary-foreground": "#f7f7f8",
"--muted": "#2a2e39",
"--muted-foreground": "#9ca3af",
"--accent": "#0ea5e9",
"--accent-foreground": "#f7f7f8",
"--destructive": "#ef4444",
"--destructive-foreground": "#f7f7f8",
"--border": "#2a2e39",
},
},
{
id: "ayu-dark",
name: "Ayu Dark",
colors: {
"--background": "#0a0e14",
"--foreground": "#b3b1ad",
"--card": "#11151c",
"--card-foreground": "#b3b1ad",
"--popover": "#11151c",
"--popover-foreground": "#b3b1ad",
"--primary": "#39bae6",
"--primary-foreground": "#0a0e14",
"--secondary": "#ffb454",
"--secondary-foreground": "#0a0e14",
"--muted": "#1f2430",
"--muted-foreground": "#5c6773",
"--accent": "#d2a6ff",
"--accent-foreground": "#0a0e14",
"--destructive": "#f07178",
"--destructive-foreground": "#b3b1ad",
"--border": "#1f2430",
},
},
{
id: "ayu-light",
name: "Ayu Light",
colors: {
"--background": "#fafafa",
"--foreground": "#5c6773",
"--card": "#ffffff",
"--card-foreground": "#5c6773",
"--popover": "#ffffff",
"--popover-foreground": "#5c6773",
"--primary": "#399ee6",
"--primary-foreground": "#fafafa",
"--secondary": "#fa8d3e",
"--secondary-foreground": "#fafafa",
"--muted": "#f0f0f0",
"--muted-foreground": "#828c99",
"--accent": "#a37acc",
"--accent-foreground": "#fafafa",
"--destructive": "#f07178",
"--destructive-foreground": "#fafafa",
"--border": "#e7eaed",
},
},
];
export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> =
[
{ key: "--background", label: "Background" },
{ key: "--foreground", label: "Foreground" },
{ key: "--card", label: "Card" },
{ key: "--card-foreground", label: "Card FG" },
{ key: "--popover", label: "Popover" },
{ key: "--popover-foreground", label: "Popover FG" },
{ key: "--primary", label: "Primary" },
{ key: "--primary-foreground", label: "Primary FG" },
{ key: "--secondary", label: "Secondary" },
{ key: "--secondary-foreground", label: "Secondary FG" },
{ key: "--muted", label: "Muted" },
{ key: "--muted-foreground", label: "Muted FG" },
{ key: "--accent", label: "Accent" },
{ key: "--accent-foreground", label: "Accent FG" },
{ key: "--destructive", label: "Destructive" },
{ key: "--destructive-foreground", label: "Destructive FG" },
{ key: "--border", label: "Border" },
];
export function getThemeById(id: string): Theme | undefined {
return THEMES.find((theme) => theme.id === id);
}
export function getThemeByColors(
colors: Record<string, string>,
): Theme | undefined {
return THEMES.find((theme) => {
return THEME_VARIABLES.every(({ key }) => {
return theme.colors[key] === colors[key];
});
});
}
export function applyThemeColors(colors: Record<string, string>): void {
const root = document.documentElement;
Object.entries(colors).forEach(([key, value]) => {
root.style.setProperty(key, value, "important");
});
}
export function clearThemeColors(): void {
const root = document.documentElement;
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
}
+5 -5
View File
@@ -127,13 +127,13 @@
/* Ensure Sonner toasts appear above all dialogs and remain interactive */
.toaster,
[data-sonner-toaster] {
z-index: 99999 !important;
pointer-events: auto !important;
z-index: 99999;
pointer-events: auto;
}
[data-sonner-toast] {
z-index: 99999 !important;
pointer-events: auto !important;
z-index: 99999;
pointer-events: auto;
}
/* Ensure toast buttons and interactive elements work */
@@ -141,5 +141,5 @@
[data-sonner-toast] [role="button"],
[data-sonner-toast] input,
[data-sonner-toast] select {
pointer-events: auto !important;
pointer-events: auto;
}
+4
View File
@@ -22,6 +22,7 @@ export interface BrowserProfile {
release_type: string; // "stable" or "nightly"
camoufox_config?: CamoufoxConfig; // Camoufox configuration
group_id?: string; // Reference to profile group
tags?: string[];
}
export interface StoredProxy {
@@ -86,6 +87,9 @@ export interface CamoufoxConfig {
// Extended interface for the advanced fingerprint configuration
export interface CamoufoxFingerprintConfig {
// Browser behavior
allowAddonNewTab?: boolean;
// Navigator properties
"navigator.userAgent"?: string;
"navigator.appVersion"?: string;
+1 -1
View File
@@ -31,5 +31,5 @@
"next-env.d.ts",
"dist/types/**/*.ts"
],
"exclude": ["node_modules", "nodecar"]
"exclude": ["node_modules", "nodecar", "src-tauri/target"]
}