Compare commits

...

105 Commits

Author SHA1 Message Date
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
80 changed files with 11295 additions and 5168 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.
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
# build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
@@ -43,7 +43,7 @@ jobs:
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
+2 -2
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
with:
scan-args: |-
-r
@@ -73,7 +73,7 @@ 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@ad649157c69da4d34e601ee360de7a74ce4e2090 #v2.10.126
with:
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
MERGE_METHOD: SQUASH
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@2d4393e6bc0e2efb2e48fba7e06819c3bf61ffc9 # v2.0.0
- uses: actions/first-interaction@753c925c8d1ac6fede23781875376600628d9b5d # v3.0.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."
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.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: |
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
+6 -6
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,7 +41,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Set up pnpm package manager
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
@@ -53,7 +53,7 @@ jobs:
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 +65,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 +77,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 +94,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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
with:
scan-args: |-
-r
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.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: |
+9 -9
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@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
with:
scan-args: |-
-r
@@ -105,7 +105,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
@@ -116,7 +116,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Setup Rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
@@ -162,7 +162,7 @@ jobs:
run: pnpm build
- name: Build Tauri app
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
uses: tauri-apps/tauri-action@e834788a94591d81e3ae0bd9ec06366f5afb8994 #v0.5.23
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REF_NAME: ${{ github.ref_name }}
@@ -174,8 +174,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]"
+4 -4
View File
@@ -12,7 +12,7 @@ env:
jobs:
security-scan:
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
with:
scan-args: |-
-r
@@ -104,7 +104,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
@@ -115,7 +115,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Setup Rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
@@ -170,7 +170,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@e834788a94591d81e3ae0bd9ec06366f5afb8994 #v0.5.23
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Spell Check Repo
uses: crate-ci/typos@52bd719c2c91f9d676e2aa359fc8e0db8925e6d8 #v1.35.3
uses: crate-ci/typos@65f69f021b736bdbe548ce72200500752d42b40e #v1.35.7
+10
View File
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"ABORTIFHUNG",
"adwaita",
"ahooks",
"akhilmhdh",
@@ -20,6 +21,7 @@
"CFURL",
"checkin",
"chrono",
"ciphertext",
"CLICOLOR",
"clippy",
"cmdk",
@@ -31,6 +33,7 @@
"dataclasses",
"datareporting",
"datas",
"DBAPI",
"dconf",
"devedition",
"distro",
@@ -81,8 +84,10 @@
"libwebkit",
"libxdo",
"localtime",
"lpdw",
"lxml",
"lzma",
"Matchalk",
"mmdb",
"mountpoint",
"msiexec",
@@ -111,6 +116,8 @@
"peerconnection",
"pids",
"pixbuf",
"pkexec",
"pkill",
"plasmohq",
"platformdirs",
"prefs",
@@ -130,7 +137,9 @@
"SARIF",
"scipy",
"screeninfo",
"selectables",
"serde",
"SETTINGCHANGE",
"setuptools",
"shadcn",
"showcursor",
@@ -138,6 +147,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="./.next/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;
+3 -3
View File
@@ -21,14 +21,14 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.2.1",
"@types/node": "^24.3.0",
"commander": "^14.0.0",
"donutbrowser-camoufox-js": "^0.6.6",
"dotenv": "^17.2.1",
"fingerprint-generator": "^2.1.69",
"fingerprint-generator": "^2.1.70",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"playwright-core": "^1.54.2",
"playwright-core": "^1.55.0",
"proxy-chain": "^2.5.9",
"tmp": "^0.2.5",
"ts-node": "^10.9.2",
+31 -27
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.9.2",
"version": "0.12.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -21,34 +21,37 @@
"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-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-popover": "^1.1.15",
"@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-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.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.8.0",
"@tauri-apps/plugin-deep-link": "^2.4.2",
"@tauri-apps/plugin-dialog": "^2.3.3",
"@tauri-apps/plugin-fs": "~2.4.2",
"@tauri-apps/plugin-opener": "^2.5.0",
"ahooks": "^3.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.0",
"lucide-react": "^0.542.0",
"motion": "^12.23.12",
"next": "^15.4.6",
"next": "^15.5.2",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
@@ -57,18 +60,19 @@
"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.2",
"@tailwindcss/postcss": "^4.1.12",
"@tauri-apps/cli": "^2.8.3",
"@types/color": "^4.2.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
"tailwindcss": "^4.1.11",
"tailwindcss": "^4.1.12",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.3.6",
"tw-animate-css": "^1.3.7",
"typescript": "~5.9.2"
},
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
+1756 -1002
View File
File diff suppressed because it is too large Load Diff
+527 -274
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.9.2"
version = "0.12.2"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -29,10 +29,12 @@ 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"
@@ -47,6 +49,12 @@ msi-extract = "0"
uuid = { version = "1.0", 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"] }
@@ -71,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"
+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()
}
+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 -9
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?;
+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
}
-586
View File
@@ -1,586 +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 extracted binaries but preserve downloaded archives
if info.file_path.exists() {
if info.file_path.is_dir() {
// Allowed archive extensions to preserve
let archive_exts = [
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
];
for entry in fs::read_dir(&info.file_path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
fs::remove_dir_all(&path)?;
continue;
}
// For files, preserve if they look like downloaded archives/installers
let keep = path
.file_name()
.and_then(|n| n.to_str())
.map(|name| {
// Match suffixes (handles multi-part extensions like .tar.xz)
archive_exts
.iter()
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
})
.unwrap_or(false);
if !keep {
fs::remove_file(&path)?;
}
}
} else {
// It's a file. If it's not an archive, remove it; otherwise preserve it.
let file_name = info
.file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let archive_exts = [
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
];
let is_archive = archive_exts
.iter()
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
if !is_archive {
fs::remove_file(&info.file_path)?;
}
}
}
}
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;
}
// Only add to registry if this looks like a valid installed browser, not just an archive
if !self.is_browser_downloaded(browser_name, version_name) {
if let Ok(browser_type) = crate::browser::BrowserType::from_str(browser_name) {
let browser = crate::browser::create_browser(browser_type);
if browser.is_version_downloaded(version_name, binaries_dir) {
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();
}
+53 -273
View File
@@ -4,6 +4,7 @@ use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Emitter;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileGroup {
@@ -97,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
@@ -113,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>> {
@@ -142,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();
@@ -156,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(())
}
@@ -203,267 +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());
// Use per-test isolated data directory without relying on global env vars
let data_override = temp_dir.path().join("donutbrowser_test_data");
let manager = GroupManager::with_data_dir_override(&data_override);
(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 (group2 has 0 profiles but should still appear)
assert_eq!(
groups_with_counts.len(),
3,
"Should include all groups, even those with 0 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");
// Check that group2 exists with 0 profiles
let group2_with_count = groups_with_counts
.iter()
.find(|g| g.name == "Group 2")
.expect("Should have group2 present even with 0 profiles");
assert_eq!(group2_with_count.count, 0, "Group2 should have 0 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();
@@ -491,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}"))
}
+179 -33
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")]
@@ -175,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}"))
}
@@ -190,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}"))
}
@@ -378,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");
@@ -416,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) => {
@@ -439,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();
@@ -489,8 +505,135 @@ pub fn run() {
}
});
// Periodically broadcast browser running status to the frontend
let app_handle_status = app.handle().clone();
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500));
let mut last_running_states: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
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) => {
eprintln!("Failed to load app settings for API startup: {e}");
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -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,
@@ -550,6 +693,9 @@ pub fn run() {
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");
+225 -68
View File
@@ -214,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,
@@ -455,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")]
@@ -727,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(())
}
+443 -195
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;
@@ -748,24 +840,42 @@ impl ProfileManager {
let metadata_exists = metadata_file.exists();
if metadata_exists {
// Update the process ID if we found a different one
// 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 inner_profile.process_id != Some(pid) {
inner_profile.process_id = Some(pid);
if let Err(e) = self.save_profile(&inner_profile) {
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 inner_profile.process_id.is_some() {
} else if merged.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) {
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;
}
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &inner_profile) {
if let Err(e) = app_handle.emit("profile-updated", &merged) {
println!("Warning: Failed to emit profile update event: {e}");
}
}
@@ -779,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();
@@ -790,49 +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;
// Only write status changes if metadata still exists
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 process_id_changed && metadata_exists {
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}");
}
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(),
};
// 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 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}");
}
println!(
"Camoufox process has started for profile '{}' with PID: {:?}",
profile.name, camoufox_process.processId
);
// 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
// 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();
if profile.process_id.is_some() && metadata_exists {
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}");
}
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(),
};
// 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 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)
@@ -845,16 +975,30 @@ impl ProfileManager {
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
if profile.process_id.is_some() && metadata_exists {
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}");
}
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(),
};
// 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 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)
@@ -889,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
@@ -1079,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();
@@ -1201,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());
+61 -30
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;
@@ -146,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> {
@@ -168,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
@@ -182,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>,
@@ -226,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() {
@@ -242,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(())
}
@@ -350,29 +375,6 @@ impl ProxyManager {
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
}
// 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
};
// Start a new proxy using the nodecar binary with the correct CLI interface
let mut nodecar = app_handle
.shell()
@@ -400,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
@@ -449,6 +446,30 @@ impl ProxyManager {
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();
@@ -518,6 +539,11 @@ impl ProxyManager {
}
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
eprintln!("Failed to emit proxies-changed event: {e}");
}
Ok(())
}
@@ -558,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)
}
}
+258 -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,249 @@ 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 = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
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 = &file_data[offset..offset + 12];
let nonce = Nonce::from_slice(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 = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
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 +439,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 +519,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!(
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.9.2",
"version": "0.12.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+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 />
+202 -267
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";
@@ -18,11 +18,15 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-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"
@@ -40,9 +44,26 @@ interface PendingUrl {
}
export default function Home() {
// Mount global version update listener/toasts
useVersionUpdater();
const [isInitializing, setIsInitializing] = useState(true);
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
const [error, setError] = useState<string | null>(null);
// 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);
@@ -59,13 +80,12 @@ export default function Home() {
string[]
>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState<string>("");
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
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] =
@@ -142,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) {
@@ -154,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(
@@ -203,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
@@ -269,9 +251,8 @@ export default function Home() {
await invoke("warm_up_nodecar");
} catch (err) {
if (!cancelled) {
setError(
`Initialization failed: ${err instanceof Error ? err.message : String(err)}`,
);
// Don't set error here since useProfileEvents handles profile errors
console.error("Initialization failed:", err);
}
} finally {
if (!cancelled) setIsInitializing(false);
@@ -283,6 +264,27 @@ export default function Home() {
};
}, []);
// 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
@@ -339,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);
@@ -375,38 +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 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;
@@ -417,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)
}`,
@@ -447,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);
}, []);
@@ -621,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;
@@ -642,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();
@@ -681,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) {
@@ -688,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)
@@ -729,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) {
@@ -761,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:var(--font-geist-sans)] bg-background">
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
<div className="w-full">
<HomeHeader
@@ -774,17 +706,19 @@ 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}
onDeleteProfile={handleDeleteProfile}
@@ -802,13 +736,13 @@ export default function Home() {
</main>
{isInitializing && (
<div className="fixed inset-0 z-[100000] backdrop-blur-sm bg-black/30 flex items-center justify-center">
<div className="bg-white dark:bg-neutral-900 rounded-xl p-6 shadow-xl border border-black/10 dark:border-white/10 w-[320px] text-center">
<div className="fixed inset-0 z-[1000] backdrop-blur-sm bg-background/30 flex items-center justify-center">
<div className="bg-background rounded-xl p-6 shadow-xl border border-border/10 w-[320px] text-center">
<div className="text-lg font-medium">Initializing</div>
<div className="mt-1 mb-2 text-sm text-gray-600 dark:text-gray-300">
Please don't close the app
</div>
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 border-gray-300 animate-spin border-t-gray-900 dark:border-gray-700 dark:border-t-white" />
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
</div>
</div>
)}
@@ -834,7 +768,6 @@ export default function Home() {
onClose={() => {
setImportProfileDialogOpen(false);
}}
onImportComplete={() => void loadProfiles()}
/>
<ProxyManagementDialog
@@ -892,6 +825,7 @@ export default function Home() {
}}
selectedProfiles={selectedProfilesForGroup}
onAssignmentComplete={handleGroupAssignmentComplete}
profiles={profiles}
/>
<DeleteConfirmationDialog
@@ -902,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>
);
+1 -1
View File
@@ -107,7 +107,7 @@ export function CamoufoxConfigDialog({
</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>
);
+1 -1
View File
@@ -168,7 +168,7 @@ export function UnifiedToast(props: ToastProps) {
const progress = "progress" in props ? props.progress : undefined;
return (
<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="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-semibold leading-tight text-foreground">
+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>
+29 -4
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,6 +37,8 @@ const HomeHeader = ({
onGroupManagementDialogOpen,
onImportProfileDialogOpen,
onCreateProfileDialogOpen,
searchQuery,
onSearchQueryChange,
}: Props) => {
const handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
@@ -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}
/>
</>
);
}
+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
+131 -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>
@@ -817,14 +817,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 +852,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 +869,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 +886,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 +903,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 +915,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;
};
+1 -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}
+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}
+1 -1
View File
@@ -41,7 +41,7 @@ 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"
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) => {
+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;
}
+1
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 {
+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"]
}