Compare commits

..

49 Commits

Author SHA1 Message Date
zhom d05ab23404 test: remove https tests 2026-03-16 18:21:01 +04:00
zhom 8511535d69 refactor: socks5 chaining 2026-03-16 17:48:02 +04:00
zhom 29dd5abb34 chore: exclude nightly tag 2026-03-16 15:55:29 +04:00
zhom b2d1456aa9 chore: version bump 2026-03-16 15:50:06 +04:00
zhom e3fc715cfa chore: cp instead of sync 2026-03-16 15:49:25 +04:00
zhom 2cf9013d28 chore: handle download interuptions 2026-03-16 15:48:52 +04:00
zhom 76dd0d84e8 refactor: check proxy validity via donut-proxy 2026-03-16 15:48:00 +04:00
zhom ccecd2a1e3 chore: version bump 2026-03-16 04:44:27 +04:00
zhom 238f7648cf chore: remove ref 2026-03-16 03:34:19 +04:00
zhom c4aee3a00b refactor: encrypt manifest for encrypted profiles 2026-03-16 03:33:44 +04:00
zhom 140e611085 test: e2e for encrypted sync 2026-03-16 02:57:31 +04:00
zhom b4488ee3ec refactor: make bypass of paid plan harder 2026-03-16 02:57:08 +04:00
zhom c4bfd4e253 chore: linting 2026-03-15 20:31:02 +04:00
zhom 0b3dac5da8 chore: icons 2026-03-15 20:06:40 +04:00
zhom db4c1fce6c Merge pull request #236 from zhom/dependabot/cargo/src-tauri/rust-dependencies-f0e0da4c3a
deps(rust)(deps): bump the rust-dependencies group across 1 directory with 13 updates
2026-03-15 12:01:28 -04:00
zhom d2d459feeb fix: better scroll handling 2026-03-15 19:58:51 +04:00
zhom 7648785e39 test: run ephemeral dir test serially 2026-03-15 19:00:15 +04:00
dependabot[bot] 081a1922df deps(rust)(deps): bump the rust-dependencies group across 1 directory with 13 updates
Bumps the rust-dependencies group with 9 updates in the /src-tauri directory:

| Package | From | To |
| --- | --- | --- |
| [zip](https://github.com/zip-rs/zip2) | `7.2.0` | `8.2.0` |
| [rand](https://github.com/rust-random/rand) | `0.9.2` | `0.10.0` |
| [rusqlite](https://github.com/rusqlite/rusqlite) | `0.38.0` | `0.39.0` |
| [smoltcp](https://github.com/smoltcp-rs/smoltcp) | `0.11.0` | `0.12.0` |
| [winreg](https://github.com/gentoo90/winreg-rs) | `0.55.0` | `0.56.0` |
| [resvg](https://github.com/linebender/resvg) | `0.46.0` | `0.47.0` |
| [portable-atomic-util](https://github.com/taiki-e/portable-atomic-util) | `0.2.5` | `0.2.6` |
| [tinyvec](https://github.com/Lokathor/tinyvec) | `1.10.0` | `1.11.0` |
| [uds_windows](https://github.com/haraldh/rust_uds_windows) | `1.2.0` | `1.2.1` |



Updates `zip` from 7.2.0 to 8.2.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/v7.2.0...v8.2.0)

Updates `rand` from 0.9.2 to 0.10.0
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.2...0.10.0)

Updates `rusqlite` from 0.38.0 to 0.39.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.38.0...v0.39.0)

Updates `smoltcp` from 0.11.0 to 0.12.0
- [Release notes](https://github.com/smoltcp-rs/smoltcp/releases)
- [Changelog](https://github.com/smoltcp-rs/smoltcp/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smoltcp-rs/smoltcp/compare/v0.11.0...v0.12.0)

Updates `winreg` from 0.55.0 to 0.56.0
- [Release notes](https://github.com/gentoo90/winreg-rs/releases)
- [Changelog](https://github.com/gentoo90/winreg-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gentoo90/winreg-rs/compare/v0.55.0...v0.56.0)

Updates `resvg` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.46.0...v0.47.0)

Updates `libsqlite3-sys` from 0.36.0 to 0.37.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.36.0...v0.37.0)

Updates `portable-atomic-util` from 0.2.5 to 0.2.6
- [Release notes](https://github.com/taiki-e/portable-atomic-util/releases)
- [Changelog](https://github.com/taiki-e/portable-atomic-util/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/portable-atomic-util/compare/v0.2.5...v0.2.6)

Updates `tiny-skia` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `tiny-skia-path` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `tinyvec` from 1.10.0 to 1.11.0
- [Changelog](https://github.com/Lokathor/tinyvec/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Lokathor/tinyvec/compare/v1.10.0...v1.11.0)

Updates `uds_windows` from 1.2.0 to 1.2.1
- [Release notes](https://github.com/haraldh/rust_uds_windows/releases)
- [Changelog](https://github.com/haraldh/rust_uds_windows/blob/master/CHANGELOG.md)
- [Commits](https://github.com/haraldh/rust_uds_windows/compare/v1.2.0...v1.2.1)

Updates `usvg` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.46.0...v0.47.0)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: rand
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rusqlite
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: smoltcp
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: winreg
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: resvg
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libsqlite3-sys
  dependency-version: 0.37.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: portable-atomic-util
  dependency-version: 0.2.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tiny-skia
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tiny-skia-path
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tinyvec
  dependency-version: 1.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: uds_windows
  dependency-version: 1.2.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: usvg
  dependency-version: 0.47.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-15 14:38:49 +00:00
zhom 55b8b61f42 fix: run opencode on all issues and prs 2026-03-15 18:00:24 +04:00
zhom 5bea6a32e0 feat: synchronizer 2026-03-15 18:00:04 +04:00
zhom e72874142b Merge pull request #233 from zhom/dependabot/github_actions/github-actions-d7a59ebd9d
ci(deps): bump the github-actions group with 3 updates
2026-03-14 05:05:36 -04:00
dependabot[bot] 6b5b177482 ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [swatinem/rust-cache](https://github.com/swatinem/rust-cache).


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

Updates `anomalyco/opencode` from 1.2.20 to 1.2.26
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/6c7d968c4423a0cd6c85099c9377a6066313fa0a...d954026dd855e018302a6c0733a1dd74140931df)

Updates `swatinem/rust-cache` from 2.8.2 to 2.9.1
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/779680da715d629ac1d338a641029a2f4372abb5...c19371144df3bb44fab255c43d04cbc2ab54d1c4)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.2.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-14 09:04:26 +00:00
zhom cdaacc5b27 refactor: support non-latin characters 2026-03-14 12:47:15 +04:00
zhom f5e068346c chore: formatting 2026-03-14 12:47:02 +04:00
zhom 07ac2b7ff8 chore: linting 2026-03-14 12:46:34 +04:00
zhom ee7160bb9e chore: update dependencies 2026-03-14 12:36:43 +04:00
zhom d0ea3f8903 refactor: match API spec in MCP 2026-03-14 12:31:34 +04:00
zhom 942d193206 feat: human-like typing for MCP 2026-03-14 12:12:14 +04:00
zhom 90563ea6f5 refactor: allow use without external sleep 2026-03-14 11:29:13 +04:00
zhom 6a88887a6c docs: agents 2026-03-14 08:51:00 +04:00
zhom 0553f76f71 chore: linting 2026-03-13 12:57:01 +04:00
zhom 95e5dbb84a chore: use env for aws instead of configure 2026-03-13 10:20:08 +04:00
zhom e9b5442340 refactor: cleanup 2026-03-13 10:19:34 +04:00
zhom 756bd69a84 chore: version bump 2026-03-10 03:24:45 +04:00
zhom 21a6185344 refactor: normalize invalid locale string 2026-03-10 02:19:32 +04:00
zhom b3d279046b fix: properly match proxy timezone 2026-03-10 01:59:58 +04:00
zhom f4eecf24cc fix: browser update on close 2026-03-09 20:34:12 +04:00
zhom cf79f2b172 fix: wayfern auto-updates 2026-03-09 17:46:00 +04:00
zhom 3669d63ddf chore: linting 2026-03-09 15:09:25 +04:00
zhom 478553a4a8 refactor: cleanup proxy process management on windows 2026-03-09 15:08:51 +04:00
zhom 3d1471d41d chore: cleanup triage bot 2026-03-09 14:42:45 +04:00
zhom 12bc4ed08f Merge pull request #230 from zhom/dependabot/npm_and_yarn/frontend-dependencies-083e094fc6
deps(deps): bump the frontend-dependencies group with 119 updates
2026-03-09 06:37:09 -04:00
zhom 48ba93cf9a chore: remove homebrew version bump and fix r2 url 2026-03-09 14:24:18 +04:00
zhom 43ee6856f9 refactor: cleanup 2026-03-09 14:24:18 +04:00
dependabot[bot] 56034a99d6 deps(deps): bump the frontend-dependencies group with 119 updates
Bumps the frontend-dependencies group with 119 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `25.8.13` | `25.8.14` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.576.0` | `0.577.0` |
| [motion](https://github.com/motiondivision/motion) | `12.34.3` | `12.35.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `16.5.4` | `16.5.6` |
| [react-icons](https://github.com/react-icons/react-icons) | `5.5.0` | `5.6.0` |
| [recharts](https://github.com/recharts/recharts) | `3.7.0` | `3.8.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@tauri-apps/cli](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.3` | `25.3.5` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `16.3.1` | `16.3.2` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1000.0` | `3.1004.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1000.0` | `3.1004.0` |
| [@nestjs/common](https://github.com/nestjs/nest/tree/HEAD/packages/common) | `11.1.14` | `11.1.16` |
| [@nestjs/core](https://github.com/nestjs/nest/tree/HEAD/packages/core) | `11.1.14` | `11.1.16` |
| [@nestjs/platform-express](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express) | `11.1.14` | `11.1.16` |
| [@nestjs/testing](https://github.com/nestjs/nest/tree/HEAD/packages/testing) | `11.1.14` | `11.1.16` |
| [@aws-sdk/core](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/core) | `3.973.15` | `3.973.18` |
| [@aws-sdk/crc64-nvme](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/crc64-nvme) | `3.972.3` | `3.972.4` |
| [@aws-sdk/credential-provider-env](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-env) | `3.972.13` | `3.972.16` |
| [@aws-sdk/credential-provider-http](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-http) | `3.972.15` | `3.972.18` |
| [@aws-sdk/credential-provider-ini](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-ini) | `3.972.13` | `3.972.17` |
| [@aws-sdk/credential-provider-login](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-login) | `3.972.13` | `3.972.17` |
| [@aws-sdk/credential-provider-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-node) | `3.972.14` | `3.972.18` |
| [@aws-sdk/credential-provider-process](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-process) | `3.972.13` | `3.972.16` |
| [@aws-sdk/credential-provider-sso](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-sso) | `3.972.13` | `3.972.17` |
| [@aws-sdk/credential-provider-web-identity](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-web-identity) | `3.972.13` | `3.972.17` |
| [@aws-sdk/middleware-bucket-endpoint](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-bucket-endpoint) | `3.972.6` | `3.972.7` |
| [@aws-sdk/middleware-expect-continue](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-expect-continue) | `3.972.6` | `3.972.7` |
| [@aws-sdk/middleware-flexible-checksums](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-flexible-checksums) | `3.973.1` | `3.973.4` |
| [@aws-sdk/middleware-host-header](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-host-header) | `3.972.6` | `3.972.7` |
| [@aws-sdk/middleware-location-constraint](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-location-constraint) | `3.972.6` | `3.972.7` |
| [@aws-sdk/middleware-logger](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-logger) | `3.972.6` | `3.972.7` |
| [@aws-sdk/middleware-recursion-detection](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-recursion-detection) | `3.972.6` | `3.972.7` |
| [@aws-sdk/middleware-sdk-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-sdk-s3) | `3.972.15` | `3.972.18` |
| [@aws-sdk/middleware-ssec](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-ssec) | `3.972.6` | `3.972.7` |
| [@aws-sdk/middleware-user-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-user-agent) | `3.972.15` | `3.972.19` |
| [@aws-sdk/nested-clients](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/nested-clients) | `3.996.3` | `3.996.7` |
| [@aws-sdk/region-config-resolver](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/region-config-resolver) | `3.972.6` | `3.972.7` |
| [@aws-sdk/signature-v4-multi-region](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-multi-region) | `3.996.3` | `3.996.6` |
| [@aws-sdk/token-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/token-providers) | `3.999.0` | `3.1004.0` |
| [@aws-sdk/types](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/types) | `3.973.4` | `3.973.5` |
| [@aws-sdk/util-arn-parser](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-arn-parser) | `3.972.2` | `3.972.3` |
| [@aws-sdk/util-endpoints](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/util-endpoints) | `3.996.3` | `3.996.4` |
| [@aws-sdk/util-format-url](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-format-url) | `3.972.6` | `3.972.7` |
| [@aws-sdk/util-locate-window](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-locate-window) | `3.965.4` | `3.965.5` |
| [@aws-sdk/util-user-agent-browser](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-user-agent-browser) | `3.972.6` | `3.972.7` |
| [@aws-sdk/util-user-agent-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-user-agent-node) | `3.973.0` | `3.973.4` |
| [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder) | `3.972.8` | `3.972.10` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.4` | `2.4.6` |
| [@smithy/abort-controller](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/abort-controller) | `4.2.10` | `4.2.11` |
| [@smithy/chunked-blob-reader-native](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/chunked-blob-reader-native) | `4.2.2` | `4.2.3` |
| [@smithy/chunked-blob-reader](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/chunked-blob-reader) | `5.2.1` | `5.2.2` |
| [@smithy/config-resolver](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/config-resolver) | `4.4.9` | `4.4.10` |
| [@smithy/core](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/core) | `3.23.6` | `3.23.9` |
| [@smithy/credential-provider-imds](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/credential-provider-imds) | `4.2.10` | `4.2.11` |
| [@smithy/eventstream-codec](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/eventstream-codec) | `4.2.10` | `4.2.11` |
| [@smithy/eventstream-serde-browser](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/eventstream-serde-browser) | `4.2.10` | `4.2.11` |
| [@smithy/eventstream-serde-config-resolver](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/eventstream-serde-config-resolver) | `4.3.10` | `4.3.11` |
| [@smithy/eventstream-serde-node](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/eventstream-serde-node) | `4.2.10` | `4.2.11` |
| [@smithy/eventstream-serde-universal](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/eventstream-serde-universal) | `4.2.10` | `4.2.11` |
| [@smithy/fetch-http-handler](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/fetch-http-handler) | `5.3.11` | `5.3.13` |
| [@smithy/hash-blob-browser](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/hash-blob-browser) | `4.2.11` | `4.2.12` |
| [@smithy/hash-node](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/hash-node) | `4.2.10` | `4.2.11` |
| [@smithy/hash-stream-node](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/hash-stream-node) | `4.2.10` | `4.2.11` |
| [@smithy/invalid-dependency](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/invalid-dependency) | `4.2.10` | `4.2.11` |
| [@smithy/md5-js](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/md5-js) | `4.2.10` | `4.2.11` |
| [@smithy/middleware-content-length](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-content-length) | `4.2.10` | `4.2.11` |
| [@smithy/middleware-endpoint](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-endpoint) | `4.4.20` | `4.4.23` |
| [@smithy/middleware-retry](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-retry) | `4.4.37` | `4.4.40` |
| [@smithy/middleware-serde](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-serde) | `4.2.11` | `4.2.12` |
| [@smithy/middleware-stack](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-stack) | `4.2.10` | `4.2.11` |
| [@smithy/node-config-provider](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/node-config-provider) | `4.3.10` | `4.3.11` |
| [@smithy/node-http-handler](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/node-http-handler) | `4.4.12` | `4.4.14` |
| [@smithy/property-provider](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/property-provider) | `4.2.10` | `4.2.11` |
| [@smithy/protocol-http](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/protocol-http) | `5.3.10` | `5.3.11` |
| [@smithy/querystring-builder](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/querystring-builder) | `4.2.10` | `4.2.11` |
| [@smithy/querystring-parser](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/querystring-parser) | `4.2.10` | `4.2.11` |
| [@smithy/service-error-classification](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/service-error-classification) | `4.2.10` | `4.2.11` |
| [@smithy/shared-ini-file-loader](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/shared-ini-file-loader) | `4.4.5` | `4.4.6` |
| [@smithy/signature-v4](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/signature-v4) | `5.3.10` | `5.3.11` |
| [@smithy/smithy-client](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/smithy-client) | `4.12.0` | `4.12.3` |
| [@smithy/url-parser](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/url-parser) | `4.2.10` | `4.2.11` |
| [@smithy/util-base64](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-base64) | `4.3.1` | `4.3.2` |
| [@smithy/util-body-length-browser](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-body-length-browser) | `4.2.1` | `4.2.2` |
| [@smithy/util-body-length-node](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-body-length-node) | `4.2.2` | `4.2.3` |
| [@smithy/util-config-provider](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-config-provider) | `4.2.1` | `4.2.2` |
| [@smithy/util-defaults-mode-browser](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-defaults-mode-node) | `4.3.36` | `4.3.39` |
| [@smithy/util-defaults-mode-node](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-defaults-mode-node) | `4.2.39` | `4.2.42` |
| [@smithy/util-endpoints](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-endpoints) | `3.3.1` | `3.3.2` |
| [@smithy/util-hex-encoding](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-hex-encoding) | `4.2.1` | `4.2.2` |
| [@smithy/util-middleware](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-middleware) | `4.2.10` | `4.2.11` |
| [@smithy/util-retry](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-retry) | `4.2.10` | `4.2.11` |
| [@smithy/util-stream](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-stream) | `4.5.15` | `4.5.17` |
| [@smithy/util-uri-escape](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-uri-escape) | `4.2.1` | `4.2.2` |
| [@smithy/util-waiter](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-waiter) | `4.2.10` | `4.2.11` |
| [@smithy/uuid](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/uuid) | `1.1.1` | `1.1.2` |
| [@tauri-apps/cli-darwin-arm64](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-darwin-x64](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-linux-arm-gnueabihf](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-linux-arm64-gnu](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-linux-arm64-musl](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-linux-riscv64-gnu](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-linux-x64-gnu](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-linux-x64-musl](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-win32-arm64-msvc](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-win32-ia32-msvc](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [@tauri-apps/cli-win32-x64-msvc](https://github.com/tauri-apps/tauri) | `2.10.0` | `2.10.1` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.44.0` | `1.45.1` |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | `5.3.6` | `5.4.1` |
| [framer-motion](https://github.com/motiondivision/motion) | `12.34.3` | `12.35.0` |
| [motion-dom](https://github.com/motiondivision/motion) | `12.34.3` | `12.35.0` |
| [multer](https://github.com/expressjs/multer) | `2.0.2` | `2.1.1` |


Updates `i18next` from 25.8.13 to 25.8.14
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.8.13...v25.8.14)

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

Updates `motion` from 12.34.3 to 12.35.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.34.3...v12.35.0)

Updates `react-i18next` from 16.5.4 to 16.5.6
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v16.5.4...v16.5.6)

Updates `react-icons` from 5.5.0 to 5.6.0
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.5.0...v5.6.0)

Updates `recharts` from 3.7.0 to 3.8.0
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.7.0...v3.8.0)

Updates `@biomejs/biome` from 2.4.4 to 2.4.6
- [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.4.6/packages/@biomejs/biome)

Updates `@tauri-apps/cli` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v2.10.0...@tauri-apps/cli-v2.10.1)

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

Updates `lint-staged` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.3.1...v16.3.2)

Updates `@aws-sdk/client-s3` from 3.1000.0 to 3.1004.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1004.0/clients/client-s3)

Updates `@aws-sdk/s3-request-presigner` from 3.1000.0 to 3.1004.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1004.0/packages/s3-request-presigner)

Updates `@nestjs/common` from 11.1.14 to 11.1.16
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.16/packages/common)

Updates `@nestjs/core` from 11.1.14 to 11.1.16
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.16/packages/core)

Updates `@nestjs/platform-express` from 11.1.14 to 11.1.16
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.16/packages/platform-express)

Updates `@nestjs/testing` from 11.1.14 to 11.1.16
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.16/packages/testing)

Updates `@aws-sdk/core` from 3.973.15 to 3.973.18
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/core/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/core)

Updates `@aws-sdk/crc64-nvme` from 3.972.3 to 3.972.4
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages/crc64-nvme)

Updates `@aws-sdk/credential-provider-env` from 3.972.13 to 3.972.16
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-env/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-env)

Updates `@aws-sdk/credential-provider-http` from 3.972.15 to 3.972.18
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-http/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-http)

Updates `@aws-sdk/credential-provider-ini` from 3.972.13 to 3.972.17
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-ini/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-ini)

Updates `@aws-sdk/credential-provider-login` from 3.972.13 to 3.972.17
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-login/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-login)

Updates `@aws-sdk/credential-provider-node` from 3.972.14 to 3.972.18
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-node/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-node)

Updates `@aws-sdk/credential-provider-process` from 3.972.13 to 3.972.16
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-process/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-process)

Updates `@aws-sdk/credential-provider-sso` from 3.972.13 to 3.972.17
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-sso/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-sso)

Updates `@aws-sdk/credential-provider-web-identity` from 3.972.13 to 3.972.17
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-web-identity/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-web-identity)

Updates `@aws-sdk/middleware-bucket-endpoint` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-bucket-endpoint/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-bucket-endpoint)

Updates `@aws-sdk/middleware-expect-continue` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-expect-continue/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-expect-continue)

Updates `@aws-sdk/middleware-flexible-checksums` from 3.973.1 to 3.973.4
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-flexible-checksums/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-flexible-checksums)

Updates `@aws-sdk/middleware-host-header` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-host-header/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-host-header)

Updates `@aws-sdk/middleware-location-constraint` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-location-constraint/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-location-constraint)

Updates `@aws-sdk/middleware-logger` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-logger/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-logger)

Updates `@aws-sdk/middleware-recursion-detection` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-recursion-detection/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-recursion-detection)

Updates `@aws-sdk/middleware-sdk-s3` from 3.972.15 to 3.972.18
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-sdk-s3)

Updates `@aws-sdk/middleware-ssec` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-ssec/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-ssec)

Updates `@aws-sdk/middleware-user-agent` from 3.972.15 to 3.972.19
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-user-agent/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-user-agent)

Updates `@aws-sdk/nested-clients` from 3.996.3 to 3.996.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages/nested-clients)

Updates `@aws-sdk/region-config-resolver` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/region-config-resolver/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/region-config-resolver)

Updates `@aws-sdk/signature-v4-multi-region` from 3.996.3 to 3.996.6
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages/signature-v4-multi-region)

Updates `@aws-sdk/token-providers` from 3.999.0 to 3.1004.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/token-providers/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1004.0/packages/token-providers)

Updates `@aws-sdk/types` from 3.973.4 to 3.973.5
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/types/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/types)

Updates `@aws-sdk/util-arn-parser` from 3.972.2 to 3.972.3
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/util-arn-parser/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/util-arn-parser)

Updates `@aws-sdk/util-endpoints` from 3.996.3 to 3.996.4
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages/util-endpoints)

Updates `@aws-sdk/util-format-url` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/util-format-url/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/util-format-url)

Updates `@aws-sdk/util-locate-window` from 3.965.4 to 3.965.5
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/util-locate-window/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/util-locate-window)

Updates `@aws-sdk/util-user-agent-browser` from 3.972.6 to 3.972.7
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/util-user-agent-browser/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/util-user-agent-browser)

Updates `@aws-sdk/util-user-agent-node` from 3.973.0 to 3.973.4
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/util-user-agent-node/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/util-user-agent-node)

Updates `@aws-sdk/xml-builder` from 3.972.8 to 3.972.10
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder)

Updates `@biomejs/cli-darwin-arm64` from 2.4.4 to 2.4.6
- [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.4.6/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.4.4 to 2.4.6
- [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.4.6/packages/@biomejs/biome)

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

Updates `@biomejs/cli-linux-arm64` from 2.4.4 to 2.4.6
- [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.4.6/packages/@biomejs/biome)

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

Updates `@biomejs/cli-linux-x64` from 2.4.4 to 2.4.6
- [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.4.6/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.4.4 to 2.4.6
- [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.4.6/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.4.4 to 2.4.6
- [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.4.6/packages/@biomejs/biome)

Updates `@smithy/abort-controller` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/abort-controller/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/abort-controller@4.2.11/packages/abort-controller)

Updates `@smithy/chunked-blob-reader-native` from 4.2.2 to 4.2.3
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/chunked-blob-reader-native/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/chunked-blob-reader-native@4.2.3/packages/chunked-blob-reader-native)

Updates `@smithy/chunked-blob-reader` from 5.2.1 to 5.2.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/chunked-blob-reader/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/chunked-blob-reader@5.2.2/packages/chunked-blob-reader)

Updates `@smithy/config-resolver` from 4.4.9 to 4.4.10
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/config-resolver/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/config-resolver@4.4.10/packages/config-resolver)

Updates `@smithy/core` from 3.23.6 to 3.23.9
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/core/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/core@3.23.9/packages/core)

Updates `@smithy/credential-provider-imds` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/credential-provider-imds/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/credential-provider-imds@4.2.11/packages/credential-provider-imds)

Updates `@smithy/eventstream-codec` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/eventstream-codec/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/eventstream-codec@4.2.11/packages/eventstream-codec)

Updates `@smithy/eventstream-serde-browser` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/eventstream-serde-browser/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/eventstream-serde-browser@4.2.11/packages/eventstream-serde-browser)

Updates `@smithy/eventstream-serde-config-resolver` from 4.3.10 to 4.3.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/eventstream-serde-config-resolver/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/eventstream-serde-config-resolver@4.3.11/packages/eventstream-serde-config-resolver)

Updates `@smithy/eventstream-serde-node` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/eventstream-serde-node/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/eventstream-serde-node@4.2.11/packages/eventstream-serde-node)

Updates `@smithy/eventstream-serde-universal` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/eventstream-serde-universal/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/eventstream-serde-universal@4.2.11/packages/eventstream-serde-universal)

Updates `@smithy/fetch-http-handler` from 5.3.11 to 5.3.13
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/fetch-http-handler/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/fetch-http-handler@5.3.13/packages/fetch-http-handler)

Updates `@smithy/hash-blob-browser` from 4.2.11 to 4.2.12
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/hash-blob-browser/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/hash-blob-browser@4.2.12/packages/hash-blob-browser)

Updates `@smithy/hash-node` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/hash-node/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/hash-node@4.2.11/packages/hash-node)

Updates `@smithy/hash-stream-node` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/hash-stream-node/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/hash-stream-node@4.2.11/packages/hash-stream-node)

Updates `@smithy/invalid-dependency` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/invalid-dependency/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/invalid-dependency@4.2.11/packages/invalid-dependency)

Updates `@smithy/md5-js` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/md5-js/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/md5-js@4.2.11/packages/md5-js)

Updates `@smithy/middleware-content-length` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/middleware-content-length/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/middleware-content-length@4.2.11/packages/middleware-content-length)

Updates `@smithy/middleware-endpoint` from 4.4.20 to 4.4.23
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/middleware-endpoint/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/middleware-endpoint@4.4.23/packages/middleware-endpoint)

Updates `@smithy/middleware-retry` from 4.4.37 to 4.4.40
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/middleware-retry/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/middleware-retry@4.4.40/packages/middleware-retry)

Updates `@smithy/middleware-serde` from 4.2.11 to 4.2.12
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/middleware-serde/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/middleware-serde@4.2.12/packages/middleware-serde)

Updates `@smithy/middleware-stack` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/middleware-stack/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/middleware-stack@4.2.11/packages/middleware-stack)

Updates `@smithy/node-config-provider` from 4.3.10 to 4.3.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/node-config-provider/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/node-config-provider@4.3.11/packages/node-config-provider)

Updates `@smithy/node-http-handler` from 4.4.12 to 4.4.14
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/node-http-handler/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/node-http-handler@4.4.14/packages/node-http-handler)

Updates `@smithy/property-provider` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/property-provider/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/property-provider@4.2.11/packages/property-provider)

Updates `@smithy/protocol-http` from 5.3.10 to 5.3.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/protocol-http/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/protocol-http@5.3.11/packages/protocol-http)

Updates `@smithy/querystring-builder` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/querystring-builder/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/querystring-builder@4.2.11/packages/querystring-builder)

Updates `@smithy/querystring-parser` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/querystring-parser/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/querystring-parser@4.2.11/packages/querystring-parser)

Updates `@smithy/service-error-classification` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/service-error-classification/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/service-error-classification@4.2.11/packages/service-error-classification)

Updates `@smithy/shared-ini-file-loader` from 4.4.5 to 4.4.6
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/shared-ini-file-loader/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/shared-ini-file-loader@4.4.6/packages/shared-ini-file-loader)

Updates `@smithy/signature-v4` from 5.3.10 to 5.3.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/signature-v4/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/signature-v4@5.3.11/packages/signature-v4)

Updates `@smithy/smithy-client` from 4.12.0 to 4.12.3
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/smithy-client/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/smithy-client@4.12.3/packages/smithy-client)

Updates `@smithy/url-parser` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/url-parser/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/url-parser@4.2.11/packages/url-parser)

Updates `@smithy/util-base64` from 4.3.1 to 4.3.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-base64/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-base64@4.3.2/packages/util-base64)

Updates `@smithy/util-body-length-browser` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-body-length-browser/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-body-length-browser@4.2.2/packages/util-body-length-browser)

Updates `@smithy/util-body-length-node` from 4.2.2 to 4.2.3
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-body-length-node/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-body-length-node@4.2.3/packages/util-body-length-node)

Updates `@smithy/util-config-provider` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-config-provider/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-config-provider@4.2.2/packages/util-config-provider)

Updates `@smithy/util-defaults-mode-browser` from 4.3.36 to 4.3.39
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-defaults-mode-browser@4.3.39/packages/util-defaults-mode-node)

Updates `@smithy/util-defaults-mode-node` from 4.2.39 to 4.2.42
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-defaults-mode-node/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-defaults-mode-node@4.2.42/packages/util-defaults-mode-node)

Updates `@smithy/util-endpoints` from 3.3.1 to 3.3.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-endpoints/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-endpoints@3.3.2/packages/util-endpoints)

Updates `@smithy/util-hex-encoding` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-hex-encoding/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-hex-encoding@4.2.2/packages/util-hex-encoding)

Updates `@smithy/util-middleware` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-middleware/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-middleware@4.2.11/packages/util-middleware)

Updates `@smithy/util-retry` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-retry/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-retry@4.2.11/packages/util-retry)

Updates `@smithy/util-stream` from 4.5.15 to 4.5.17
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-stream/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-stream@4.5.17/packages/util-stream)

Updates `@smithy/util-uri-escape` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-uri-escape/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-uri-escape@4.2.2/packages/util-uri-escape)

Updates `@smithy/util-waiter` from 4.2.10 to 4.2.11
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/util-waiter/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/util-waiter@4.2.11/packages/util-waiter)

Updates `@smithy/uuid` from 1.1.1 to 1.1.2
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/uuid/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/uuid@1.1.2/packages/uuid)

Updates `@tauri-apps/cli-darwin-arm64` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-darwin-x64` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-linux-arm-gnueabihf` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-linux-arm64-gnu` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-linux-arm64-musl` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-linux-riscv64-gnu` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-linux-x64-gnu` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-linux-x64-musl` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-win32-arm64-msvc` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-win32-ia32-msvc` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `@tauri-apps/cli-win32-x64-msvc` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.0...tauri-v2.10.1)

Updates `es-toolkit` from 1.44.0 to 1.45.1
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.44.0...v1.45.1)

Updates `fast-xml-parser` from 5.3.6 to 5.4.1
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.6...v5.4.1)

Updates `framer-motion` from 12.34.3 to 12.35.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.34.3...v12.35.0)

Updates `motion-dom` from 12.34.3 to 12.35.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.34.3...v12.35.0)

Updates `multer` from 2.0.2 to 2.1.1
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.0.2...v2.1.1)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: lucide-react
  dependency-version: 0.577.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: motion
  dependency-version: 12.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: react-i18next
  dependency-version: 16.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: react-icons
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: recharts
  dependency-version: 3.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli"
  dependency-version: 2.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: lint-staged
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1004.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.1004.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/common"
  dependency-version: 11.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/core"
  dependency-version: 11.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/platform-express"
  dependency-version: 11.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/testing"
  dependency-version: 11.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/core"
  dependency-version: 3.973.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/crc64-nvme"
  dependency-version: 3.972.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-env"
  dependency-version: 3.972.16
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-http"
  dependency-version: 3.972.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-ini"
  dependency-version: 3.972.17
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-login"
  dependency-version: 3.972.17
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-node"
  dependency-version: 3.972.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-process"
  dependency-version: 3.972.16
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-sso"
  dependency-version: 3.972.17
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-web-identity"
  dependency-version: 3.972.17
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-bucket-endpoint"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-expect-continue"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-flexible-checksums"
  dependency-version: 3.973.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-host-header"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-location-constraint"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-logger"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-recursion-detection"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-sdk-s3"
  dependency-version: 3.972.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-ssec"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-user-agent"
  dependency-version: 3.972.19
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/nested-clients"
  dependency-version: 3.996.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/region-config-resolver"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/signature-v4-multi-region"
  dependency-version: 3.996.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/token-providers"
  dependency-version: 3.1004.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/types"
  dependency-version: 3.973.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-arn-parser"
  dependency-version: 3.972.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-endpoints"
  dependency-version: 3.996.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-format-url"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-locate-window"
  dependency-version: 3.965.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-user-agent-browser"
  dependency-version: 3.972.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-user-agent-node"
  dependency-version: 3.973.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.4.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/abort-controller"
  dependency-version: 4.2.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/chunked-blob-reader-native"
  dependency-version: 4.2.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/chunked-blob-reader"
  dependency-version: 5.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/config-resolver"
  dependency-version: 4.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/core"
  dependency-version: 3.23.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-de...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-07 09:52:29 +00:00
dependabot[bot] a8be96d28e ci(deps): bump anomalyco/opencode in the github-actions group (#229)
Bumps the github-actions group with 1 update: [anomalyco/opencode](https://github.com/anomalyco/opencode).


Updates `anomalyco/opencode` from 1.2.15 to 1.2.20
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/799b2623cbb1c0f19e045d87c2c8593e83678bc0...6c7d968c4423a0cd6c85099c9377a6066313fa0a)

---
updated-dependencies:
- dependency-name: anomalyco/opencode
  dependency-version: 1.2.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 09:47:33 +00:00
zhom 0a826ff03c chore: version bump 2026-03-03 01:01:05 +04:00
zhom 250e206eef refactor: extension cleanup 2026-03-03 01:00:28 +04:00
zhom dd6834a4af fix: prevent double download 2026-03-03 00:57:09 +04:00
128 changed files with 16136 additions and 6754 deletions
@@ -1,76 +0,0 @@
messages:
- role: system
content: |-
You are an issue validation assistant for Donut Browser, an anti-detect browser.
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
For Bug Reports, the issue should include:
1. Clear description of the problem
2. Steps to reproduce the issue (numbered list preferred)
3. Expected vs actual behavior
4. Environment information (OS, browser version, etc.)
5. Error messages, stack traces, or screenshots if applicable
For Feature Requests, the issue should include:
1. Clear description of the requested feature
2. Use case or problem it solves
3. Proposed solution or how it should work
4. Priority level or importance
General Requirements for all issues:
1. Descriptive title
2. Sufficient detail to understand and act upon
3. Professional tone and clear communication
Constraints:
- Maximum 3 items in missing_info array
- Maximum 3 items in suggestions array
- Each array item must be under 80 characters
- overall_assessment must be under 100 characters
- role: user
content: |-
## Issue Content to Analyze:
**Title:** {{issue_title}}
**Body:**
{{issue_body}}
**Labels:** {{issue_labels}}
model: openai/gpt-4.1
responseFormat: json_schema
jsonSchema: |-
{
"name": "issue_validation",
"strict": true,
"schema": {
"type": "object",
"properties": {
"is_valid": {
"type": "boolean",
"description": "Whether the issue contains sufficient information"
},
"issue_type": {
"type": "string",
"enum": ["bug_report", "feature_request", "other"]
},
"missing_info": {
"type": "array",
"items": { "type": "string" },
"description": "Missing information items (max 3, each under 80 characters)"
},
"suggestions": {
"type": "array",
"items": { "type": "string" },
"description": "Suggestions for improvement (max 3, each under 80 characters)"
},
"overall_assessment": {
"type": "string",
"description": "One sentence assessment under 100 characters"
}
},
"required": ["is_valid", "issue_type", "missing_info", "suggestions", "overall_assessment"],
"additionalProperties": false
}
}
-67
View File
@@ -1,67 +0,0 @@
messages:
- role: system
content: |-
You are a code review assistant for Donut Browser, an open-source anti-detect browser built with Tauri, Next.js, and Rust.
Review the provided pull request and provide constructive feedback. Focus on:
1. Code quality and best practices
2. Potential bugs or issues
3. Security concerns (especially important for an anti-detect browser)
4. Performance implications
5. Consistency with the project's patterns
Constraints:
- Maximum 4 items in feedback array
- Maximum 3 items in suggestions array
- Maximum 2 items in security_notes array
- Each array item must be under 150 characters
- summary must be under 200 characters
- Be constructive and helpful, not harsh
- role: user
content: |-
## Pull Request to Review:
**Title:** {{pr_title}}
**Description:**
{{pr_body}}
**Diff:**
{{pr_diff}}
model: openai/gpt-4.1
responseFormat: json_schema
jsonSchema: |-
{
"name": "pr_review",
"strict": true,
"schema": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Brief 1-2 sentence summary under 200 characters"
},
"quality_score": {
"type": "string",
"enum": ["good", "needs_work", "critical_issues"]
},
"feedback": {
"type": "array",
"items": { "type": "string" },
"description": "Feedback points (max 4, each under 150 characters)"
},
"suggestions": {
"type": "array",
"items": { "type": "string" },
"description": "Suggestions (max 3, each under 150 characters)"
},
"security_notes": {
"type": "array",
"items": { "type": "string" },
"description": "Security notes if any (max 2, each under 150 characters)"
}
},
"required": ["summary", "quality_score", "feedback", "suggestions", "security_notes"],
"additionalProperties": false
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+44 -209
View File
@@ -3,7 +3,7 @@ name: Issue & PR Automation
on:
issues:
types: [opened]
pull_request:
pull_request_target:
types: [opened]
issue_comment:
types: [created]
@@ -18,31 +18,13 @@ permissions:
id-token: write
jobs:
validate-issue:
analyze-issue:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Save issue body to file
env:
ISSUE_BODY: ${{ github.event.issue.body }}
run: printf '%s' "${ISSUE_BODY:-}" > issue_body.txt
- name: Validate issue with AI
id: validate
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
prompt-file: .github/prompts/issue-validation.prompt.yml
input: |
issue_title: ${{ github.event.issue.title }}
issue_labels: ${{ join(github.event.issue.labels.*.name, ', ') }}
file_input: |
issue_body: ./issue_body.txt
max-tokens: 1024
- name: Check if first-time contributor
id: check-first-time
env:
@@ -59,102 +41,33 @@ jobs:
echo "is_first_time=false" >> $GITHUB_OUTPUT
fi
- name: Parse validation result and take action
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RESPONSE_FILE: ${{ steps.validate.outputs.response-file }}
run: |
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
else
echo "::error::Response file not found: $RESPONSE_FILE"
exit 1
fi
JSON_RESULT=$(printf "%s" "$RAW_OUTPUT" | sed -n '/```json/,/```/p' | sed '1d;$d')
if [ -z "$JSON_RESULT" ]; then
JSON_RESULT="$RAW_OUTPUT"
fi
if ! echo "$JSON_RESULT" | jq empty 2>/dev/null; then
echo "::warning::Invalid JSON in AI response, using fallback"
JSON_RESULT='{"is_valid":true,"issue_type":"other","missing_info":[],"suggestions":[],"overall_assessment":"Unable to validate automatically"}'
fi
IS_VALID=$(echo "$JSON_RESULT" | jq -r '.is_valid // false')
ISSUE_TYPE=$(echo "$JSON_RESULT" | jq -r '.issue_type // "other"')
MISSING_INFO=$(echo "$JSON_RESULT" | jq -r '.missing_info[]? // empty' | sed 's/^/- /')
SUGGESTIONS=$(echo "$JSON_RESULT" | jq -r '.suggestions[]? // empty' | sed 's/^/- /')
ASSESSMENT=$(echo "$JSON_RESULT" | jq -r '.overall_assessment // "No assessment provided"')
IS_FIRST_TIME="${{ steps.check-first-time.outputs.is_first_time }}"
GREETING_SECTION=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING_SECTION="## 👋 Welcome!\n\nThank you for your first issue ❤️ If this is a feature request, please make sure it is clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible.\n\n---\n\n"
fi
if [ "$IS_VALID" = "false" ]; then
{
printf "%b" "$GREETING_SECTION"
printf "## 🤖 Issue Validation\n\n"
printf "Thank you for submitting this issue! However, it appears that some required information might be missing to help the maintainers better understand and address your concern.\n\n"
printf "**Issue Type Detected:** \`%s\`\n\n" "$ISSUE_TYPE"
printf "**Assessment:** %s\n\n" "$ASSESSMENT"
printf "### 📋 Missing Information:\n%s\n\n" "$MISSING_INFO"
printf "### 💡 Suggestions for Improvement:\n%s\n\n" "$SUGGESTIONS"
printf "### 📝 How to Provide Additional Information:\n\n"
printf "Please edit your original issue description to include the missing information. Here are the issue templates for reference:\n\n"
printf -- "- **Bug Report Template:** [View Template](.github/ISSUE_TEMPLATE/01-bug-report.md)\n"
printf -- "- **Feature Request Template:** [View Template](.github/ISSUE_TEMPLATE/02-feature-request.md)\n\n"
printf "### 🔧 Quick Tips:\n"
printf -- "- For **bug reports**: Include step-by-step reproduction instructions, your environment details, and any error messages\n"
printf -- "- For **feature requests**: Describe the use case, expected behavior, and why this feature would be valuable\n"
printf -- "- Add **screenshots** or **logs** when applicable\n\n"
printf "Once you have updated the issue with the missing information, feel free to remove this comment or reply to let the maintainers know the updates have been made.\n\n"
printf -- "---\n*This validation was performed automatically to ensure all the information needed to help effectively is provided.*\n"
} > comment.md
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
gh issue edit ${{ github.event.issue.number }} --add-label "needs-info"
else
SUGGESTIONS_SECTION=""
if [ -n "$SUGGESTIONS" ]; then
SUGGESTIONS_SECTION=$(printf "### 💡 Suggestions:\n%s\n\n" "$SUGGESTIONS")
fi
{
printf "%b" "$GREETING_SECTION"
printf "## 🤖 Issue Validation\n\n"
printf "**Issue Type Detected:** \`%s\`\n\n" "$ISSUE_TYPE"
printf "**Assessment:** %s\n\n" "$ASSESSMENT"
printf "%b" "$SUGGESTIONS_SECTION"
printf -- "---\n*This validation was performed automatically to help triage issues.*\n"
} > comment.md
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
case "$ISSUE_TYPE" in
"bug_report")
gh issue edit ${{ github.event.issue.number }} --add-label "bug"
;;
"feature_request")
gh issue edit ${{ github.event.issue.number }} --add-label "enhancement"
;;
esac
fi
- name: Run opencode analysis
uses: anomalyco/opencode/github@799b2623cbb1c0f19e045d87c2c8593e83678bc0 #v1.2.15
- name: Analyze issue
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: zai-coding-plan/glm-4.7
prompt: |
You are a triage bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).
- name: Cleanup
run: rm -f issue_body.txt comment.md
${{ steps.check-first-time.outputs.is_first_time == 'true' && 'This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"' || '' }}
handle-pr:
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
Analyze this issue and post a single concise comment. Format:
1. One sentence acknowledging what the user wants.
2. A short **Action items** list — what specific info is missing or what the user should do next. Only include items that are actually missing. If the issue is complete, say so and skip this section.
3. Label the issue: add "bug" label for bug reports, "enhancement" label for feature requests.
Rules:
- Be brief. No filler, no generic tips, no templates.
- If it's a bug report, check for: reproduction steps, OS/version, error messages. Only ask for what's actually missing.
- If it's a feature request, check for: clear description of desired behavior, use case. Only ask for what's actually missing.
- If the issue already has everything needed, just acknowledge it and label it.
- Never exceed 6 items total.
analyze-pr:
if: github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -178,109 +91,30 @@ jobs:
echo "is_first_time=false" >> $GITHUB_OUTPUT
fi
- name: Get PR diff
id: get-diff
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number }} > pr_diff.txt
head -c 10000 pr_diff.txt > pr_diff_truncated.txt
- name: Save PR body to file
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: printf '%s' "${PR_BODY:-No description provided}" > pr_body.txt
- name: Analyze PR with AI
id: analyze
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
prompt-file: .github/prompts/pr-review.prompt.yml
input: |
pr_title: ${{ github.event.pull_request.title }}
file_input: |
pr_body: ./pr_body.txt
pr_diff: ./pr_diff_truncated.txt
max-tokens: 1024
- name: Post PR feedback comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RESPONSE_FILE: ${{ steps.analyze.outputs.response-file }}
run: |
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
else
echo "::error::Response file not found"
exit 1
fi
JSON_RESULT=$(printf "%s" "$RAW_OUTPUT" | sed -n '/```json/,/```/p' | sed '1d;$d')
if [ -z "$JSON_RESULT" ]; then
JSON_RESULT="$RAW_OUTPUT"
fi
if ! echo "$JSON_RESULT" | jq empty 2>/dev/null; then
echo "::warning::Invalid JSON in AI response, using fallback"
JSON_RESULT='{"summary":"Unable to analyze automatically","quality_score":"good","feedback":[],"suggestions":[],"security_notes":[]}'
fi
SUMMARY=$(echo "$JSON_RESULT" | jq -r '.summary // "No summary"')
QUALITY=$(echo "$JSON_RESULT" | jq -r '.quality_score // "good"')
FEEDBACK=$(echo "$JSON_RESULT" | jq -r '.feedback[]? // empty' | sed 's/^/- /')
SUGGESTIONS=$(echo "$JSON_RESULT" | jq -r '.suggestions[]? // empty' | sed 's/^/- /')
SECURITY=$(echo "$JSON_RESULT" | jq -r '.security_notes[]? // empty' | sed 's/^/- ⚠️ /')
IS_FIRST_TIME="${{ steps.check-first-time.outputs.is_first_time }}"
{
if [ "$IS_FIRST_TIME" = "true" ]; then
printf "## 👋 Welcome!\n\n"
printf "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 review and could be merged.\n\n"
printf -- "---\n\n"
fi
printf "## 🤖 PR Review\n\n"
printf "**Summary:** %s\n\n" "$SUMMARY"
case "$QUALITY" in
"good")
printf "**Status:** ✅ Looking good!\n\n"
;;
"needs_work")
printf "**Status:** 🔧 Some improvements suggested\n\n"
;;
"critical_issues")
printf "**Status:** ⚠️ Please address the issues below\n\n"
;;
esac
if [ -n "$FEEDBACK" ]; then
printf "### 📝 Feedback:\n%s\n\n" "$FEEDBACK"
fi
if [ -n "$SUGGESTIONS" ]; then
printf "### 💡 Suggestions:\n%s\n\n" "$SUGGESTIONS"
fi
if [ -n "$SECURITY" ]; then
printf "### 🔒 Security Notes:\n%s\n\n" "$SECURITY"
fi
printf -- "---\n*This review was performed automatically. A human maintainer will also review your changes.*\n"
} > comment.md
gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md
- name: Run opencode analysis
uses: anomalyco/opencode/github@799b2623cbb1c0f19e045d87c2c8593e83678bc0 #v1.2.15
- name: Analyze PR
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: zai-coding-plan/glm-4.7
prompt: |
You are a review bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).
- name: Cleanup
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_body.txt comment.md
${{ steps.check-first-time.outputs.is_first_time == 'true' && 'This is a first-time contributor. Start your comment with: "Thanks for your first PR!"' || '' }}
Review this PR and post a single concise comment. Format:
1. One sentence summarizing what this PR does.
2. **Action items** — only list things that actually need to be fixed or addressed. If the PR looks good, say so and skip this section.
Rules:
- Be brief. No filler, no praise padding.
- Focus on: bugs, security issues, missing edge cases, breaking changes.
- If the PR touches UI text or adds new strings, remind to update translation files in src/i18n/locales/.
- If the PR modifies Tauri commands, remind to check the unused-commands test.
- Do not nitpick style or formatting — the project has automated linting.
- Never exceed 8 lines total.
opencode-command:
if: |
@@ -295,8 +129,9 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Run opencode
uses: anomalyco/opencode/github@799b2623cbb1c0f19e045d87c2c8593e83678bc0 #v1.2.15
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: zai-coding-plan/glm-4.7
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+28 -39
View File
@@ -102,7 +102,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
@@ -125,7 +125,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
with:
workdir: ./src-tauri
@@ -264,22 +264,19 @@ jobs:
go install github.com/ralt/repogen/cmd/repogen@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Configure AWS CLI for Cloudflare R2
run: |
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
- name: Sync existing repo metadata from R2
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
mkdir -p /tmp/repo
aws s3 sync "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
aws s3 cp "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
aws s3 cp "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
- name: Generate repository with repogen
run: |
@@ -296,38 +293,30 @@ jobs:
- name: Upload repository to R2
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
aws s3 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
--endpoint-url "${R2_ENDPOINT}"
aws s3 sync /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
--endpoint-url "${R2_ENDPOINT}"
aws s3 cp /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
--endpoint-url "${R2_ENDPOINT}" --recursive
- name: Verify upload
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "DEB repo:"
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}"
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
echo "RPM repo:"
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
bump-homebrew-cask:
needs: [release]
runs-on: macos-latest
permissions:
contents: read
steps:
- name: Bump Homebrew cask
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
run: |
brew tap --force homebrew/cask
VERSION="${GITHUB_REF_NAME#v}"
brew bump-cask-pr --version "$VERSION" --no-browse donut
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
+2 -2
View File
@@ -101,7 +101,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
@@ -124,7 +124,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
with:
workdir: ./src-tauri
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
@@ -51,7 +51,7 @@ jobs:
toolchain: stable
- name: Cache Rust dependencies
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
with:
workspaces: "src-tauri"
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
with:
run_install: false
+4 -1
View File
@@ -55,4 +55,7 @@ nodecar/nodecar-bin
.cache/
# env
.env
.env
# next
next-env.d.ts
+10
View File
@@ -0,0 +1,10 @@
# Prevent pushing the 'nightly' tag — it is managed by CI
if git rev-parse nightly >/dev/null 2>&1; then
LOCAL_NIGHTLY=$(git rev-parse nightly)
REMOTE_NIGHTLY=$(git ls-remote --tags origin refs/tags/nightly 2>/dev/null | awk '{print $1}')
if [ -n "$REMOTE_NIGHTLY" ] && [ "$LOCAL_NIGHTLY" != "$REMOTE_NIGHTLY" ]; then
echo "⚠ Skipping push of 'nightly' tag (managed by CI)"
# Delete the local nightly tag so --tags won't try to push it
git tag -d nightly >/dev/null 2>&1 || true
fi
fi
+13
View File
@@ -13,6 +13,7 @@
"autoconfig",
"autologin",
"biomejs",
"boringtun",
"breezedark",
"browserforge",
"busctl",
@@ -42,6 +43,7 @@
"DBAPI",
"dconf",
"debuginfo",
"desynced",
"devedition",
"direnv",
"distro",
@@ -83,6 +85,7 @@
"infobars",
"inkey",
"Inno",
"isps",
"kdeglobals",
"keras",
"KHTML",
@@ -131,6 +134,7 @@
"ntlm",
"numpy",
"objc",
"oneshot",
"opencode",
"orhun",
"orjson",
@@ -163,12 +167,16 @@
"pyyaml",
"quic",
"ralt",
"ramdisk",
"repodata",
"repogen",
"reportingpolicy",
"reqwest",
"resvg",
"ridedott",
"rlib",
"rsplit",
"rusqlite",
"rustc",
"rwxr",
"SARIF",
@@ -188,6 +196,7 @@
"signon",
"signum",
"sklearn",
"smoltcp",
"SMTO",
"sonner",
"splitn",
@@ -208,14 +217,18 @@
"TERX",
"testpass",
"testuser",
"thiserror",
"timedatectl",
"titlebar",
"tkinter",
"tmpfs",
"tqdm",
"trackingprotection",
"trailhead",
"tungstenite",
"turbopack",
"turtledemo",
"typer",
"udeps",
"unlisten",
"unminimize",
+37 -7
View File
@@ -1,9 +1,39 @@
# Instructions for AI Agents
# Project Guidelines
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
- Don't leave comments that don't add value.
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times.
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
- 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.
## Testing and Quality
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
## Code Quality
- Don't leave comments that don't add value
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Singletons
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
## UI Theming
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
- Available semantic color classes:
- `background`, `foreground` — page/container background and text
- `card`, `card-foreground` — card surfaces
- `popover`, `popover-foreground` — dropdown/popover surfaces
- `primary`, `primary-foreground` — primary actions
- `secondary`, `secondary-foreground` — secondary actions
- `muted`, `muted-foreground` — muted/disabled elements
- `accent`, `accent-foreground` — accent highlights
- `destructive`, `destructive-foreground` — errors, danger, delete actions
- `success`, `success-foreground` — success states, valid indicators
- `warning`, `warning-foreground` — warnings, caution messages
- `border` — borders
- `chart-1` through `chart-5` — data visualization
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+20 -1
View File
@@ -4,6 +4,7 @@
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
## Code Quality
@@ -17,4 +18,22 @@
## UI Theming
- When modifying the UI, don't add random colors that are not controlled by `src/lib/themes.ts`
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
- Available semantic color classes:
- `background`, `foreground` — page/container background and text
- `card`, `card-foreground` — card surfaces
- `popover`, `popover-foreground` — dropdown/popover surfaces
- `primary`, `primary-foreground` — primary actions
- `secondary`, `secondary-foreground` — secondary actions
- `muted`, `muted-foreground` — muted/disabled elements
- `accent`, `accent-foreground` — accent highlights
- `destructive`, `destructive-foreground` — errors, danger, delete actions
- `success`, `success-foreground` — success states, valid indicators
- `warning`, `warning-foreground` — warnings, caution messages
- `border` — borders
- `chart-1` through `chart-5` — data visualization
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+1
View File
@@ -9,3 +9,4 @@ extend-exclude = [
[default.extend-words]
DBE = "DBE"
nd = "nd"
+8 -8
View File
@@ -18,12 +18,12 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1000.0",
"@aws-sdk/s3-request-presigner": "^3.1000.0",
"@nestjs/common": "^11.1.14",
"@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1009.0",
"@nestjs/common": "^11.1.16",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.14",
"@nestjs/platform-express": "^11.1.14",
"@nestjs/core": "^11.1.16",
"@nestjs/platform-express": "^11.1.16",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
@@ -31,13 +31,13 @@
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.1.14",
"@nestjs/testing": "^11.1.16",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/supertest": "^7.2.0",
"jest": "^30.2.0",
"jest": "^30.3.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
+5 -1
View File
@@ -1,4 +1,5 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module.js";
function validateEnv() {
@@ -11,7 +12,10 @@ function validateEnv() {
async function bootstrap() {
validateEnv();
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// biome-ignore lint/correctness/useHookAtTopLevel: NestJS method, not a React hook
app.useBodyParser("json", { limit: "50mb" });
app.enableCors({
origin: "*",
+17 -13
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.15.0",
"version": "0.17.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -12,9 +12,10 @@
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
"lint": "pnpm lint:js && pnpm lint:rust",
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
@@ -56,32 +57,32 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^25.8.13",
"lucide-react": "^0.576.0",
"motion": "^12.34.3",
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"motion": "^12.36.0",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"recharts": "3.7.0",
"react-i18next": "^16.5.8",
"react-icons": "^5.6.0",
"recharts": "3.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.4",
"@biomejs/biome": "2.4.7",
"@tailwindcss/postcss": "^4.2.1",
"@tauri-apps/cli": "~2.10.0",
"@tauri-apps/cli": "~2.10.1",
"@types/color": "^4.2.0",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.3.1",
"lint-staged": "^16.3.4",
"tailwindcss": "^4.2.1",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
@@ -96,6 +97,9 @@
"bash -c 'cd src-tauri && cargo fmt --all'",
"bash -c 'cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all'",
"bash -c 'cd src-tauri && cargo test --lib'"
],
"**/*.{rs,ts,tsx,js,jsx,md}": [
"typos"
]
}
}
+1348 -1407
View File
File diff suppressed because it is too large Load Diff
+536 -268
View File
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.15.0"
version = "0.17.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -30,7 +30,7 @@ path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.46"
resvg = "0.47"
[dependencies]
serde_json = "1"
@@ -57,7 +57,7 @@ base64 = "0.22"
libc = "0.2"
async-trait = "0.1"
futures-util = "0.3"
zip = { version = "7", default-features = false, features = ["deflate-flate2"] }
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
tar = "0"
bzip2 = "0"
flate2 = "1"
@@ -72,14 +72,19 @@ mime_guess = "2"
once_cell = "1"
urlencoding = "2.1"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
axum = { version = "0.8.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
rand = "0.10.0"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha1 = "0.10"
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
@@ -91,7 +96,7 @@ playwright = { git = "https://github.com/sctg-development/playwright-rust", bran
# Wayfern CDP integration
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
rusqlite = { version = "0.38", features = ["bundled"] }
rusqlite = { version = "0.39", features = ["bundled"] }
serde_yaml = "0.9"
thiserror = "2.0"
regex-lite = "0.1"
@@ -100,9 +105,8 @@ maxminddb = "0.27"
quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support
lz4_flex = "0.11"
boringtun = "0.7"
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
smoltcp = { version = "0.12", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.21"
@@ -122,7 +126,7 @@ objc2 = "0.6.3"
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
winreg = "0.56"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_ProcessStatus",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 B

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+55 -12
View File
@@ -12,6 +12,7 @@ pub struct VersionComponent {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub build: u32,
pub pre_release: Option<PreRelease>,
}
@@ -47,6 +48,7 @@ impl VersionComponent {
major: 999, // High major version to indicate it's a rolling release
minor: 0,
patch: 0,
build: 0,
pre_release: Some(PreRelease {
kind: PreReleaseKind::Alpha,
number: Some(999), // High number to indicate it's a rolling release
@@ -66,6 +68,7 @@ impl VersionComponent {
let major = parts.first().copied().unwrap_or(0);
let minor = parts.get(1).copied().unwrap_or(0);
let patch = parts.get(2).copied().unwrap_or(0);
let build = parts.get(3).copied().unwrap_or(0);
// Parse pre-release part
let pre_release = pre_release_part
@@ -76,6 +79,7 @@ impl VersionComponent {
major,
minor,
patch,
build,
pre_release,
}
}
@@ -173,7 +177,12 @@ impl Ord for VersionComponent {
match (self_is_twilight, other_is_twilight) {
(true, true) => {
// Both are twilight, compare by base version
return (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
return (self.major, self.minor, self.patch, self.build).cmp(&(
other.major,
other.minor,
other.patch,
other.build,
));
}
(false, false) => {
// Neither is twilight, continue with normal comparison
@@ -181,8 +190,13 @@ impl Ord for VersionComponent {
_ => unreachable!(), // Already handled above
}
// Compare major.minor.patch first
match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) {
// Compare major.minor.patch.build first
match (self.major, self.minor, self.patch, self.build).cmp(&(
other.major,
other.minor,
other.patch,
other.build,
)) {
Ordering::Equal => {
// If numeric parts are equal, compare pre-release
match (&self.pre_release, &other.pre_release) {
@@ -1124,18 +1138,47 @@ impl ApiClient {
log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json");
let url = "https://donutbrowser.com/wayfern.json";
let response = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
let mut last_err = None;
let mut version_info: Option<WayfernVersionInfo> = None;
if !response.status().is_success() {
return Err(format!("Failed to fetch Wayfern version: {}", response.status()).into());
for attempt in 1..=3 {
match self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await
{
Ok(response) => {
if !response.status().is_success() {
last_err = Some(format!("HTTP {}", response.status()));
} else {
match response.json::<WayfernVersionInfo>().await {
Ok(info) => {
version_info = Some(info);
break;
}
Err(e) => last_err = Some(format!("Failed to parse response: {e}")),
}
}
}
Err(e) => {
log::warn!("Wayfern fetch attempt {attempt}/3 failed: {e}");
last_err = Some(e.to_string());
}
}
if attempt < 3 {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
let version_info: WayfernVersionInfo = response.json().await?;
let version_info = version_info.ok_or_else(|| {
format!(
"Failed to fetch Wayfern version after 3 attempts: {}",
last_err.unwrap_or_default()
)
})?;
log::info!("Fetched Wayfern version: {}", version_info.version);
// Cache the results (unless bypassing cache)
+115 -25
View File
@@ -111,13 +111,17 @@ struct ApiProxyResponse {
name: String,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest {
name: String,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -125,6 +129,8 @@ struct UpdateProxyRequest {
name: Option<String>,
#[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -315,6 +321,7 @@ impl ApiServer {
.routes(routes!(download_browser_api))
.routes(routes!(get_browser_versions))
.routes(routes!(check_browser_downloaded))
.routes(routes!(get_wayfern_token, refresh_wayfern_token))
.split_for_parts();
let api = ApiDoc::openapi();
@@ -333,7 +340,7 @@ impl ApiServer {
.with_state(ws_state);
let app = Router::new()
.nest("/v1", v1_routes)
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.layer(CorsLayer::permissive())
@@ -1027,6 +1034,8 @@ async fn get_proxies(
.map(|p| ApiProxyResponse {
id: p.id,
name: p.name,
dynamic_proxy_url: p.dynamic_proxy_url,
dynamic_proxy_format: p.dynamic_proxy_format,
proxy_settings: p.proxy_settings,
})
.collect(),
@@ -1060,6 +1069,8 @@ async fn get_proxy(
id: proxy.id,
name: proxy.name,
proxy_settings: proxy.proxy_settings,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
}))
} else {
Err(StatusCode::NOT_FOUND)
@@ -1085,14 +1096,27 @@ async fn create_proxy(
State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
request.proxy_settings,
) {
let result = if let (Some(url), Some(format)) =
(&request.dynamic_proxy_url, &request.dynamic_proxy_format)
{
PROXY_MANAGER.create_dynamic_proxy(
&state.app_handle,
request.name.clone(),
url.clone(),
format.clone(),
)
} else if let Some(settings) = request.proxy_settings {
PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings)
} else {
return Err(StatusCode::BAD_REQUEST);
};
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
@@ -1123,28 +1147,29 @@ async fn update_proxy(
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 = request
.proxy_settings
.unwrap_or(proxy.proxy_settings.clone());
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
match PROXY_MANAGER.update_stored_proxy(
let result = if is_dynamic {
PROXY_MANAGER.update_dynamic_proxy(
&state.app_handle,
&id,
Some(new_name.clone()),
Some(new_proxy_settings.clone()),
) {
Ok(_) => Ok(Json(ApiProxyResponse {
id,
name: new_name,
proxy_settings: new_proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
request.name,
request.dynamic_proxy_url,
request.dynamic_proxy_format,
)
} else {
Err(StatusCode::NOT_FOUND)
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings)
};
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
@@ -1288,6 +1313,13 @@ async fn run_profile(
State(state): State<ApiServerState>,
Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let headless = request.headless.unwrap_or(false);
let url = request.url;
@@ -1356,6 +1388,13 @@ async fn open_url_in_profile(
State(state): State<ApiServerState>,
Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
@@ -1501,3 +1540,54 @@ async fn check_browser_downloaded(
let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version);
Ok(Json(is_downloaded))
}
// API Handlers - Wayfern Token
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct WayfernTokenResponse {
pub token: Option<String>,
}
#[utoipa::path(
get,
path = "/v1/wayfern-token",
responses(
(status = 200, description = "Current wayfern token", body = WayfernTokenResponse),
(status = 401, description = "Unauthorized"),
),
security(
("bearer_auth" = [])
),
tag = "wayfern"
)]
async fn get_wayfern_token(
State(_state): State<ApiServerState>,
) -> Result<Json<WayfernTokenResponse>, StatusCode> {
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token }))
}
#[utoipa::path(
post,
path = "/v1/wayfern-token/refresh",
responses(
(status = 200, description = "Refreshed wayfern token", body = WayfernTokenResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Failed to refresh token"),
),
security(
("bearer_auth" = [])
),
tag = "wayfern"
)]
async fn refresh_wayfern_token(
State(_state): State<ApiServerState>,
) -> Result<Json<WayfernTokenResponse>, (StatusCode, String)> {
crate::cloud_auth::CLOUD_AUTH
.request_wayfern_token()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token }))
}
+10
View File
@@ -1602,6 +1602,16 @@ rm "{}"
#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
// The disable_auto_updates setting controls app self-updates only
let disabled = crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|s| s.disable_auto_updates)
.unwrap_or(false);
if disabled {
log::info!("App auto-updates disabled by user setting");
return Ok(None);
}
let updater = AppAutoUpdater::instance();
updater
.check_for_updates()
+5
View File
@@ -66,6 +66,10 @@ pub fn proxies_dir() -> PathBuf {
data_dir().join("proxies")
}
pub fn proxy_workers_dir() -> PathBuf {
cache_dir().join("proxy_workers")
}
pub fn vpn_dir() -> PathBuf {
data_dir().join("vpn")
}
@@ -155,6 +159,7 @@ mod tests {
assert!(data_subdir().ends_with("data"));
assert!(settings_dir().ends_with("settings"));
assert!(proxies_dir().ends_with("proxies"));
assert!(proxy_workers_dir().ends_with("proxy_workers"));
assert!(vpn_dir().ends_with("vpn"));
assert!(extensions_dir().ends_with("extensions"));
}
+250 -86
View File
@@ -1,5 +1,4 @@
use crate::browser_version_manager::{BrowserVersionInfo, BrowserVersionManager};
use crate::events;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::settings_manager::SettingsManager;
use serde::{Deserialize, Serialize};
@@ -81,24 +80,25 @@ 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
// Always fetch fresh versions for update checks — stale cache would miss new releases
let versions = match self
.browser_version_manager
.get_cached_browser_versions_detailed(&browser)
.fetch_browser_versions_detailed(&browser, false)
.await
{
cached
} else if self.browser_version_manager.should_update_cache(&browser) {
// Try to fetch fresh versions
match self
.browser_version_manager
.fetch_browser_versions_detailed(&browser, false)
.await
{
Ok(versions) => versions,
Err(_) => continue, // Skip this browser if fetch fails
Ok(versions) => versions,
Err(e) => {
log::warn!("Failed to fetch versions for {browser}: {e}, trying cache");
// Fall back to cache if network fails
if let Some(cached) = self
.browser_version_manager
.get_cached_browser_versions_detailed(&browser)
{
cached
} else {
continue;
}
}
} else {
continue; // No cached versions and cache doesn't need update
};
browser_versions.insert(browser.clone(), versions.clone());
@@ -106,26 +106,7 @@ impl AutoUpdater {
// Check each profile for updates
for profile in profiles {
if let Some(update) = self.check_profile_update(&profile, &versions)? {
// Apply chromium threshold logic
if browser == "chromium" {
// For chromium, only show notifications if there are 400+ new versions
let current_version = &profile.version.parse::<u32>().unwrap();
let new_version = &update.new_version.parse::<u32>().unwrap();
let result = new_version - current_version;
log::info!(
"Current version: {current_version}, New version: {new_version}, Result: {result}"
);
if result > 400 {
notifications.push(update);
} else {
log::info!(
"Skipping chromium update notification: only {result} new versions (need 400+)"
);
}
} else {
notifications.push(update);
}
notifications.push(update);
}
}
}
@@ -136,78 +117,72 @@ impl AutoUpdater {
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
log::info!("Starting auto-update check with progress...");
// Browser auto-updates are always enabled — the disable_auto_updates setting
// only controls app self-updates, not browser version updates.
// Check for browser updates and trigger auto-downloads
match self.check_for_updates().await {
Ok(update_notifications) => {
if !update_notifications.is_empty() {
log::info!(
"Found {} browser updates to auto-download",
update_notifications.len()
);
// Group by browser+version to avoid duplicate downloads
let grouped = self.group_update_notifications(update_notifications);
if !grouped.is_empty() {
log::info!("Found {} browser updates", grouped.len());
// Trigger automatic downloads for each update
for notification in update_notifications {
for notification in grouped {
log::info!(
"Auto-downloading {} version {}",
"Auto-updating {} to version {} ({} profiles)",
notification.browser,
notification.new_version
notification.new_version,
notification.affected_profiles.len()
);
// Clone app_handle for the async task
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::downloaded_browsers_registry::is_browser_downloaded(
browser.clone(),
new_version.clone(),
) {
true => {
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
// Browser already exists, go straight to profile update
match AutoUpdater::instance()
.complete_browser_update_with_auto_update(
&app_handle_clone,
&browser.clone(),
&new_version.clone(),
)
.await
{
Ok(updated_profiles) => {
if registry.is_browser_downloaded(&browser, &new_version) {
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
// Browser already exists, go straight to profile update
match AutoUpdater::instance()
.auto_update_profile_versions(&app_handle_clone, &browser, &new_version)
.await
{
Ok(updated_profiles) => {
if !updated_profiles.is_empty() {
log::info!(
"Auto-update completed for {} profiles: {:?}",
"Auto-updated {} profiles to {browser} {new_version}: {:?}",
updated_profiles.len(),
updated_profiles
);
}
Err(e) => {
log::error!("Failed to complete auto-update for {browser}: {e}");
}
}
Err(e) => {
log::error!("Failed to auto-update profiles for {browser}: {e}");
}
}
false => {
log::info!("Downloading browser {browser} version {new_version}...");
} else {
log::info!("Downloading browser {browser} version {new_version}...");
// Emit the auto-update event to trigger frontend handling
let auto_update_event = serde_json::json!({
"browser": browser,
"new_version": new_version,
"notification_id": notification_id,
"affected_profiles": affected_profiles
});
if let Err(e) = events::emit("browser-auto-update-available", &auto_update_event)
{
log::error!("Failed to emit auto-update event for {browser}: {e}");
} else {
log::info!("Emitted auto-update event for {browser}");
// Download directly from Rust — download_browser_full already
// auto-updates non-running profiles after successful download.
match crate::downloader::download_browser(
app_handle_clone,
browser.clone(),
new_version.clone(),
)
.await
{
Ok(actual_version) => {
log::info!("Auto-download completed for {browser} {actual_version}");
}
Err(e) => {
log::error!("Failed to auto-download {browser} {new_version}: {e}");
}
}
}
@@ -221,6 +196,24 @@ impl AutoUpdater {
log::error!("Failed to check for browser updates: {e}");
}
}
// Also update any profiles that can be bumped to an already-installed newer version.
// This handles cases where a version was downloaded but profiles weren't updated
// (e.g., they were running at the time, or the update was missed).
match self.update_profiles_to_latest_installed(app_handle) {
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Updated {} profiles to latest installed versions: {:?}",
updated.len(),
updated
);
}
}
Err(e) => {
log::error!("Failed to update profiles to latest installed versions: {e}");
}
}
}
/// Check if a specific profile has an available update
@@ -323,7 +316,36 @@ impl AutoUpdater {
// Check if profile is currently running
if profile.process_id.is_some() {
continue; // Skip running profiles
// Store as pending update so it gets applied when browser closes
log::info!(
"Profile {} is running, storing pending update {} -> {}",
profile.name,
profile.version,
new_version
);
let mut state = self.load_auto_update_state().unwrap_or_default();
let notification = UpdateNotification {
id: format!("{}_{}_to_{}", browser, profile.version, new_version),
browser: browser.to_string(),
current_version: profile.version.clone(),
new_version: new_version.to_string(),
affected_profiles: vec![profile.name.clone()],
is_stable_update: true,
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
// Add if not already pending
if !state
.pending_updates
.iter()
.any(|u| u.id == notification.id)
{
state.pending_updates.push(notification);
let _ = self.save_auto_update_state(&state);
}
continue;
}
// Check if this is an update (newer version)
@@ -456,6 +478,148 @@ impl AutoUpdater {
Ok(None)
}
/// Get the latest installed version for a browser from the downloaded browsers registry
pub fn get_latest_installed_version(&self, browser: &str) -> Option<String> {
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let versions = registry.get_downloaded_versions(browser);
versions
.into_iter()
.filter(|v| registry.is_browser_downloaded(browser, v))
.max_by(|a, b| self.compare_versions(a, b))
}
/// Update a single profile to the latest installed version for its browser.
/// Used when a browser closes to ensure it's on the latest version.
pub fn update_profile_to_latest_installed(
&self,
app_handle: &tauri::AppHandle,
profile: &crate::profile::BrowserProfile,
) -> Option<crate::profile::BrowserProfile> {
let latest = self.get_latest_installed_version(&profile.browser)?;
if !self.is_version_newer(&latest, &profile.version) {
return None;
}
// Only update stable->stable and nightly->nightly
let is_profile_nightly =
crate::api_client::is_browser_version_nightly(&profile.browser, &profile.version, None);
let is_latest_nightly =
crate::api_client::is_browser_version_nightly(&profile.browser, &latest, None);
if is_profile_nightly != is_latest_nightly {
return None;
}
match self
.profile_manager
.update_profile_version(app_handle, &profile.id.to_string(), &latest)
{
Ok(updated) => {
log::info!(
"Updated profile {} from {} {} to latest installed version {}",
profile.name,
profile.browser,
profile.version,
latest
);
Some(updated)
}
Err(e) => {
log::error!(
"Failed to update profile {} to latest installed version: {e}",
profile.name
);
None
}
}
}
/// Update all non-running profiles to the latest installed version for each browser.
/// Handles the case where a newer version was downloaded but profiles weren't updated.
pub fn update_profiles_to_latest_installed(
&self,
app_handle: &tauri::AppHandle,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let profiles = self
.profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let mut all_updated = Vec::new();
// Group profiles by browser
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
for profile in profiles {
if profile.is_cross_os() {
continue;
}
browser_profiles
.entry(profile.browser.clone())
.or_default()
.push(profile);
}
for (browser, profiles) in browser_profiles {
let installed_versions = registry.get_downloaded_versions(&browser);
if installed_versions.is_empty() {
continue;
}
// Find the latest installed version that actually exists on disk
let latest_installed = installed_versions
.iter()
.filter(|v| registry.is_browser_downloaded(&browser, v))
.max_by(|a, b| self.compare_versions(a, b));
let latest_version = match latest_installed {
Some(v) => v.clone(),
None => continue,
};
for profile in profiles {
if profile.process_id.is_some() {
continue;
}
if !self.is_version_newer(&latest_version, &profile.version) {
continue;
}
// Only update stable->stable and nightly->nightly
let is_profile_nightly =
crate::api_client::is_browser_version_nightly(&browser, &profile.version, None);
let is_latest_nightly =
crate::api_client::is_browser_version_nightly(&browser, &latest_version, None);
if is_profile_nightly != is_latest_nightly {
continue;
}
match self.profile_manager.update_profile_version(
app_handle,
&profile.id.to_string(),
&latest_version,
) {
Ok(_) => {
log::info!(
"Updated profile {} from {} {} to latest installed version {}",
profile.name,
browser,
profile.version,
latest_version
);
all_updated.push(profile.name);
}
Err(e) => {
log::error!("Failed to update profile {}: {e}", profile.name);
}
}
}
}
Ok(all_updated)
}
}
// Tauri commands
+77 -554
View File
@@ -13,11 +13,6 @@ pub struct ProxySettings {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum BrowserType {
Chromium,
Firefox,
FirefoxDeveloper,
Brave,
Zen,
Camoufox,
Wayfern,
}
@@ -25,11 +20,6 @@ pub enum BrowserType {
impl BrowserType {
pub fn as_str(&self) -> &'static str {
match self {
BrowserType::Chromium => "chromium",
BrowserType::Firefox => "firefox",
BrowserType::FirefoxDeveloper => "firefox-developer",
BrowserType::Brave => "brave",
BrowserType::Zen => "zen",
BrowserType::Camoufox => "camoufox",
BrowserType::Wayfern => "wayfern",
}
@@ -37,11 +27,6 @@ impl BrowserType {
pub fn from_str(s: &str) -> Result<Self, String> {
match s {
"chromium" => Ok(BrowserType::Chromium),
"firefox" => Ok(BrowserType::Firefox),
"firefox-developer" => Ok(BrowserType::FirefoxDeveloper),
"brave" => Ok(BrowserType::Brave),
"zen" => Ok(BrowserType::Zen),
"camoufox" => Ok(BrowserType::Camoufox),
"wayfern" => Ok(BrowserType::Wayfern),
_ => Err(format!("Unknown browser type: {s}")),
@@ -49,6 +34,7 @@ impl BrowserType {
}
}
#[allow(dead_code)]
pub trait Browser: Send + Sync {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args(
@@ -88,10 +74,7 @@ mod macos {
.filter(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("Browser")
name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("Browser")
})
.map(|entry| entry.path())
.collect();
@@ -200,34 +183,6 @@ mod macos {
Ok(executable_path)
}
pub fn get_chromium_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("Browser app not found")?;
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
Ok(executable_path)
}
pub fn get_wayfern_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -281,18 +236,7 @@ mod macos {
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path) -> bool {
// On macOS, check for .app files
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
return true;
}
}
}
false
}
#[allow(dead_code)]
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On macOS, no special preparation needed
Ok(())
@@ -312,24 +256,10 @@ mod linux {
// - Firefox/Firefox Developer on Linux often extract to: install_dir/firefox/firefox
// - Some archives may extract directly under: install_dir/firefox or install_dir/firefox-bin
// - For some flavors we may have: install_dir/<browser_type>/<binary>
let browser_subdir = install_dir.join(browser_type.as_str());
let _browser_subdir = install_dir.join(browser_type.as_str());
// Try common firefox executable locations (nested and flat)
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => vec![
// Nested "firefox/firefox" or "firefox/firefox-bin"
install_dir.join("firefox").join("firefox"),
install_dir.join("firefox").join("firefox-bin"),
// Flat under version directory
install_dir.join("firefox"),
install_dir.join("firefox-bin"),
// Under a subdirectory matching the browser type
browser_subdir.join("firefox"),
browser_subdir.join("firefox-bin"),
],
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -360,36 +290,10 @@ mod linux {
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let possible_executables = match browser_type {
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"),
],
BrowserType::Wayfern => vec![
// Wayfern extracts to a directory with chromium executable
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("wayfern"),
// Subdirectory paths (tar.xz may extract to a subdirectory)
install_dir.join("wayfern").join("chromium"),
install_dir.join("wayfern").join("chrome"),
install_dir.join("chrome-linux").join("chrome"),
@@ -418,22 +322,9 @@ mod linux {
// install_dir/<browser>/<binary>
// However, Firefox Developer tarballs often extract to a "firefox" subfolder
// rather than "firefox-developer". Support both layouts.
let browser_subdir = install_dir.join(browser_type.as_str());
let _browser_subdir = install_dir.join(browser_type.as_str());
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
vec![
// Preferred: executable inside a subdirectory named after the browser type
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
// Fallback: executable inside a generic "firefox" subdirectory
install_dir.join("firefox").join("firefox-bin"),
install_dir.join("firefox").join("firefox"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -454,36 +345,10 @@ mod linux {
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
let possible_executables = match browser_type {
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"),
],
BrowserType::Wayfern => vec![
// Wayfern extracts to a directory with chromium executable
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("wayfern"),
// Subdirectory paths
install_dir.join("wayfern").join("chromium"),
install_dir.join("wayfern").join("chrome"),
install_dir.join("chrome-linux").join("chrome"),
@@ -500,6 +365,7 @@ mod linux {
false
}
#[allow(dead_code)]
pub fn prepare_executable(executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Linux, ensure the executable has proper permissions
log::info!("Setting execute permissions for: {:?}", executable_path);
@@ -551,10 +417,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("browser")
if name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("browser")
{
return Ok(path);
}
@@ -571,30 +434,11 @@ mod windows {
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// On Windows, look for .exe files
let possible_paths = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
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"),
],
BrowserType::Wayfern => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("wayfern.exe"),
install_dir.join("bin").join("chromium.exe"),
// Subdirectory patterns
install_dir.join("wayfern").join("chromium.exe"),
install_dir.join("wayfern").join("chrome.exe"),
install_dir.join("chrome-win").join("chrome.exe"),
@@ -618,18 +462,14 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.contains("chromium")
|| name.contains("brave")
|| name.contains("chrome")
|| name.contains("wayfern")
{
if name.contains("chromium") || name.contains("chrome") || name.contains("wayfern") {
return Ok(path);
}
}
}
}
Err("Chromium/Brave/Wayfern executable not found in Windows installation directory".into())
Err("Chromium/Wayfern executable not found in Windows installation directory".into())
}
pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool {
@@ -657,10 +497,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("browser")
if name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("browser")
{
return true;
}
@@ -674,30 +511,11 @@ mod windows {
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// On Windows, check for .exe files
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
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"),
],
BrowserType::Wayfern => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("wayfern.exe"),
install_dir.join("bin").join("chromium.exe"),
// Subdirectory patterns
install_dir.join("wayfern").join("chromium.exe"),
install_dir.join("wayfern").join("chrome.exe"),
install_dir.join("chrome-win").join("chrome.exe"),
@@ -722,11 +540,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.contains("chromium")
|| name.contains("brave")
|| name.contains("chrome")
|| name.contains("wayfern")
{
if name.contains("chromium") || name.contains("chrome") || name.contains("wayfern") {
return true;
}
}
@@ -736,236 +550,13 @@ mod windows {
false
}
#[allow(dead_code)]
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Windows, no special preparation needed
Ok(())
}
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_firefox_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_firefox_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_firefox_executable_path(install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
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()];
// 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 when remote debugging to avoid conflicts with existing instances
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Firefox-based browsers use profile directory and user.js for proxy configuration
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
log::info!("Firefox browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&browser_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
log::info!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
// Chromium-based browsers (Chromium, Brave)
pub struct ChromiumBrowser {
#[allow(dead_code)]
browser_type: BrowserType,
}
impl ChromiumBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for ChromiumBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_chromium_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_chromium_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_chromium_executable_path(install_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
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),
"--no-default-browser-check".to_string(),
"--disable-background-mode".to_string(),
"--disable-component-update".to_string(),
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
// Disable quit confirmation and session restore prompts
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
// Disable QUIC/HTTP3 to ensure traffic goes through HTTP proxy
"--disable-quic".to_string(),
];
// Add remote debugging if requested
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 {
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
));
}
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
log::info!("Chromium browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_chromium_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
log::info!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
pub struct CamoufoxBrowser;
impl CamoufoxBrowser {
@@ -1175,10 +766,6 @@ impl BrowserFactory {
pub fn create_browser(&self, browser_type: BrowserType) -> Box<dyn Browser> {
match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
Box::new(FirefoxBrowser::new(browser_type))
}
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
BrowserType::Wayfern => Box::new(WayfernBrowser::new()),
}
@@ -1272,35 +859,10 @@ mod tests {
#[test]
fn test_browser_type_conversions() {
// Test as_str
assert_eq!(BrowserType::Firefox.as_str(), "firefox");
assert_eq!(BrowserType::FirefoxDeveloper.as_str(), "firefox-developer");
assert_eq!(BrowserType::Chromium.as_str(), "chromium");
assert_eq!(BrowserType::Brave.as_str(), "brave");
assert_eq!(BrowserType::Zen.as_str(), "zen");
assert_eq!(BrowserType::Camoufox.as_str(), "camoufox");
assert_eq!(BrowserType::Wayfern.as_str(), "wayfern");
// Test from_str - use expect with descriptive messages instead of unwrap
assert_eq!(
BrowserType::from_str("firefox").expect("firefox should be valid"),
BrowserType::Firefox
);
assert_eq!(
BrowserType::from_str("firefox-developer").expect("firefox-developer should be valid"),
BrowserType::FirefoxDeveloper
);
assert_eq!(
BrowserType::from_str("chromium").expect("chromium should be valid"),
BrowserType::Chromium
);
assert_eq!(
BrowserType::from_str("brave").expect("brave should be valid"),
BrowserType::Brave
);
assert_eq!(
BrowserType::from_str("zen").expect("zen should be valid"),
BrowserType::Zen
);
// Test from_str
assert_eq!(
BrowserType::from_str("camoufox").expect("camoufox should be valid"),
BrowserType::Camoufox
@@ -1320,25 +882,25 @@ mod tests {
let empty_result = BrowserType::from_str("");
assert!(empty_result.is_err(), "Empty string should return error");
let case_sensitive_result = BrowserType::from_str("Firefox");
assert!(
case_sensitive_result.is_err(),
"Case sensitive check should fail"
BrowserType::from_str("firefox").is_err(),
"Removed browser types should return error"
);
assert!(
BrowserType::from_str("chromium").is_err(),
"Removed browser types should return error"
);
}
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote for normal launch)
let browser = FirefoxBrowser::new(BrowserType::Firefox);
fn test_camoufox_launch_args() {
let browser = CamoufoxBrowser::new();
let args = browser
.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 for normal launch"
);
.expect("Failed to create launch args for Camoufox");
assert!(args.contains(&"-profile".to_string()));
assert!(args.contains(&"/path/to/profile".to_string()));
assert!(args.contains(&"-no-remote".to_string()));
let args = browser
.create_launch_args(
@@ -1348,40 +910,20 @@ mod tests {
None,
false,
)
.expect("Failed to create launch args for Firefox with URL");
assert_eq!(
args,
vec!["-profile", "/path/to/profile", "https://example.com"]
);
.expect("Failed to create launch args for Camoufox with URL");
assert!(args.contains(&"https://example.com".to_string()));
// Test Firefox with remote debugging (should use -no-remote)
// Test with remote debugging
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 Zen Browser (no special flags without remote debugging)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Zen Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
.expect("Failed to create launch args for Camoufox with remote debugging");
assert!(args.contains(&"--start-debugger-server".to_string()));
assert!(args.contains(&"9222".to_string()));
// 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");
.expect("Failed to create launch args for Camoufox headless");
assert!(
args.contains(&"--headless".to_string()),
"Browser should include headless flag when requested"
@@ -1389,30 +931,27 @@ mod tests {
}
#[test]
fn test_chromium_launch_args() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
fn test_wayfern_launch_args() {
let browser = WayfernBrowser::new();
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Chromium");
.expect("Failed to create launch args for Wayfern");
// Test that basic required arguments are present
assert!(
args.contains(&"--user-data-dir=/path/to/profile".to_string()),
"Chromium args should contain user-data-dir"
"Wayfern args should contain user-data-dir"
);
assert!(
args.contains(&"--no-default-browser-check".to_string()),
"Chromium args should contain no-default-browser-check"
"Wayfern args should contain no-default-browser-check"
);
// Test that automatic update disabling arguments are present
assert!(
args.contains(&"--disable-background-mode".to_string()),
"Chromium args should contain disable-background-mode"
"Wayfern args should contain disable-background-mode"
);
assert!(
args.contains(&"--disable-component-update".to_string()),
"Chromium args should contain disable-component-update"
"Wayfern args should contain disable-component-update"
);
let args_with_url = browser
@@ -1423,13 +962,11 @@ mod tests {
None,
false,
)
.expect("Failed to create launch args for Chromium with URL");
.expect("Failed to create launch args for Wayfern with URL");
assert!(
args_with_url.contains(&"https://example.com".to_string()),
"Chromium args should contain the URL"
"Wayfern args should contain the URL"
);
// Verify URL is at the end
assert_eq!(
args_with_url.last().expect("Args should not be empty"),
"https://example.com"
@@ -1438,23 +975,19 @@ mod tests {
// 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");
.expect("Failed to create launch args for Wayfern 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"
"Wayfern args should contain remote debugging port"
);
// 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");
.expect("Failed to create launch args for Wayfern headless");
assert!(
args_headless.contains(&"--headless".to_string()),
"Chromium args should contain headless flag when requested"
args_headless.contains(&"--headless=new".to_string()),
"Wayfern args should contain headless flag when requested"
);
}
@@ -1491,26 +1024,21 @@ mod tests {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
let browser_dir = binaries_dir.join("firefox").join("139.0");
// Create a mock Camoufox browser installation
let browser_dir = binaries_dir.join("camoufox").join("135.0.1");
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
#[cfg(target_os = "macos")]
{
// Create a mock .app directory for macOS
let app_dir = browser_dir.join("Firefox.app");
fs::create_dir_all(&app_dir).expect("Failed to create Firefox.app directory");
let app_dir = browser_dir.join("Camoufox.app");
fs::create_dir_all(&app_dir).expect("Failed to create Camoufox.app directory");
}
#[cfg(target_os = "linux")]
{
// Create a mock firefox subdirectory and executable for Linux
let firefox_subdir = browser_dir.join("firefox");
fs::create_dir_all(&firefox_subdir).expect("Failed to create firefox subdirectory");
let executable_path = firefox_subdir.join("firefox");
let executable_path = browser_dir.join("camoufox");
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path
.metadata()
@@ -1523,67 +1051,62 @@ mod tests {
#[cfg(target_os = "windows")]
{
// Create a mock firefox.exe for Windows
let executable_path = browser_dir.join("firefox.exe");
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
}
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert!(browser.is_version_downloaded("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.0", binaries_dir));
let browser = CamoufoxBrowser::new();
assert!(browser.is_version_downloaded("135.0.1", binaries_dir));
assert!(!browser.is_version_downloaded("999.0", binaries_dir));
// Test with Chromium browser with new path structure
let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).expect("Failed to create chromium directory");
// Test with Wayfern browser
let wayfern_dir = binaries_dir.join("wayfern").join("1.0.0");
fs::create_dir_all(&wayfern_dir).expect("Failed to create wayfern directory");
#[cfg(target_os = "macos")]
{
let chromium_app_dir = chromium_dir.join("Chromium.app");
fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS"))
let wayfern_app_dir = wayfern_dir.join("Chromium.app");
fs::create_dir_all(wayfern_app_dir.join("Contents").join("MacOS"))
.expect("Failed to create Chromium.app structure");
// Create a mock executable
let executable_path = chromium_app_dir
let executable_path = wayfern_app_dir
.join("Contents")
.join("MacOS")
.join("Chromium");
fs::write(&executable_path, "mock executable")
.expect("Failed to write mock Chromium executable");
.expect("Failed to write mock Wayfern executable");
}
#[cfg(target_os = "linux")]
{
// Create a mock chromium executable for Linux
let executable_path = chromium_dir.join("chromium");
let executable_path = wayfern_dir.join("chromium");
fs::write(&executable_path, "mock executable")
.expect("Failed to write mock chromium executable");
.expect("Failed to write mock wayfern executable");
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path
.metadata()
.expect("Failed to get chromium metadata")
.expect("Failed to get wayfern metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&executable_path, permissions)
.expect("Failed to set chromium permissions");
.expect("Failed to set wayfern permissions");
}
#[cfg(target_os = "windows")]
{
// Create a mock chromium.exe for Windows
let executable_path = chromium_dir.join("chromium.exe");
let executable_path = wayfern_dir.join("chromium.exe");
fs::write(&executable_path, "mock executable").expect("Failed to write mock chromium.exe");
}
let chromium_browser = ChromiumBrowser::new(BrowserType::Chromium);
let wayfern_browser = WayfernBrowser::new();
assert!(
chromium_browser.is_version_downloaded("1465660", binaries_dir),
"Chromium version should be detected as downloaded"
wayfern_browser.is_version_downloaded("1.0.0", binaries_dir),
"Wayfern version should be detected as downloaded"
);
assert!(
!chromium_browser.is_version_downloaded("1465661", binaries_dir),
"Non-existent Chromium version should not be detected as downloaded"
!wayfern_browser.is_version_downloaded("9.9.9", binaries_dir),
"Non-existent Wayfern version should not be detected as downloaded"
);
}
@@ -1593,28 +1116,28 @@ mod tests {
let binaries_dir = temp_dir.path();
// Create browser directory but no proper executable structure
let browser_dir = binaries_dir.join("firefox").join("139.0");
let browser_dir = binaries_dir.join("camoufox").join("135.0.1");
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
// Create some other files but no proper executable structure
fs::write(browser_dir.join("readme.txt"), "Some content").expect("Failed to write readme file");
let browser = FirefoxBrowser::new(BrowserType::Firefox);
let browser = CamoufoxBrowser::new();
assert!(
!browser.is_version_downloaded("139.0", binaries_dir),
"Firefox version should not be detected without proper executable structure"
!browser.is_version_downloaded("135.0.1", binaries_dir),
"Camoufox version should not be detected without proper executable structure"
);
}
#[test]
fn test_browser_type_clone_and_debug() {
let browser_type = BrowserType::Firefox;
let browser_type = BrowserType::Camoufox;
let cloned = browser_type.clone();
assert_eq!(browser_type, cloned);
// Test Debug trait
let debug_str = format!("{browser_type:?}");
assert!(debug_str.contains("Firefox"));
assert!(debug_str.contains("Camoufox"));
}
#[test]
+132 -438
View File
@@ -1,4 +1,4 @@
use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::browser::ProxySettings;
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
@@ -39,14 +39,37 @@ impl BrowserRunner {
}
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
/// then resolve the proxy settings.
async fn resolve_proxy_with_refresh(&self, proxy_id: Option<&String>) -> Option<ProxySettings> {
let proxy_id = proxy_id?;
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
/// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure.
async fn resolve_proxy_with_refresh(
&self,
proxy_id: Option<&String>,
profile_id: Option<&str>,
) -> Result<Option<ProxySettings>, String> {
let proxy_id = match proxy_id {
Some(id) => id,
None => return Ok(None),
};
// Handle dynamic proxies: fetch from URL at launch time
if PROXY_MANAGER.is_dynamic_proxy(proxy_id) {
log::info!("Fetching dynamic proxy settings for proxy {proxy_id}");
let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?;
return Ok(Some(settings));
}
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
CLOUD_AUTH.sync_cloud_proxy().await;
}
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
// For cloud-derived proxies, inject profile-specific sid for sticky sessions
if let Some(pid) = profile_id {
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
return Ok(PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid));
}
}
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
/// Get the executable path for a browser profile
@@ -88,9 +111,9 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
_local_proxy_settings: Option<&ProxySettings>,
remote_debugging_port: Option<u16>,
headless: bool,
_headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Camoufox profiles using CamoufoxManager
if profile.browser == "camoufox" {
@@ -106,8 +129,9 @@ impl BrowserRunner {
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.await;
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -364,8 +388,9 @@ impl BrowserRunner {
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.await;
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -521,6 +546,7 @@ impl BrowserRunner {
proxy_url,
profile.ephemeral,
&extension_paths,
remote_debugging_port,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
@@ -602,248 +628,12 @@ impl BrowserRunner {
return Ok(updated_profile);
}
// Create browser instance
let browser_type = BrowserType::from_str(&profile.browser)
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
let browser = create_browser(browser_type.clone());
// Get executable path using common helper
let executable_path = self
.get_browser_executable_path(profile)
.expect("Failed to get executable path");
log::info!("Executable path: {executable_path:?}");
// Prepare the executable (set permissions, etc.)
if let Err(e) = browser.prepare_executable(&executable_path) {
log::warn!("Warning: Failed to prepare executable: {e}");
// Continue anyway, the error might not be critical
}
// Refresh cloud proxy credentials if needed before resolving
let _stored_proxy_settings = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.await;
// Use provided local proxy for Chromium-based browsers launch arguments
let proxy_for_launch_args: Option<&ProxySettings> = local_proxy_settings;
// Get profile data path and launch arguments
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let browser_args = browser
.create_launch_args(
&profile_data_path.to_string_lossy(),
proxy_for_launch_args,
url,
remote_debugging_port,
headless,
)
.expect("Failed to create launch arguments");
// Launch browser using platform-specific method
let child = {
#[cfg(target_os = "macos")]
{
platform_browser::macos::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "windows")]
{
platform_browser::windows::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "linux")]
{
platform_browser::linux::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
return Err("Unsupported platform for browser launching".into());
}
};
let launcher_pid = child.id();
log::info!(
"Launched browser with launcher PID: {} for profile: {} (ID: {})",
launcher_pid,
profile.name,
profile.id
);
// On macOS, when launching via `open -a`, the child PID is the `open` helper.
// Resolve and store the actual browser PID for all browser types.
let actual_pid = {
#[cfg(target_os = "macos")]
{
// Give the browser a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
let system = System::new_all();
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_data_path_str = profile_data_path.to_string_lossy();
let mut resolved_pid = launcher_pid;
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
// Determine if this process matches the intended browser type
let exe_name_lower = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"firefox" => {
exe_name_lower.contains("firefox")
&& !exe_name_lower.contains("developer")
&& !exe_name_lower.contains("camoufox")
}
"firefox-developer" => {
// More flexible detection for Firefox Developer Edition
(exe_name_lower.contains("firefox") && exe_name_lower.contains("developer"))
|| (exe_name_lower.contains("firefox")
&& cmd.iter().any(|arg| {
let arg_str = arg.to_str().unwrap_or("");
arg_str.contains("Developer")
|| arg_str.contains("developer")
|| arg_str.contains("FirefoxDeveloperEdition")
|| arg_str.contains("firefox-developer")
}))
|| exe_name_lower == "firefox" // Firefox Developer might just show as "firefox"
}
"zen" => exe_name_lower.contains("zen"),
"chromium" => exe_name_lower.contains("chromium") || exe_name_lower.contains("chrome"),
"brave" => exe_name_lower.contains("brave") || exe_name_lower.contains("Brave"),
_ => false,
};
if !is_correct_browser {
continue;
}
// Check for profile path match
let profile_path_match = if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
// Firefox-based browsers: look for -profile argument followed by path
let mut found_profile_arg = false;
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
if arg_str == "-profile" && i + 1 < cmd.len() {
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
if next_arg == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
// Also check for combined -profile=path format
if arg_str == format!("-profile={profile_data_path_str}") {
found_profile_arg = true;
break;
}
// Check if the argument is the profile path directly
if arg_str == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
found_profile_arg
} else {
// Chromium-based browsers: look for --user-data-dir argument
cmd.iter().any(|s| {
if let Some(arg) = s.to_str() {
arg == format!("--user-data-dir={profile_data_path_str}")
|| arg == profile_data_path_str
} else {
false
}
})
};
if profile_path_match {
let pid_u32 = pid.as_u32();
if pid_u32 != launcher_pid {
resolved_pid = pid_u32;
break;
}
}
}
resolved_pid
}
#[cfg(not(target_os = "macos"))]
{
launcher_pid
}
};
// Update profile with process info and save
let mut updated_profile = profile.clone();
updated_profile.process_id = Some(actual_pid);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
self.save_process_info(&updated_profile)?;
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.profile_manager.list_profiles().unwrap_or_default());
});
// Apply proxy settings if needed (for Firefox-based browsers)
if profile.proxy_id.is_some()
&& matches!(
browser_type,
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen
)
{
// Proxy settings for Firefox-based browsers are applied via user.js file
// which is already handled in the profile creation process
}
log::info!(
"Emitting profile events for successful launch: {} (ID: {})",
updated_profile.name,
updated_profile.id
);
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
// Emit minimal running changed event to frontend with a small delay to ensure UI consistency
#[derive(Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: updated_profile.id.to_string(),
is_running: updated_profile.process_id.is_some(),
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
"Successfully emitted profile-running-changed event for {}: running={}",
updated_profile.name,
payload.is_running
);
}
Ok(updated_profile)
Err(format!("Unsupported browser type: {}", profile.browser).into())
}
pub async fn open_url_in_existing_browser(
&self,
app_handle: tauri::AppHandle,
_app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: &str,
_internal_proxy_settings: Option<&ProxySettings>,
@@ -937,134 +727,7 @@ impl BrowserRunner {
}
}
// Use the comprehensive browser status check for non-camoufox/wayfern browsers
let is_running = self
.check_browser_status(app_handle.clone(), profile)
.await?;
if !is_running {
return Err("Browser is not running".into());
}
// Get the updated profile with current PID
let profiles = self
.profile_manager
.list_profiles()
.expect("Failed to list profiles");
let updated_profile = profiles
.into_iter()
.find(|p| p.id == profile.id)
.unwrap_or_else(|| profile.clone());
// Ensure we have a valid process ID
if updated_profile.process_id.is_none() {
return Err("No valid process ID found for the browser".into());
}
let browser_type = BrowserType::from_str(&updated_profile.browser)
.map_err(|_| format!("Invalid browser type: {}", updated_profile.browser))?;
// Get browser directory for all platforms - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&updated_profile.browser);
browser_dir.push(&updated_profile.version);
match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
#[cfg(target_os = "macos")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::macos::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "windows")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::windows::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "linux")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::linux::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
BrowserType::Camoufox => {
// Camoufox URL opening is handled differently
Err("URL opening in existing Camoufox instance is not supported".into())
}
BrowserType::Wayfern => {
// Wayfern URL opening is handled differently
Err("URL opening in existing Wayfern instance is not supported".into())
}
BrowserType::Chromium | BrowserType::Brave => {
#[cfg(target_os = "macos")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::macos::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "windows")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::windows::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "linux")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::linux::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
}
Err(format!("Unsupported browser type: {}", profile.browser).into())
}
pub async fn launch_browser_with_debugging(
@@ -1077,10 +740,11 @@ impl BrowserRunner {
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches
// Determine upstream proxy if configured; otherwise use DIRECT
let upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Refresh cloud proxy credentials before resolving
let upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
@@ -1104,32 +768,6 @@ impl BrowserRunner {
let internal_proxy_settings = Some(internal_proxy.clone());
// Configure Firefox profiles to use local proxy
{
// For Firefox-based browsers, apply PAC/user.js to point to the local proxy
if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
// Provide a dummy upstream (ignored when internal proxy is provided)
let dummy_upstream = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: internal_proxy.port,
username: None,
password: None,
};
self
.profile_manager
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
}
let result = self
.launch_browser_internal(
app_handle.clone(),
@@ -1651,9 +1289,14 @@ impl BrowserRunner {
);
}
// Clear the process ID from the profile
// Clear the process ID from the profile and save immediately so that
// subsequent calls to update_profile_version (which re-reads from disk)
// see the cleared process_id.
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
self
.save_process_info(&updated_profile)
.map_err(|e| format!("Failed to update profile: {e}"))?;
// Check for pending updates and apply them for Camoufox profiles too
if let Ok(Some(pending_update)) = self
@@ -1667,7 +1310,6 @@ impl BrowserRunner {
pending_update.new_version
);
// Update the profile to the new version
match self.profile_manager.update_profile_version(
&app_handle,
&profile.id.to_string(),
@@ -1682,7 +1324,6 @@ impl BrowserRunner {
);
updated_profile = updated_profile_after_update;
// Remove the pending update from the auto updater state
if let Err(e) = self
.auto_updater
.dismiss_update_notification(&pending_update.id)
@@ -1696,14 +1337,19 @@ impl BrowserRunner {
profile.name,
e
);
// Continue with the original profile update (just clearing process_id)
}
}
}
self
.save_process_info(&updated_profile)
.map_err(|e| format!("Failed to update profile: {e}"))?;
// If no pending update was applied, check if a newer installed version exists
if updated_profile.version == profile.version {
if let Some(p) = self
.auto_updater
.update_profile_to_latest_installed(&app_handle, &updated_profile)
{
updated_profile = p;
}
}
log::info!(
"Emitting profile events for successful Camoufox kill: {}",
@@ -1983,9 +1629,14 @@ impl BrowserRunner {
);
}
// Clear the process ID from the profile
// Clear the process ID from the profile and save immediately so that
// subsequent calls to update_profile_version (which re-reads from disk)
// see the cleared process_id.
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
self
.save_process_info(&updated_profile)
.map_err(|e| format!("Failed to update profile: {e}"))?;
// Check for pending updates and apply them
if let Ok(Some(pending_update)) = self
@@ -2030,9 +1681,15 @@ impl BrowserRunner {
}
}
self
.save_process_info(&updated_profile)
.map_err(|e| format!("Failed to update profile: {e}"))?;
// If no pending update was applied, check if a newer installed version exists
if updated_profile.version == profile.version {
if let Some(p) = self
.auto_updater
.update_profile_to_latest_installed(&app_handle, &updated_profile)
{
updated_profile = p;
}
}
log::info!(
"Emitting profile events for successful Wayfern kill: {}",
@@ -2258,9 +1915,14 @@ impl BrowserRunner {
profile.id
);
// Clear the process ID from the profile
// Clear the process ID from the profile and save immediately so that
// subsequent calls to update_profile_version (which re-reads from disk)
// see the cleared process_id.
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
self
.save_process_info(&updated_profile)
.map_err(|e| format!("Failed to update profile: {e}"))?;
// Check for pending updates and apply them
if let Ok(Some(pending_update)) = self
@@ -2274,7 +1936,6 @@ impl BrowserRunner {
pending_update.new_version
);
// Update the profile to the new version
match self.profile_manager.update_profile_version(
&app_handle,
&profile.id.to_string(),
@@ -2289,7 +1950,6 @@ impl BrowserRunner {
);
updated_profile = updated_profile_after_update;
// Remove the pending update from the auto updater state
if let Err(e) = self
.auto_updater
.dismiss_update_notification(&pending_update.id)
@@ -2303,14 +1963,19 @@ impl BrowserRunner {
profile.name,
e
);
// Continue with the original profile update (just clearing process_id)
}
}
}
self
.save_process_info(&updated_profile)
.map_err(|e| format!("Failed to update profile: {e}"))?;
// If no pending update was applied, check if a newer installed version exists
if updated_profile.version == profile.version {
if let Some(p) = self
.auto_updater
.update_profile_to_latest_installed(&app_handle, &updated_profile)
{
updated_profile = p;
}
}
log::info!(
"Emitting profile events for successful kill: {}",
@@ -2492,11 +2157,14 @@ impl BrowserRunner {
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot open URL with profile '{}': it was created on {} and is not supported on this system",
"Cannot open URL with profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
@@ -2528,17 +2196,27 @@ pub async fn launch_browser_profile(
profile.id
);
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
// Notify sync scheduler that profile is now running
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler
.mark_profile_running(&profile.id.to_string())
.await;
}
let browser_runner = BrowserRunner::instance();
// Store the internal proxy settings for passing to launch_browser
@@ -2569,10 +2247,13 @@ pub async fn launch_browser_profile(
// This ensures all traffic goes through the local proxy for monitoring and future features
if profile.browser != "camoufox" && profile.browser != "wayfern" {
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
let mut upstream_proxy = profile_for_launch
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Refresh cloud proxy credentials and inject profile-specific sid
let mut upstream_proxy = BrowserRunner::instance()
.resolve_proxy_with_refresh(
profile_for_launch.proxy_id.as_ref(),
Some(&profile_for_launch.id.to_string()),
)
.await?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -2746,6 +2427,16 @@ pub async fn kill_browser_profile(
// Release team lock if applicable
crate::team_lock::release_team_lock_if_needed(&profile).await;
// Notify sync scheduler that profile stopped and queue sync
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let pid = profile.id.to_string();
scheduler.mark_profile_stopped(&pid).await;
if profile.is_sync_enabled() {
log::info!("Profile '{}' killed, queuing sync", profile.name);
scheduler.queue_profile_sync(pid).await;
}
}
// Auto-update non-running profiles and cleanup unused binaries
let browser_for_update = profile.browser.clone();
let app_handle_for_update = app_handle.clone();
@@ -2825,11 +2516,14 @@ pub async fn launch_browser_profile_with_debugging(
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
+23 -3
View File
@@ -2,7 +2,7 @@
//!
//! Converts fingerprints to Camoufox configuration format and builds launch options.
use rand::Rng;
use rand::RngExt;
use serde_yaml;
use std::collections::HashMap;
use std::path::Path;
@@ -425,8 +425,28 @@ impl CamoufoxConfigBuilder {
/// Build the complete Camoufox launch configuration with async geolocation support.
/// This method should be used when geoip option is set to Auto.
pub async fn build_async(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
// Get proxy URL for IP detection if set
let proxy_url = self.proxy.as_ref().map(|p| p.server.clone());
// Get full proxy URL (with credentials) for IP detection
let proxy_url = self.proxy.as_ref().map(|p| {
if let (Some(user), Some(pass)) = (&p.username, &p.password) {
// Reconstruct URL with credentials: scheme://user:pass@host:port
if let Ok(mut parsed) = url::Url::parse(&p.server) {
let _ = parsed.set_username(user);
let _ = parsed.set_password(Some(pass));
parsed.to_string()
} else {
p.server.clone()
}
} else if let Some(user) = &p.username {
if let Ok(mut parsed) = url::Url::parse(&p.server) {
let _ = parsed.set_username(user);
parsed.to_string()
} else {
p.server.clone()
}
} else {
p.server.clone()
}
});
let geoip_option = self.geoip.clone();
let block_webrtc = self.block_webrtc;
@@ -2,7 +2,7 @@
//!
//! Implements weighted random sampling from conditional probability distributions.
use rand::Rng;
use rand::RngExt;
use serde::Deserialize;
use std::collections::HashMap;
+1 -1
View File
@@ -9,7 +9,7 @@ use directories::BaseDirs;
use maxminddb::{geoip2, Reader};
use quick_xml::events::Event;
use quick_xml::Reader as XmlReader;
use rand::Rng;
use rand::RngExt;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Samples realistic WebGL configurations based on OS-specific probability distributions.
use rand::Rng;
use rand::RngExt;
use rusqlite::{Connection, Result as SqliteResult};
use std::collections::HashMap;
use std::io::Write;
+64 -81
View File
@@ -56,6 +56,7 @@ pub struct CamoufoxLaunchResult {
#[serde(alias = "profile_path")]
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
}
#[derive(Debug)]
@@ -65,6 +66,7 @@ struct CamoufoxInstance {
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
}
struct CamoufoxManagerInner {
@@ -88,6 +90,33 @@ impl CamoufoxManager {
&CAMOUFOX_LAUNCHER
}
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
#[allow(dead_code)]
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for instance in inner.instances.values() {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
return instance.cdp_port;
}
}
}
None
}
pub fn get_profiles_dir(&self) -> PathBuf {
crate::app_dirs::profiles_dir()
}
@@ -239,6 +268,9 @@ impl CamoufoxManager {
.to_string(),
];
let cdp_port = Self::find_free_port().await?;
args.push(format!("--remote-debugging-port={cdp_port}"));
// Add URL if provided
if let Some(url) = url {
args.push("-new-tab".to_string());
@@ -294,6 +326,7 @@ impl CamoufoxManager {
process_id,
profile_path: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
let launch_result = CamoufoxLaunchResult {
@@ -301,6 +334,7 @@ impl CamoufoxManager {
processId: process_id,
profilePath: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
{
@@ -418,6 +452,7 @@ impl CamoufoxManager {
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
}));
}
}
@@ -428,7 +463,9 @@ impl CamoufoxManager {
// If not found in in-memory instances, scan system processes
// This handles the case where the app was restarted but Camoufox is still running
if let Some((pid, found_profile_path)) = self.find_camoufox_process_by_profile(&target_path) {
if let Some((pid, found_profile_path, cdp_port)) =
self.find_camoufox_process_by_profile(&target_path)
{
log::info!(
"Found running Camoufox process (PID: {}) for profile path via system scan",
pid
@@ -444,6 +481,7 @@ impl CamoufoxManager {
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
},
);
@@ -452,6 +490,7 @@ impl CamoufoxManager {
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
cdp_port,
}));
}
@@ -462,7 +501,7 @@ impl CamoufoxManager {
fn find_camoufox_process_by_profile(
&self,
target_path: &std::path::Path,
) -> Option<(u32, String)> {
) -> Option<(u32, String, Option<u16>)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
@@ -487,6 +526,10 @@ impl CamoufoxManager {
continue;
}
let mut matched = false;
let mut found_profile_path = None;
let mut cdp_port: Option<u16> = None;
// Check if the command line contains our profile path
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
@@ -498,15 +541,27 @@ impl CamoufoxManager {
.unwrap_or_else(|_| std::path::Path::new(next_arg).to_path_buf());
if cmd_path == target_path {
return Some((pid.as_u32(), next_arg.to_string()));
matched = true;
found_profile_path = Some(next_arg.to_string());
}
}
}
// Also check if the argument contains the profile path directly
if arg_str.contains(&*target_path_str) {
return Some((pid.as_u32(), target_path_str.to_string()));
if !matched && arg_str.contains(&*target_path_str) {
matched = true;
found_profile_path = Some(target_path_str.to_string());
}
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
cdp_port = port_val.parse().ok();
}
}
}
if matched {
if let Some(profile_path) = found_profile_path {
return Some((pid.as_u32(), profile_path, cdp_port));
}
}
}
@@ -557,9 +612,11 @@ impl CamoufoxManager {
/// Check if a Camoufox server is running with the given process ID
async fn is_server_running(&self, process_id: u32) -> bool {
// Check if the process is still running
use sysinfo::{Pid, System};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
let system = System::new_all();
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
if let Some(process) = system.process(Pid::from(process_id as usize)) {
// Check if this is actually a Camoufox process by looking at the command line
let cmd = process.cmd();
@@ -628,9 +685,6 @@ impl CamoufoxManager {
}
}
// Write search.json.mozlz4 with default search engines (DuckDuckGo + Google)
write_default_search_config(&profile_path);
self
.launch_camoufox(
&app_handle,
@@ -644,77 +698,6 @@ impl CamoufoxManager {
}
}
fn write_default_search_config(profile_path: &std::path::Path) {
let search_file = profile_path.join("search.json.mozlz4");
if search_file.exists() {
return;
}
let json = serde_json::json!({
"version": 6,
"engines": [
{
"_name": "DuckDuckGo",
"_isAppProvided": false,
"_metaData": { "order": 1 },
"_urls": [
{
"template": "https://duckduckgo.com/?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://duckduckgo.com/favicon.ico"
},
{
"_name": "Google",
"_isAppProvided": false,
"_metaData": { "order": 2 },
"_urls": [
{
"template": "https://www.google.com/search?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://www.google.com/favicon.ico"
}
],
"metaData": {
"useSavedOrder": false,
"defaultEngineId": "DuckDuckGo"
}
});
let json_bytes = match serde_json::to_vec(&json) {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("Failed to serialize search config: {e}");
return;
}
};
let magic = b"mozLz40\0";
let compressed = lz4_flex::block::compress_prepend_size(&json_bytes);
let mut output = Vec::with_capacity(magic.len() + compressed.len());
output.extend_from_slice(magic);
output.extend_from_slice(&compressed);
if let Err(e) = std::fs::write(&search_file, &output) {
log::warn!("Failed to write search.json.mozlz4: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
+284 -33
View File
@@ -81,6 +81,14 @@ struct SyncTokenResponse {
sync_token: String,
}
#[derive(Debug, Deserialize)]
struct WayfernTokenResponse {
token: String,
#[serde(rename = "expiresIn")]
#[allow(dead_code)]
expires_in: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationItem {
pub code: String,
@@ -105,6 +113,7 @@ pub struct CloudAuthManager {
client: Client,
state: Mutex<Option<CloudAuthState>>,
refresh_lock: tokio::sync::Mutex<()>,
wayfern_token: Mutex<Option<String>>,
}
lazy_static! {
@@ -118,6 +127,7 @@ impl CloudAuthManager {
client: Client::new(),
state: Mutex::new(state),
refresh_lock: tokio::sync::Mutex::new(()),
wayfern_token: Mutex::new(None),
}
}
@@ -578,6 +588,9 @@ impl CloudAuthManager {
}
pub async fn logout(&self) -> Result<(), String> {
// Clear wayfern token
self.clear_wayfern_token().await;
// Disconnect team lock manager
crate::team_lock::TEAM_LOCK.disconnect().await;
@@ -666,7 +679,7 @@ impl CloudAuthManager {
/// API call with 401 retry: if first attempt gets 401, refresh access token and retry once.
/// Uses refresh_lock to prevent concurrent token rotations from racing.
async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
pub async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
where
F: Fn(String) -> Fut + Send,
Fut: std::future::Future<Output = Result<T, String>> + Send,
@@ -697,11 +710,12 @@ impl CloudAuthManager {
/// Fetch proxy configuration from the cloud backend
async fn fetch_proxy_config(&self) -> Result<Option<CloudProxyConfigResponse>, String> {
// Check cached user state for proxy bandwidth
// Check cached user state for proxy bandwidth (subscription or extra)
{
let state = self.state.lock().await;
match &*state {
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {}
Some(auth)
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 => {}
_ => return Ok(None),
}
}
@@ -840,13 +854,13 @@ impl CloudAuthManager {
.await
}
/// Fetch state list for a country from the cloud backend
pub async fn fetch_states(&self, country: &str) -> Result<Vec<LocationItem>, String> {
/// Fetch region list for a country from the cloud backend
pub async fn fetch_regions(&self, country: &str) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/states?country={}",
"{CLOUD_API_URL}/api/proxy/locations/regions?country={}",
country
);
let client = reqwest::Client::new();
@@ -856,37 +870,40 @@ impl CloudAuthManager {
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch states: {e}"))?;
.map_err(|e| format!("Failed to fetch regions: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("States fetch failed ({status}): {body}"));
return Err(format!("Regions fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse states: {e}"))
.map_err(|e| format!("Failed to parse regions: {e}"))
}
})
.await
}
/// Fetch city list for a country+state from the cloud backend
/// Fetch city list for a country, optionally filtered by region
pub async fn fetch_cities(
&self,
country: &str,
state: &str,
region: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let state = state.to_string();
let region = region.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}&state={}",
country, state
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
let client = reqwest::Client::new();
async move {
let response = client
@@ -911,8 +928,108 @@ impl CloudAuthManager {
.await
}
/// Fetch ISP list for a country, optionally filtered by region and city
pub async fn fetch_isps(
&self,
country: &str,
region: Option<&str>,
city: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
let city = city.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/isps?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
if let Some(ref c) = city {
url.push_str(&format!("&city={}", c));
}
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch ISPs: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("ISPs fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse ISPs: {e}"))
}
})
.await
}
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.has_active_paid_subscription().await {
self.clear_wayfern_token().await;
return Ok(());
}
let token = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
let client = reqwest::Client::new();
async move {
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to request wayfern token: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Wayfern token request failed ({status}): {body}"));
}
let result: WayfernTokenResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse wayfern token response: {e}"))?;
Ok(result.token)
}
})
.await?;
let mut wt = self.wayfern_token.lock().await;
*wt = Some(token);
log::info!("Wayfern token acquired");
Ok(())
}
/// Get the current wayfern token, if any.
pub async fn get_wayfern_token(&self) -> Option<String> {
let wt = self.wayfern_token.lock().await;
wt.clone()
}
/// Clear the cached wayfern token.
pub async fn clear_wayfern_token(&self) {
let mut wt = self.wayfern_token.lock().await;
*wt = None;
}
/// Background loop that refreshes the sync token periodically
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
let mut wayfern_refresh_counter: u32 = 0;
loop {
tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes
@@ -920,6 +1037,8 @@ impl CloudAuthManager {
continue;
}
wayfern_refresh_counter += 1;
// Proactively refresh the access token if it's expired or expiring soon.
// This runs first so subsequent API calls use a fresh token.
if let Ok(Some(token)) = Self::load_access_token() {
@@ -961,6 +1080,18 @@ impl CloudAuthManager {
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
if wayfern_refresh_counter >= 60 {
wayfern_refresh_counter = 0;
if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
}
} else {
CLOUD_AUTH.clear_wayfern_token().await;
}
}
let _ = &app_handle; // keep app_handle alive
}
}
@@ -996,6 +1127,11 @@ pub async fn cloud_verify_otp(
Ok(None) => log::warn!("Sync token not available despite active subscription"),
Err(e) => log::error!("Failed to pre-fetch sync token after login: {e}"),
}
// Request wayfern token for paid users
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token after login: {e}");
}
}
// Sync cloud proxy after login
@@ -1037,6 +1173,9 @@ pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
}
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
crate::proxy_manager::PROXY_MANAGER.remove_cloud_proxies();
let _ = crate::events::emit_empty("cloud-auth-changed");
Ok(())
}
@@ -1046,33 +1185,59 @@ pub async fn cloud_has_active_subscription() -> Result<bool, String> {
Ok(CLOUD_AUTH.has_active_paid_subscription().await)
}
#[tauri::command]
pub async fn cloud_get_wayfern_token() -> Result<Option<String>, String> {
Ok(CLOUD_AUTH.get_wayfern_token().await)
}
#[tauri::command]
pub async fn cloud_refresh_wayfern_token() -> Result<Option<String>, String> {
CLOUD_AUTH.request_wayfern_token().await?;
Ok(CLOUD_AUTH.get_wayfern_token().await)
}
#[tauri::command]
pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_countries().await
}
#[tauri::command]
pub async fn cloud_get_states(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_states(&country).await
pub async fn cloud_get_regions(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_regions(&country).await
}
#[tauri::command]
pub async fn cloud_get_cities(country: String, state: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, &state).await
pub async fn cloud_get_cities(
country: String,
region: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await
}
#[tauri::command]
pub async fn cloud_get_isps(
country: String,
region: Option<String>,
city: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH
.fetch_isps(&country, region.as_deref(), city.as_deref())
.await
}
#[tauri::command]
pub async fn create_cloud_location_proxy(
name: String,
country: String,
state: Option<String>,
region: Option<String>,
city: Option<String>,
isp: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
// If no cloud proxy exists yet, attempt to sync it first
if !PROXY_MANAGER.has_cloud_proxy() {
CLOUD_AUTH.sync_cloud_proxy().await;
}
PROXY_MANAGER.create_cloud_location_proxy(name, country, state, city)
PROXY_MANAGER.create_cloud_location_proxy(name, country, region, city, isp)
}
#[derive(Debug, Serialize)]
@@ -1080,22 +1245,108 @@ pub struct CloudProxyUsage {
pub used_mb: i64,
pub limit_mb: i64,
pub remaining_mb: i64,
pub recurring_limit_mb: i64,
pub extra_limit_mb: i64,
}
#[derive(Debug, Deserialize)]
struct ProxyUsageResponse {
#[serde(rename = "usedMb")]
used_mb: i64,
#[serde(rename = "limitMb")]
limit_mb: i64,
#[serde(rename = "remainingMb")]
remaining_mb: i64,
#[serde(rename = "recurringLimitMb", default)]
recurring_limit_mb: i64,
#[serde(rename = "extraLimitMb", default)]
extra_limit_mb: i64,
}
#[tauri::command]
pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String> {
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {
let used = auth.user.proxy_bandwidth_used_mb;
let limit = auth.user.proxy_bandwidth_limit_mb;
Ok(Some(CloudProxyUsage {
used_mb: used,
limit_mb: limit,
remaining_mb: (limit - used).max(0),
}))
let (has_proxy, cached_recurring, cached_extra) = {
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth)
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 =>
{
(
true,
auth.user.proxy_bandwidth_limit_mb,
auth.user.proxy_bandwidth_extra_mb,
)
}
_ => return Ok(None),
}
};
if !has_proxy {
return Ok(None);
}
// Fetch live usage from the API
match CLOUD_AUTH
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/proxy/usage");
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch proxy usage: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Proxy usage API returned status {}",
response.status()
));
}
response
.json::<ProxyUsageResponse>()
.await
.map_err(|e| format!("Failed to parse proxy usage: {e}"))
}
})
.await
{
Ok(usage) => Ok(Some(CloudProxyUsage {
used_mb: usage.used_mb,
limit_mb: usage.limit_mb,
remaining_mb: usage.remaining_mb,
recurring_limit_mb: if usage.recurring_limit_mb > 0 {
usage.recurring_limit_mb
} else {
cached_recurring
},
extra_limit_mb: if usage.recurring_limit_mb > 0 {
usage.extra_limit_mb
} else {
cached_extra
},
})),
Err(e) => {
log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}");
// Fallback to cached values
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth) => {
let used = auth.user.proxy_bandwidth_used_mb;
let total = cached_recurring + cached_extra;
Ok(Some(CloudProxyUsage {
used_mb: used,
limit_mb: total,
remaining_mb: (total - used).max(0),
recurring_limit_mb: cached_recurring,
extra_limit_mb: cached_extra,
}))
}
_ => Ok(None),
}
}
_ => Ok(None),
}
}
@@ -1139,7 +1390,7 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
}
}
Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e);
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
+186 -25
View File
@@ -7,6 +7,112 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::AppHandle;
/// Chromium cookie encryption/decryption support.
/// On macOS: uses "Chromium Safe Storage" key from Keychain with PBKDF2 + AES-128-CBC.
/// On Linux: uses os_crypt_key file from profile directory with PBKDF2 + AES-128-CBC.
mod chrome_decrypt {
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
const PBKDF2_ITERATIONS: u32 = 1;
const KEY_LEN: usize = 16; // AES-128
const SALT: &[u8] = b"saltysalt";
const IV: [u8; 16] = [b' '; 16]; // 16 spaces
fn derive_key(password: &[u8]) -> [u8; KEY_LEN] {
let mut key = [0u8; KEY_LEN];
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password, SALT, PBKDF2_ITERATIONS, &mut key);
key
}
/// Get the encryption key for Chrome cookies.
/// Wayfern stores os_crypt_key as a file inside the profile's user-data-dir on all platforms.
/// On macOS/Linux the key is a base64 string used as PBKDF2 password.
/// On Windows the key is raw bytes (32 bytes) used directly.
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
let key_file = profile_data_path.join("os_crypt_key");
if let Ok(contents) = std::fs::read_to_string(&key_file) {
let contents = contents.trim();
if !contents.is_empty() {
return Some(derive_key(contents.as_bytes()));
}
}
// Fallback for macOS: try system Keychain (for profiles created before file-based keys)
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("security")
.args([
"find-generic-password",
"-w",
"-s",
"Chromium Safe Storage",
"-a",
"Chromium",
])
.output()
.ok()?;
if output.status.success() {
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !password.is_empty() {
return Some(derive_key(password.as_bytes()));
}
}
}
None
}
/// Decrypt a Chrome encrypted cookie value.
/// Chromium prefixes encrypted values with "v10" (macOS) or "v11" (Linux).
pub fn decrypt(encrypted: &[u8], key: &[u8; KEY_LEN]) -> Option<String> {
if encrypted.len() < 3 {
return None;
}
// Check for v10/v11 prefix
let prefix = &encrypted[..3];
if prefix != b"v10" && prefix != b"v11" {
return None;
}
let ciphertext = &encrypted[3..];
if ciphertext.is_empty() {
return Some(String::new());
}
let mut buf = ciphertext.to_vec();
let decrypted = Aes128CbcDec::new(key.into(), &IV.into())
.decrypt_padded_mut::<Pkcs7>(&mut buf)
.ok()?;
String::from_utf8(decrypted.to_vec()).ok()
}
/// Encrypt a cookie value in Chrome format (v10/v11 prefix + AES-128-CBC).
pub fn encrypt(plaintext: &str, key: &[u8; KEY_LEN]) -> Vec<u8> {
let pt = plaintext.as_bytes();
let block_size = 16usize;
// Allocate buffer with space for PKCS7 padding (up to one extra block)
let padded_len = pt.len() + (block_size - pt.len() % block_size);
let mut buf = vec![0u8; padded_len];
buf[..pt.len()].copy_from_slice(pt);
let encrypted = Aes128CbcEnc::new(key.into(), &IV.into())
.encrypt_padded_mut::<Pkcs7>(&mut buf, pt.len())
.expect("encryption buffer too small");
let mut result = Vec::with_capacity(3 + encrypted.len());
#[cfg(target_os = "macos")]
result.extend_from_slice(b"v10");
#[cfg(not(target_os = "macos"))]
result.extend_from_slice(b"v11");
result.extend_from_slice(encrypted);
result
}
}
/// Unified cookie representation that works across both browser types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedCookie {
@@ -77,6 +183,12 @@ impl CookieManager {
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
/// Get the Chrome cookie encryption key for a Wayfern profile
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
chrome_decrypt::get_encryption_key(&profile_data_path)
}
/// Get the cookie database path for a profile
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
@@ -155,31 +267,58 @@ impl CookieManager {
Ok(cookies)
}
/// Read cookies from a Chrome/Wayfern profile
fn read_chrome_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> {
/// Read cookies from a Chrome/Wayfern profile.
/// Handles encrypted cookies by decrypting encrypted_value using the profile's encryption key.
fn read_chrome_cookies(
db_path: &Path,
encryption_key: Option<&[u8; 16]>,
) -> Result<Vec<UnifiedCookie>, String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT name, value, host_key, path, expires_utc, is_secure,
is_httponly, samesite, creation_utc, last_access_utc
FROM cookies",
is_httponly, samesite, creation_utc, last_access_utc, encrypted_value
FROM cookies",
)
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
let cookies = stmt
.query_map([], |row| {
let name: String = row.get(0)?;
let plaintext_value: String = row.get(1)?;
let domain: String = row.get(2)?;
let path: String = row.get(3)?;
let expires_utc: i64 = row.get(4)?;
let is_secure: i32 = row.get(5)?;
let is_httponly: i32 = row.get(6)?;
let samesite: i32 = row.get(7)?;
let creation_utc: i64 = row.get(8)?;
let last_access_utc: i64 = row.get(9)?;
let encrypted_value: Vec<u8> = row.get(10)?;
// Use plaintext value if available, otherwise decrypt encrypted_value
let value = if !plaintext_value.is_empty() {
plaintext_value
} else if !encrypted_value.is_empty() {
encryption_key
.and_then(|key| chrome_decrypt::decrypt(&encrypted_value, key))
.unwrap_or_default()
} else {
String::new()
};
Ok(UnifiedCookie {
name: row.get(0)?,
value: row.get(1)?,
domain: row.get(2)?,
path: row.get(3)?,
expires: Self::chrome_time_to_unix(row.get(4)?),
is_secure: row.get::<_, i32>(5)? != 0,
is_http_only: row.get::<_, i32>(6)? != 0,
same_site: row.get(7)?,
creation_time: Self::chrome_time_to_unix(row.get(8)?),
last_accessed: Self::chrome_time_to_unix(row.get(9)?),
name,
value,
domain,
path,
expires: Self::chrome_time_to_unix(expires_utc),
is_secure: is_secure != 0,
is_http_only: is_httponly != 0,
same_site: samesite,
creation_time: Self::chrome_time_to_unix(creation_utc),
last_accessed: Self::chrome_time_to_unix(last_access_utc),
})
})
.map_err(|e| format!("Failed to query cookies: {e}"))?
@@ -256,10 +395,12 @@ impl CookieManager {
Ok((copied, replaced))
}
/// Write cookies to a Chrome/Wayfern profile
/// Write cookies to a Chrome/Wayfern profile.
/// If an encryption key is available, stores cookies encrypted in encrypted_value.
fn write_chrome_cookies(
db_path: &Path,
cookies: &[UnifiedCookie],
encryption_key: Option<&[u8; 16]>,
) -> Result<(usize, usize), String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
@@ -272,6 +413,12 @@ impl CookieManager {
.as_secs() as i64;
for cookie in cookies {
// Prepare value/encrypted_value based on whether we have an encryption key
let (value_str, encrypted_bytes): (&str, Vec<u8>) = match encryption_key {
Some(key) => ("", chrome_decrypt::encrypt(&cookie.value, key)),
None => (cookie.value.as_str(), Vec::new()),
};
let existing: Option<i64> = conn
.query_row(
"SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3",
@@ -283,11 +430,12 @@ impl CookieManager {
if existing.is_some() {
conn
.execute(
"UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3,
is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7
WHERE host_key = ?8 AND name = ?9 AND path = ?10",
"UPDATE cookies SET value = ?1, encrypted_value = ?2, expires_utc = ?3, is_secure = ?4,
is_httponly = ?5, samesite = ?6, last_access_utc = ?7, last_update_utc = ?8
WHERE host_key = ?9 AND name = ?10 AND path = ?11",
params![
&cookie.value,
value_str,
encrypted_bytes,
Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32,
cookie.is_http_only as i32,
@@ -308,12 +456,13 @@ impl CookieManager {
path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires,
is_persistent, priority, samesite, source_scheme, source_port, source_type,
has_cross_site_ancestor, last_update_utc)
VALUES (?1, ?2, '', ?3, ?4, X'', ?5, ?6, ?7, ?8, ?9, 1, 1, 1, ?10, 2, -1, 0, 0, ?11)",
VALUES (?1, ?2, '', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, 1, 1, ?11, 2, -1, 0, 0, ?12)",
params![
Self::unix_to_chrome_time(cookie.creation_time),
&cookie.domain,
&cookie.name,
&cookie.value,
value_str,
encrypted_bytes,
&cookie.path,
Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32,
@@ -348,7 +497,10 @@ impl CookieManager {
let cookies = match profile.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&db_path)?,
"wayfern" => Self::read_chrome_cookies(&db_path)?,
"wayfern" => {
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
Self::read_chrome_cookies(&db_path, key.as_ref())?
}
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
};
@@ -401,7 +553,10 @@ impl CookieManager {
let source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?;
let all_cookies = match source.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&source_db_path)?,
"wayfern" => Self::read_chrome_cookies(&source_db_path)?,
"wayfern" => {
let key = Self::get_chrome_encryption_key(source, &profiles_dir);
Self::read_chrome_cookies(&source_db_path, key.as_ref())?
}
_ => return Err(format!("Unsupported browser type: {}", source.browser)),
};
@@ -468,7 +623,10 @@ impl CookieManager {
let write_result = match target.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy),
"wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy),
"wayfern" => {
let key = Self::get_chrome_encryption_key(target, &profiles_dir);
Self::write_chrome_cookies(&target_db_path, &cookies_to_copy, key.as_ref())
}
_ => {
results.push(CookieCopyResult {
target_profile_id: target_id.clone(),
@@ -733,7 +891,10 @@ impl CookieManager {
let write_result = match profile.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
"wayfern" => {
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
Self::write_chrome_cookies(&db_path, &cookies, key.as_ref())
}
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
};
+112 -515
View File
@@ -56,7 +56,7 @@ impl Downloader {
}
#[cfg(test)]
pub fn new_with_api_client(_api_client: ApiClient) -> Self {
pub fn new_for_test() -> Self {
Self {
client: Client::new(),
api_client: ApiClient::instance(),
@@ -67,87 +67,53 @@ impl Downloader {
}
}
#[cfg(test)]
pub async fn download_file(
&self,
download_url: &str,
dest_path: &Path,
filename: &str,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_path.join(filename);
let response = self
.client
.get(download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()).into());
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&file_path)?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
}
Ok(file_path)
}
/// Resolve the actual download URL for browsers that need dynamic asset resolution
pub async fn resolve_download_url(
&self,
browser_type: BrowserType,
version: &str,
download_info: &DownloadInfo,
_download_info: &DownloadInfo,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual platform-specific asset
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
.await?;
// Find the release with the matching version
let release = releases
.iter()
.find(|r| {
r.tag_name == version || r.tag_name == format!("v{}", version.trim_start_matches('v'))
})
.ok_or(format!("Brave version {version} not found"))?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset based on platform and architecture
let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No compatible asset found for Brave version {version} on {os}/{arch}"
))?;
Ok(asset_url)
}
BrowserType::Zen => {
// For Zen, verify the asset exists and handle different naming patterns
let releases = match self.api_client.fetch_zen_releases_with_caching(true).await {
Ok(releases) => releases,
Err(e) => {
log::error!("Failed to fetch Zen releases: {e}");
return Err(format!("Failed to fetch Zen releases from GitHub API: {e}. This might be due to GitHub API rate limiting or network issues. Please try again later.").into());
}
};
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or_else(|| {
format!(
"Zen version {} not found. Available versions: {}",
version,
releases
.iter()
.take(5)
.map(|r| r.tag_name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or_else(|| {
let available_assets: Vec<&str> =
release.assets.iter().map(|a| a.name.as_str()).collect();
format!(
"No compatible asset found for Zen version {} on {}/{}. Available assets: {}",
version,
os,
arch,
available_assets.join(", ")
)
})?;
Ok(asset_url)
}
BrowserType::Camoufox => {
// For Camoufox, verify the asset exists and find the correct download URL
let releases = self
@@ -209,10 +175,6 @@ impl Downloader {
Ok(download_url)
}
_ => {
// For other browsers, use the provided URL
Ok(download_info.url.clone())
}
}
}
@@ -239,110 +201,6 @@ impl Downloader {
(os.to_string(), arch.to_string())
}
/// Find the appropriate Brave asset for the current platform and architecture
fn find_brave_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Brave asset naming patterns:
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
let asset = match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
.or_else(|| {
// Fallback to any EXE if standalone not found
assets.iter().find(|asset| asset.name.ends_with(".exe"))
})
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
.or_else(|| {
// Fallback to any DMG
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
})
}
"linux" => {
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Zen asset for the current platform and architecture
fn find_zen_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Zen asset naming patterns:
// Windows: zen.installer.exe, zen.installer-arm64.exe
// macOS: zen.macos-universal.dmg
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
let asset = match (os, arch) {
("windows", "x64") => assets
.iter()
.find(|asset| asset.name == "zen.installer.exe"),
("windows", "arm64") => assets
.iter()
.find(|asset| asset.name == "zen.installer-arm64.exe"),
("macos", _) => assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg"),
("linux", "x64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-x86_64.AppImage")
})
}
("linux", "arm64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-aarch64.AppImage")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Camoufox asset for the current platform and architecture
fn find_camoufox_asset(
&self,
@@ -434,13 +292,6 @@ impl Downloader {
Ok(())
}
fn configure_camoufox_search_engine(
&self,
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
configure_camoufox_search_engine(browser_dir)
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
_app_handle: &tauri::AppHandle<R>,
@@ -457,16 +308,40 @@ impl Downloader {
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
// Check if this is a twilight release for special handling
let is_twilight =
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
// Determine if we have a partial file to resume
// Check existing file size — if it matches the expected size, skip download
let mut existing_size: u64 = 0;
if let Ok(meta) = std::fs::metadata(&file_path) {
existing_size = meta.len();
}
// Do a HEAD request to get the expected file size for skip/resume decisions
let head_response = self
.client
.head(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
)
.send()
.await
.ok();
let expected_size = head_response.as_ref().and_then(|r| r.content_length());
// If existing file matches expected size, skip download entirely
if existing_size > 0 {
if let Some(expected) = expected_size {
if existing_size == expected {
log::info!(
"Archive {} already exists with correct size ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
// 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 = {
@@ -555,11 +430,7 @@ impl Downloader {
0.0
};
let initial_stage = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let initial_stage = "downloading".to_string();
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -621,11 +492,7 @@ impl Downloader {
None
};
let stage_description = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let stage_description = "downloading".to_string();
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -844,11 +711,16 @@ impl Downloader {
// 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.
log::error!("Extraction failed for {browser_str} {version}: {e}");
// Delete the corrupt/invalid archive so a fresh download happens next time
if download_path.exists() {
log::info!("Deleting corrupt archive: {}", download_path.display());
let _ = std::fs::remove_file(&download_path);
}
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);
@@ -857,6 +729,20 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
// Emit error stage so the UI shows a toast
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to extract browser: {e}").into());
}
}
@@ -1004,10 +890,6 @@ impl Downloader {
{
log::warn!("Failed to create version.json for Camoufox: {e}");
}
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
log::warn!("Failed to configure Camoufox search engine: {e}");
}
}
// Emit completion
@@ -1033,28 +915,17 @@ impl Downloader {
tokens.remove(&download_key);
}
// Auto-update non-running profiles to the new version and cleanup unused binaries
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
{
let browser_for_update = browser_str.clone();
let version_for_update = version.clone();
let app_handle_for_update = app_handle.clone();
tauri::async_runtime::spawn(async move {
let auto_updater = crate::auto_updater::AutoUpdater::instance();
match auto_updater
.auto_update_profile_versions(
&app_handle_for_update,
&browser_for_update,
&version_for_update,
)
.await
{
match auto_updater.update_profiles_to_latest_installed(&app_handle_for_update) {
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Auto-updated {} profiles to {} {}: {:?}",
"Auto-updated {} profiles to latest installed versions: {:?}",
updated.len(),
browser_for_update,
version_for_update,
updated
);
}
@@ -1113,250 +984,24 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
}
}
/// Find all candidate `distribution/` directories inside the Camoufox browser dir.
/// On macOS: `<browser_dir>/<app>.app/Contents/Resources/distribution/`
/// On Linux: `<browser_dir>/camoufox/distribution/`
/// On Windows: `<browser_dir>/distribution/`
/// Also includes `<browser_dir>/distribution/` as a fallback for all platforms.
#[allow(clippy::vec_init_then_push)]
fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "macos")]
{
if let Ok(entries) = std::fs::read_dir(browser_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
dirs.push(
entry
.path()
.join("Contents")
.join("Resources")
.join("distribution"),
);
}
}
}
}
#[cfg(target_os = "linux")]
{
dirs.push(browser_dir.join("camoufox").join("distribution"));
}
// Fallback for all platforms
dirs.push(browser_dir.join("distribution"));
dirs
}
/// Set DuckDuckGo as the default search engine in Camoufox.
/// Creates or updates distribution/policies.json with a proper DuckDuckGo engine definition.
/// Called both at download time and at launch time to cover existing installations.
pub fn configure_camoufox_search_engine(
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let distribution_dirs = find_camoufox_distribution_dirs(browser_dir);
// Find an existing policies.json, or pick the first candidate dir to create one
let (policies_path, mut policies) = {
let mut found = None;
for dir in &distribution_dirs {
let path = dir.join("policies.json");
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
found = Some((path, val));
break;
}
}
}
}
match found {
Some(f) => f,
None => {
// Pick the first candidate directory that exists (or can be created)
let target_dir = distribution_dirs
.iter()
.find(|d| d.parent().is_some_and(|p| p.exists()))
.or(distribution_dirs.first())
.ok_or("No suitable distribution directory found")?;
std::fs::create_dir_all(target_dir)?;
(
target_dir.join("policies.json"),
serde_json::json!({"policies": {}}),
)
}
}
};
// Check if already configured
let has_ddg_default = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Default"))
.and_then(|d| d.as_str())
== Some("DuckDuckGo");
let has_ddg_engine = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Add"))
.and_then(|a| a.as_array())
.is_some_and(|arr| {
arr
.iter()
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
});
if has_ddg_default && has_ddg_engine {
return Ok(());
}
let ddg_engine = serde_json::json!({
"Name": "DuckDuckGo",
"URLTemplate": "https://duckduckgo.com/?q={searchTerms}",
"SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"Method": "GET",
"IconURL": "https://duckduckgo.com/favicon.ico",
"Alias": "ddg"
});
// Ensure policies.SearchEngines exists
let policies_obj = policies
.as_object_mut()
.ok_or("Invalid policies.json")?
.entry("policies")
.or_insert(serde_json::json!({}));
let se = policies_obj
.as_object_mut()
.ok_or("Invalid policies object")?
.entry("SearchEngines")
.or_insert(serde_json::json!({}));
if let Some(se_obj) = se.as_object_mut() {
// Set DuckDuckGo as default
se_obj.insert(
"Default".to_string(),
serde_json::Value::String("DuckDuckGo".to_string()),
);
// Add DuckDuckGo engine definition if not present
let add_arr = se_obj
.entry("Add")
.or_insert(serde_json::json!([]))
.as_array_mut()
.ok_or("SearchEngines.Add is not an array")?;
// Remove fake "None" engine
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
// Add DuckDuckGo if not already present
if !add_arr
.iter()
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
{
add_arr.push(ddg_engine);
}
// Ensure DuckDuckGo is not in the Remove list
if let Some(remove_arr) = se_obj.get_mut("Remove").and_then(|r| r.as_array_mut()) {
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
}
}
let updated = serde_json::to_string_pretty(&policies)?;
std::fs::write(&policies_path, updated)?;
log::info!(
"Configured DuckDuckGo search engine in {}",
policies_path.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api_client::ApiClient;
use crate::browser::BrowserType;
use crate::browser_version_manager::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
MockServer::start().await
}
fn create_test_api_client(server: &MockServer) -> ApiClient {
let base_url = server.uri();
ApiClient::new_with_base_urls(
base_url.clone(), // firefox_api_base
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
)
}
#[tokio::test]
async fn test_resolve_firefox_download_url() {
let server = setup_mock_server().await;
async fn test_download_file_with_progress() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
filename: "firefox-test.dmg".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Firefox, "139.0", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_resolve_chromium_download_url() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
filename: "chromium-test.zip".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Chromium, "1465660", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_download_browser_with_progress() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
// Create a temporary directory for the test
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create test file content (simulating a small download)
let test_content = b"This is a test file content for download simulation";
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.respond_with(
@@ -1368,85 +1013,51 @@ mod tests {
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/test-download", server.uri()),
filename: "test-file.dmg".to_string(),
is_archive: true,
};
// Create a mock app handle for testing
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/test-download", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "test-file.dmg")
.await;
assert!(result.is_ok());
let downloaded_file = result.unwrap();
assert!(downloaded_file.exists());
// Verify file content
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content, test_content);
}
#[tokio::test]
async fn test_download_browser_network_error() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
async fn test_download_file_network_error() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/missing-file", server.uri()),
filename: "missing-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/missing-file", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "missing-file.dmg")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_download_browser_chunked_response() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
async fn test_download_file_chunked_response() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create larger test content to simulate chunked transfer
let test_content = vec![42u8; 1024]; // 1KB of data
Mock::given(method("GET"))
@@ -1460,24 +1071,10 @@ mod tests {
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/chunked-download", server.uri()),
filename: "chunked-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/chunked-download", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Chromium,
"1465660",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "chunked-file.dmg")
.await;
assert!(result.is_ok());
+1
View File
@@ -281,6 +281,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_ephemeral_dir_lifecycle() {
let profile_id = uuid::Uuid::new_v4();
let id_str = profile_id.to_string();
+314 -5
View File
@@ -19,6 +19,14 @@ pub struct Extension {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub homepage_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -71,6 +79,166 @@ fn get_file_type(file_name: &str) -> Option<String> {
}
}
fn find_zip_start(data: &[u8]) -> usize {
for i in 0..data.len().saturating_sub(3) {
if data[i] == 0x50 && data[i + 1] == 0x4B && data[i + 2] == 0x03 && data[i + 3] == 0x04 {
return i;
}
}
0
}
#[allow(clippy::type_complexity)]
fn extract_manifest_metadata(
file_data: &[u8],
file_type: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(_) => return (None, None, None, None, None),
};
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
let mut contents = String::new();
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
Some(contents)
} else {
None
}
} else {
None
};
let manifest_content = match manifest_content {
Some(c) => c,
None => return (None, None, None, None, None),
};
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
Ok(v) => v,
Err(_) => return (None, None, None, None, None),
};
let name = manifest
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let version = manifest
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = manifest
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let author = manifest
.get("author")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let homepage_url = manifest
.get("homepage_url")
.or_else(|| manifest.get("homepage"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(name, version, description, author, homepage_url)
}
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(_) => return None,
};
let icon_path = {
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
let mut contents = String::new();
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
Some(contents)
} else {
None
}
} else {
None
};
let manifest_content = manifest_content?;
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
let mut best_path: Option<String> = None;
let mut best_size: u32 = 0;
if let Some(icons) = manifest.get("icons").and_then(|v| v.as_object()) {
for (size_str, path_val) in icons {
if let (Ok(size), Some(path)) = (size_str.parse::<u32>(), path_val.as_str()) {
if size > best_size {
best_size = size;
best_path = Some(path.to_string());
}
}
}
}
if best_path.is_none() {
for key in &["action", "browser_action"] {
if let Some(action) = manifest.get(*key) {
if let Some(icon) = action.get("default_icon") {
if let Some(path) = icon.as_str() {
best_path = Some(path.to_string());
} else if let Some(icons) = icon.as_object() {
for (size_str, path_val) in icons {
if let (Ok(size), Some(path)) = (size_str.parse::<u32>(), path_val.as_str()) {
if size > best_size {
best_size = size;
best_path = Some(path.to_string());
}
}
}
}
}
}
}
}
best_path
};
let icon_path = icon_path?;
let clean_path = icon_path.trim_start_matches('/');
let mut file = archive.by_name(clean_path).ok()?;
let mut data = Vec::new();
std::io::Read::read_to_end(&mut file, &mut data).ok()?;
let ext = clean_path
.rsplit('.')
.next()
.unwrap_or("png")
.to_lowercase();
Some((data, ext))
}
pub struct ExtensionManager;
impl ExtensionManager {
@@ -108,9 +276,18 @@ impl ExtensionManager {
let browser_compatibility = determine_browser_compatibility(&file_type);
let now = now_secs();
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &file_type);
let final_name = if manifest_name.is_some() {
manifest_name.clone().unwrap_or(name)
} else {
name
};
let ext = Extension {
id: uuid::Uuid::new_v4().to_string(),
name,
name: final_name,
file_name: file_name.clone(),
file_type,
browser_compatibility,
@@ -118,12 +295,23 @@ impl ExtensionManager {
updated_at: now,
sync_enabled: crate::sync::is_sync_configured(),
last_sync: None,
version,
description,
author,
homepage_url,
};
let file_dir = self.get_file_dir(&ext.id);
fs::create_dir_all(&file_dir)?;
fs::write(file_dir.join(&file_name), &file_data)?;
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) {
let icon_path = self
.get_extension_dir(&ext.id)
.join(format!("icon.{icon_ext}"));
let _ = fs::write(icon_path, icon_data);
}
let metadata_path = self.get_metadata_path(&ext.id);
let json = serde_json::to_string_pretty(&ext)?;
fs::write(metadata_path, json)?;
@@ -187,6 +375,7 @@ impl ExtensionManager {
) -> Result<Extension, Box<dyn std::error::Error>> {
let mut ext = self.get_extension(id)?;
let explicit_name_provided = name.is_some();
if let Some(new_name) = name {
ext.name = new_name;
}
@@ -206,6 +395,31 @@ impl ExtensionManager {
ext.file_name = new_file_name;
ext.file_type = new_file_type.clone();
ext.browser_compatibility = determine_browser_compatibility(&new_file_type);
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&data, &new_file_type);
if let Some(v) = version {
ext.version = Some(v);
}
if let Some(d) = description {
ext.description = Some(d);
}
if let Some(a) = author {
ext.author = Some(a);
}
if let Some(h) = homepage_url {
ext.homepage_url = Some(h);
}
if let Some(mn) = manifest_name {
if !explicit_name_provided {
ext.name = mn;
}
}
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
let _ = fs::write(icon_path, icon_data);
}
}
ext.updated_at = now_secs();
@@ -615,8 +829,8 @@ impl ExtensionManager {
) -> Result<(), Box<dyn std::error::Error>> {
let group = self.get_group(group_id)?;
let browser_type = match browser {
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
"wayfern" | "chromium" | "brave" => "chromium",
"camoufox" => "firefox",
"wayfern" => "chromium",
_ => return Err(format!("Extensions are not supported for browser '{browser}'").into()),
};
@@ -657,8 +871,8 @@ impl ExtensionManager {
}
let browser_type = match profile.browser.as_str() {
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
"wayfern" | "chromium" | "brave" => "chromium",
"camoufox" => "firefox",
"wayfern" => "chromium",
_ => return Ok(Vec::new()),
};
@@ -777,6 +991,95 @@ impl ExtensionManager {
let magic = [0x50, 0x4B, 0x03, 0x04];
data.windows(4).position(|window| window == magic)
}
pub fn ensure_icons_extracted(&self) {
let extensions = match self.list_extensions() {
Ok(exts) => exts,
Err(_) => return,
};
for ext in extensions {
let ext_dir = self.get_extension_dir(&ext.id);
let has_icon = ext_dir
.read_dir()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().starts_with("icon."))
})
.unwrap_or(false);
if has_icon {
continue;
}
let file_dir = self.get_file_dir(&ext.id);
let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) {
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) {
let icon_path = ext_dir.join(format!("icon.{icon_ext}"));
let _ = fs::write(icon_path, icon_data);
}
}
if ext.version.is_none() && ext.description.is_none() {
let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) {
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &ext.file_type);
if version.is_some()
|| description.is_some()
|| author.is_some()
|| homepage_url.is_some()
|| manifest_name.is_some()
{
let mut updated_ext = ext.clone();
if let Some(v) = version {
updated_ext.version = Some(v);
}
if let Some(d) = description {
updated_ext.description = Some(d);
}
if let Some(a) = author {
updated_ext.author = Some(a);
}
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
}
let metadata_path = self.get_metadata_path(&ext.id);
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
let _ = fs::write(metadata_path, json);
}
}
}
}
}
}
pub fn get_extension_icon(&self, ext_id: &str) -> Option<String> {
let ext_dir = self.get_extension_dir(ext_id);
let entries = ext_dir.read_dir().ok()?;
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("icon.") {
let icon_path = entry.path();
let data = fs::read(&icon_path).ok()?;
let ext = name.rsplit('.').next().unwrap_or("png");
let mime = match ext {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "image/png",
};
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
return Some(format!("data:{};base64,{}", mime, b64));
}
}
None
}
}
// Global instance
@@ -800,6 +1103,12 @@ pub async fn list_extensions() -> Result<Vec<Extension>, String> {
.map_err(|e| format!("Failed to list extensions: {e}"))
}
#[tauri::command]
pub fn get_extension_icon(extension_id: String) -> Option<String> {
let manager = crate::extension_manager::ExtensionManager::new();
manager.get_extension_icon(&extension_id)
}
#[tauri::command]
pub async fn add_extension(
name: String,
+35 -103
View File
@@ -6,8 +6,8 @@ use crate::browser::BrowserType;
use crate::downloader::DownloadProgress;
use crate::events;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::process::Command;
#[cfg(target_os = "macos")]
use tokio::process::Command;
#[cfg(target_os = "macos")]
use std::fs::create_dir_all;
@@ -38,12 +38,7 @@ impl Extractor {
"camoufox"
} else if dest_dir.to_string_lossy().contains("wayfern") {
"wayfern"
} else if dest_dir.to_string_lossy().contains("firefox") {
"firefox"
} else if dest_dir.to_string_lossy().contains("zen") {
"zen"
} else {
// For other browsers, assume the structure is already correct
return Ok(());
};
@@ -237,17 +232,8 @@ impl Extractor {
&self,
file_path: &Path,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// First check file extension for DMG files since they're common on macOS
// and can have misleading magic numbers
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
if ext.to_lowercase() == "dmg" {
return Ok("dmg".to_string());
}
if ext.to_lowercase() == "msi" {
return Ok("msi".to_string());
}
}
// Always check magic bytes first — the file extension may be wrong
// (e.g. CDN serving a ZIP with .dmg extension)
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
file.read_exact(&mut buffer)?;
@@ -362,16 +348,20 @@ impl Extractor {
.args([
"attach",
"-nobrowse",
"-noverify",
"-noautoopen",
"-mountpoint",
mount_point.to_str().unwrap(),
dmg_path.to_str().unwrap(),
])
.output()?;
.stdin(std::process::Stdio::null())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
// Clean up mount point before returning error
let _ = fs::remove_dir_all(&mount_point);
@@ -387,12 +377,13 @@ impl Extractor {
let app_entry = match app_result {
Ok(app_path) => app_path,
Err(e) => {
log::info!("Failed to find .app in mount point: {e}");
log::error!("Failed to find .app in mount point: {e}");
// Try to unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
.output()
.await;
let _ = fs::remove_dir_all(&mount_point);
return Err("No .app found after extraction".into());
@@ -412,16 +403,18 @@ impl Extractor {
app_entry.to_str().unwrap(),
app_path.to_str().unwrap(),
])
.output()?;
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::info!("Failed to copy app: {stderr}");
log::error!("Failed to copy app: {stderr}");
// Unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
.output()
.await;
let _ = fs::remove_dir_all(&mount_point);
return Err(format!("Failed to copy app: {stderr}").into());
@@ -432,18 +425,21 @@ impl Extractor {
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
.output()
.await;
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output();
.output()
.await;
log::info!("Removed quarantine attributes");
// Unmount the DMG
let output = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output()?;
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -739,57 +735,19 @@ impl Extractor {
dest_dir: &Path,
browser_type: BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Zen => {
// Zen installer EXE needs to be run to install
#[cfg(target_os = "windows")]
{
self.install_zen_windows(exe_path, dest_dir).await
}
#[cfg(not(target_os = "windows"))]
{
Err("Zen EXE installation is only supported on Windows".into())
}
}
_ => {
// For other browsers (Firefox, TOR, etc.), the EXE is typically just copied
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
{
let _ = browser_type;
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
}
#[cfg(target_os = "windows")]
async fn install_zen_windows(
&self,
installer_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// For Zen installer, we need to run it silently
let output = Command::new(installer_path)
.args(["/S", &format!("/D={}", dest_dir.display())])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to install Zen: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the installed executable
self.find_extracted_executable(dest_dir).await
}
fn flatten_single_directory_archive(
&self,
dest_dir: &Path,
@@ -954,8 +912,6 @@ impl Extractor {
"firefox.exe",
"chrome.exe",
"chromium.exe",
"zen.exe",
"brave.exe",
"camoufox.exe",
"wayfern.exe",
];
@@ -1023,8 +979,6 @@ impl Extractor {
if file_name.contains("firefox")
|| file_name.contains("chrome")
|| file_name.contains("chromium")
|| file_name.contains("zen")
|| file_name.contains("brave")
|| file_name.contains("browser")
|| file_name.contains("camoufox")
|| file_name.contains("wayfern")
@@ -1075,31 +1029,14 @@ impl Extractor {
// Enhanced list of common browser executable names
let exe_names = [
// Firefox variants
// Firefox variants (used by Camoufox)
"firefox",
"firefox-bin",
"firefox-esr",
"firefox-trunk",
// Chrome/Chromium variants
// Chrome/Chromium variants (used by Wayfern)
"chrome",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium-bin",
// Zen Browser
"zen",
"zen-browser",
"zen-bin",
// Brave variants
"brave",
"brave-browser",
"brave-browser-stable",
"brave-browser-beta",
"brave-browser-dev",
"brave-bin",
// Camoufox variants
"camoufox",
"camoufox-bin",
@@ -1130,17 +1067,12 @@ impl Extractor {
"firefox",
"chrome",
"chromium",
"brave",
"zen",
"camoufox",
"wayfern",
".",
"./",
"firefox",
"Browser",
"browser",
"opt/google/chrome",
"opt/brave.com/brave",
"opt/camoufox",
"usr/lib/firefox",
"usr/lib/chromium",
+31 -1
View File
@@ -5,6 +5,7 @@ use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -22,6 +23,8 @@ pub struct GeoIPDownloadProgress {
pub eta_seconds: Option<f64>,
}
static DOWNLOAD_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
pub struct GeoIPDownloader {
client: Client,
}
@@ -126,6 +129,22 @@ impl GeoIPDownloader {
pub async fn download_geoip_database(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if DOWNLOAD_IN_PROGRESS
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
log::info!("GeoIP database download already in progress, skipping");
return Ok(());
}
let result = self.download_geoip_database_inner(_app_handle).await;
DOWNLOAD_IN_PROGRESS.store(false, Ordering::SeqCst);
result
}
async fn download_geoip_database_inner(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Emit initial progress
let _ = events::emit(
@@ -155,6 +174,13 @@ impl GeoIPDownloader {
let mmdb_path = Self::get_mmdb_file_path()?;
// Always download to a temp file first, then atomically rename.
// This prevents corruption if the app is closed mid-download.
let temp_path = mmdb_path.with_extension("mmdb.downloading");
// Remove any leftover temp file from a previous interrupted download
let _ = fs::remove_file(&temp_path).await;
// Download the file
let response = self.client.get(&download_url).send().await?;
@@ -170,7 +196,7 @@ impl GeoIPDownloader {
let total_size = response.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut file = fs::File::create(&mmdb_path).await?;
let mut file = fs::File::create(&temp_path).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -218,6 +244,10 @@ impl GeoIPDownloader {
}
file.flush().await?;
drop(file);
// Atomically replace the old database with the new one
fs::rename(&temp_path, &mmdb_path).await?;
// Write download timestamp
let timestamp_path = Self::get_timestamp_path();
+492
View File
@@ -0,0 +1,492 @@
use rand::{Rng, RngExt};
use std::collections::{HashMap, HashSet};
const PROB_ERROR: f64 = 0.04;
const PROB_SWAP_ERROR: f64 = 0.015;
const PROB_NOTICE_ERROR: f64 = 0.85;
const SPEED_BOOST_COMMON_WORD: f64 = 0.6;
const SPEED_PENALTY_COMPLEX_WORD: f64 = 1.3;
const SPEED_BOOST_CLOSE_KEYS: f64 = 0.5;
const SPEED_BOOST_BIGRAM: f64 = 0.4;
const TIME_KEYSTROKE_STD: f64 = 0.03;
const TIME_BACKSPACE_MEAN: f64 = 0.12;
const TIME_BACKSPACE_STD: f64 = 0.02;
const TIME_REACTION_MEAN: f64 = 0.35;
const TIME_REACTION_STD: f64 = 0.1;
const TIME_UPPERCASE_PENALTY: f64 = 0.2;
const TIME_SPACE_PAUSE_MEAN: f64 = 0.25;
const TIME_SPACE_PAUSE_STD: f64 = 0.05;
const FATIGUE_FACTOR: f64 = 1.0005;
const AVG_WORD_LENGTH: f64 = 5.0;
const WPM_STD: f64 = 10.0;
const DEFAULT_WPM: f64 = 80.0;
#[derive(Debug, Clone)]
pub enum TypingAction {
Char(char),
Backspace,
}
#[derive(Debug, Clone)]
pub struct TypingEvent {
pub time: f64,
pub action: TypingAction,
}
struct KeyboardLayout {
pos_map: HashMap<char, (usize, usize)>,
grid: Vec<Vec<char>>,
}
impl KeyboardLayout {
fn new() -> Self {
let grid: Vec<Vec<char>> = vec![
"`1234567890-=".chars().collect(),
"qwertyuiop[]\\".chars().collect(),
"asdfghjkl;'".chars().collect(),
"zxcvbnm,./".chars().collect(),
];
let mut pos_map = HashMap::new();
for (r, row) in grid.iter().enumerate() {
for (c, &ch) in row.iter().enumerate() {
pos_map.insert(ch, (r, c));
}
}
KeyboardLayout { pos_map, grid }
}
fn has_key(&self, ch: char) -> bool {
self.pos_map.contains_key(&ch.to_ascii_lowercase())
}
fn get_neighbor_keys(&self, ch: char) -> Vec<char> {
let ch = ch.to_ascii_lowercase();
let (r, c) = match self.pos_map.get(&ch) {
Some(&pos) => pos,
None => return vec![],
};
let deltas: [(i32, i32); 8] = [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
];
let mut neighbors = Vec::new();
for (dr, dc) in &deltas {
let nr = r as i32 + dr;
let nc = c as i32 + dc;
if nr >= 0 && (nr as usize) < self.grid.len() {
let row = &self.grid[nr as usize];
if nc >= 0 && (nc as usize) < row.len() {
neighbors.push(row[nc as usize]);
}
}
}
neighbors
}
fn get_distance(&self, c1: char, c2: char) -> f64 {
let c1 = c1.to_ascii_lowercase();
let c2 = c2.to_ascii_lowercase();
match (self.pos_map.get(&c1), self.pos_map.get(&c2)) {
(Some(&(r1, c1p)), Some(&(r2, c2p))) => {
let dr = r1 as f64 - r2 as f64;
let dc = c1p as f64 - c2p as f64;
(dr * dr + dc * dc).sqrt()
}
_ => 4.0,
}
}
fn get_random_neighbor(&self, ch: char, rng: &mut impl Rng) -> char {
let neighbors = self.get_neighbor_keys(ch);
if neighbors.is_empty() {
let flat: Vec<char> = self.grid.iter().flat_map(|r| r.iter().copied()).collect();
flat[rng.random_range(0..flat.len())]
} else {
neighbors[rng.random_range(0..neighbors.len())]
}
}
}
fn normal_sample(rng: &mut impl Rng, mean: f64, std_dev: f64) -> f64 {
// Box-Muller transform
let u1: f64 = rng.random::<f64>().max(1e-10);
let u2: f64 = rng.random::<f64>();
let z = (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos();
mean + std_dev * z
}
static COMMON_WORDS: &[&str] = &[
"the", "be", "to", "of", "and", "a", "in", "that", "have", "it", "for", "not", "on", "with",
"he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say", "her",
"she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", "up",
"out", "if", "about", "who", "get", "which", "go", "me", "when", "make", "can", "like", "time",
"no", "just", "him", "know", "take", "people", "into", "year", "your", "good", "some", "could",
"them", "see", "other", "than", "then", "now", "look", "only", "come", "its", "over", "think",
"also", "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", "even",
"new", "want", "because",
];
static COMMON_BIGRAMS: &[&str] = &[
"th", "he", "in", "er", "an", "re", "on", "at", "en", "nd", "ti", "es", "or", "te", "of", "ed",
"is", "it", "al", "ar", "st", "to", "nt", "ng", "se", "ha", "as", "ou", "io", "le", "ve", "co",
"me", "de", "hi", "ri", "ro", "ic", "ne", "ea", "ra", "ce",
];
fn get_word_difficulty(word: &str) -> &'static str {
let lower = word.to_lowercase();
let trimmed = lower.trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?' | ';' | ':'));
let common_set: HashSet<&str> = COMMON_WORDS.iter().copied().collect();
if common_set.contains(trimmed) {
return "common";
}
let is_long = trimmed.len() > 8;
let has_complex = trimmed.chars().any(|c| matches!(c, 'z' | 'x' | 'q' | 'j'));
if is_long || has_complex {
return "complex";
}
"normal"
}
fn is_common_bigram(c1: char, c2: char) -> bool {
let bigram = format!("{}{}", c1.to_ascii_lowercase(), c2.to_ascii_lowercase());
let bigram_set: HashSet<&str> = COMMON_BIGRAMS.iter().copied().collect();
bigram_set.contains(bigram.as_str())
}
pub struct MarkovTyper {
target: Vec<char>,
current: Vec<char>,
keyboard: KeyboardLayout,
base_keystroke_time: f64,
fatigue_multiplier: f64,
mental_cursor_pos: usize,
last_char_typed: Option<char>,
total_time: f64,
last_was_backspace: bool,
rng: rand::rngs::ThreadRng,
}
impl MarkovTyper {
pub fn new(text: &str, wpm: Option<f64>) -> Self {
let mut rng = rand::rng();
let target_wpm = wpm.unwrap_or(DEFAULT_WPM);
let session_wpm = normal_sample(&mut rng, target_wpm, WPM_STD).max(10.0);
let base_keystroke_time = 60.0 / (session_wpm * AVG_WORD_LENGTH);
MarkovTyper {
target: text.chars().collect(),
current: Vec::new(),
keyboard: KeyboardLayout::new(),
base_keystroke_time,
fatigue_multiplier: 1.0,
mental_cursor_pos: 0,
last_char_typed: None,
total_time: 0.0,
last_was_backspace: false,
rng,
}
}
fn get_current_word(&self) -> Option<String> {
if self.mental_cursor_pos >= self.target.len() {
return None;
}
let mut start = self.mental_cursor_pos;
while start > 0 && self.target[start - 1] != ' ' {
start -= 1;
}
let mut end = self.mental_cursor_pos;
while end < self.target.len() && self.target[end] != ' ' {
end += 1;
}
Some(self.target[start..end].iter().collect())
}
fn calculate_keystroke_time(&mut self, ch: char) -> f64 {
let mut time = self.base_keystroke_time * self.fatigue_multiplier;
if let Some(word) = self.get_current_word() {
match get_word_difficulty(&word) {
"common" => time *= SPEED_BOOST_COMMON_WORD,
"complex" => time *= SPEED_PENALTY_COMPLEX_WORD,
_ => {}
}
}
if let Some(last) = self.last_char_typed {
if is_common_bigram(last, ch) {
time *= SPEED_BOOST_BIGRAM;
} else {
let dist = self.keyboard.get_distance(last, ch);
if dist > 0.0 && dist < 2.0 {
time *= SPEED_BOOST_CLOSE_KEYS;
} else if dist > 4.0 {
time *= 1.2;
}
}
}
if ch == ' ' {
time += normal_sample(&mut self.rng, TIME_SPACE_PAUSE_MEAN, TIME_SPACE_PAUSE_STD);
} else if ch.is_uppercase() {
time += TIME_UPPERCASE_PENALTY;
}
let dt = normal_sample(&mut self.rng, time, TIME_KEYSTROKE_STD);
dt.max(0.02)
}
fn step(&mut self) -> Option<TypingEvent> {
if self.current == self.target {
return None;
}
// Find first error position
let mut first_error_pos = self.target.len();
let min_len = self.current.len().min(self.target.len());
for i in 0..min_len {
if self.current[i] != self.target[i] {
first_error_pos = i;
break;
}
}
if self.current.len() > self.target.len() && first_error_pos == self.target.len() {
first_error_pos = self.target.len();
}
// Error correction
if first_error_pos < self.current.len() {
let mut should_correct = false;
if self.last_was_backspace || self.mental_cursor_pos >= self.target.len() {
should_correct = true;
} else if !self.current.is_empty() {
let last_char = *self.current.last().unwrap();
let distance = self.current.len() - first_error_pos;
if " \n\t.,;!?:()[]{}\"'<>".contains(last_char) {
should_correct = true;
} else if distance >= 2 {
if self.rng.random::<f64>() < 0.8 {
should_correct = true;
}
} else if distance == 1 && self.rng.random::<f64>() < PROB_NOTICE_ERROR {
should_correct = true;
}
}
if should_correct {
if !self.last_was_backspace {
let dt = normal_sample(&mut self.rng, TIME_REACTION_MEAN, TIME_REACTION_STD).max(0.1);
self.total_time += dt;
}
let dt = normal_sample(&mut self.rng, TIME_BACKSPACE_MEAN, TIME_BACKSPACE_STD);
self.total_time += dt;
self.current.pop();
self.mental_cursor_pos = self.current.len();
self.last_was_backspace = true;
return Some(TypingEvent {
time: self.total_time,
action: TypingAction::Backspace,
});
}
}
self.last_was_backspace = false;
if self.mental_cursor_pos > self.current.len() {
self.mental_cursor_pos = self.current.len();
}
if self.mental_cursor_pos >= self.target.len() {
return None;
}
let char_intended = self.target[self.mental_cursor_pos];
self.fatigue_multiplier *= FATIGUE_FACTOR;
// Non-QWERTY characters (CJK, Cyrillic, etc.) are composed via IME —
// skip error simulation entirely, just apply realistic timing.
let on_keyboard = self.keyboard.has_key(char_intended);
// Swap error (only for characters on the physical keyboard)
if on_keyboard && self.mental_cursor_pos + 1 < self.target.len() {
let char_after = self.target[self.mental_cursor_pos + 1];
if char_after != ' '
&& char_after != char_intended
&& self.keyboard.has_key(char_after)
&& self.rng.random::<f64>() < PROB_SWAP_ERROR
{
let dt = self.calculate_keystroke_time(char_after);
self.total_time += dt;
self.current.push(char_after);
self.last_char_typed = Some(char_after);
self.mental_cursor_pos += 1;
return Some(TypingEvent {
time: self.total_time,
action: TypingAction::Char(char_after),
});
}
}
// Normal typing with possible error (errors only for QWERTY characters)
let typed_char = if on_keyboard {
let mut current_prob_error = PROB_ERROR;
if let Some(word) = self.get_current_word() {
match get_word_difficulty(&word) {
"complex" => current_prob_error *= 1.5,
"common" => current_prob_error *= 0.5,
_ => {}
}
}
if self.rng.random::<f64>() < current_prob_error {
self
.keyboard
.get_random_neighbor(char_intended, &mut self.rng)
} else {
char_intended
}
} else {
char_intended
};
let dt = self.calculate_keystroke_time(typed_char);
self.total_time += dt;
self.current.push(typed_char);
self.last_char_typed = Some(typed_char);
self.mental_cursor_pos += 1;
Some(TypingEvent {
time: self.total_time,
action: TypingAction::Char(typed_char),
})
}
pub fn run(mut self) -> Vec<TypingEvent> {
let max_steps = self.target.len() * 10;
let mut events = Vec::new();
let mut steps = 0;
while let Some(event) = self.step() {
events.push(event);
steps += 1;
if steps > max_steps {
break;
}
}
events
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generates_events() {
let typer = MarkovTyper::new("hello", Some(60.0));
let events = typer.run();
assert!(!events.is_empty());
// Final text should be "hello" — verify by replaying
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, "hello");
}
#[test]
fn test_timing_increases() {
let typer = MarkovTyper::new("test", Some(60.0));
let events = typer.run();
for window in events.windows(2) {
assert!(window[1].time >= window[0].time);
}
}
#[test]
fn test_empty_text() {
let typer = MarkovTyper::new("", Some(60.0));
let events = typer.run();
assert!(events.is_empty());
}
#[test]
fn test_chinese_text() {
let input = "你好世界";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
#[test]
fn test_russian_text() {
let input = "Привет мир";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
#[test]
fn test_japanese_text() {
let input = "東京タワー";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
#[test]
fn test_mixed_latin_and_cjk() {
let input = "Hello 你好 world";
let typer = MarkovTyper::new(input, Some(60.0));
let events = typer.run();
let mut text = String::new();
for event in &events {
match &event.action {
TypingAction::Char(c) => text.push(*c),
TypingAction::Backspace => {
text.pop();
}
}
}
assert_eq!(text, input);
}
}
+279 -15
View File
@@ -26,6 +26,7 @@ mod extension_manager;
mod extraction;
mod geoip_downloader;
mod group_manager;
mod human_typing;
mod ip_utils;
mod platform_browser;
mod profile;
@@ -36,6 +37,7 @@ pub mod proxy_server;
pub mod proxy_storage;
mod settings_manager;
pub mod sync;
mod synchronizer;
pub mod traffic_stats;
mod wayfern_manager;
mod wayfern_terms;
@@ -117,8 +119,9 @@ use profile_importer::{detect_existing_profiles, import_browser_profile};
use extension_manager::{
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, list_extension_groups,
list_extensions, remove_extension_from_group, update_extension, update_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
update_extension_group,
};
use group_manager::{
@@ -206,11 +209,21 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
async fn create_stored_proxy(
app_handle: tauri::AppHandle,
name: String,
proxy_settings: crate::browser::ProxySettings,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, proxy_settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
crate::proxy_manager::PROXY_MANAGER
.create_dynamic_proxy(&app_handle, name, url.clone(), format.clone())
.map_err(|e| format!("Failed to create dynamic proxy: {e}"))
} else if let Some(settings) = proxy_settings {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
} else {
Err("Either proxy_settings or dynamic proxy URL and format are required".to_string())
}
}
#[tauri::command]
@@ -224,10 +237,26 @@ async fn update_stored_proxy(
proxy_id: String,
name: Option<String>,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
// Check if this is a dynamic proxy update
let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
if is_dynamic || dynamic_proxy_url.is_some() {
crate::proxy_manager::PROXY_MANAGER
.update_dynamic_proxy(
&app_handle,
&proxy_id,
name,
dynamic_proxy_url,
dynamic_proxy_format,
)
.map_err(|e| format!("Failed to update dynamic proxy: {e}"))
} else {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
}
}
#[tauri::command]
@@ -240,13 +269,43 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
#[tauri::command]
async fn check_proxy_validity(
proxy_id: String,
proxy_settings: crate::browser::ProxySettings,
proxy_settings: Option<crate::browser::ProxySettings>,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
// For dynamic proxies, fetch settings first
let settings = if let Some(s) = proxy_settings {
s
} else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) {
crate::proxy_manager::PROXY_MANAGER
.resolve_dynamic_proxy(&proxy_id)
.await?
} else {
crate::proxy_manager::PROXY_MANAGER
.get_proxy_settings_by_id(&proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?
};
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity(&proxy_id, &proxy_settings)
.check_proxy_validity(&proxy_id, &settings)
.await
}
#[tauri::command]
async fn fetch_dynamic_proxy(
url: String,
format: String,
) -> Result<crate::browser::ProxySettings, String> {
let settings = crate::proxy_manager::PROXY_MANAGER
.fetch_dynamic_proxy(&url, &format)
.await?;
// Validate the proxy actually works by routing through a temporary local proxy
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity("_dynamic_test", &settings)
.await
.map_err(|e| format!("Proxy resolved but connection failed: {e}"))?;
Ok(settings)
}
#[tauri::command]
fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::ProxyCheckResult> {
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
@@ -303,7 +362,33 @@ async fn copy_profile_cookies(
{
return Err("Cookie copying requires an active Pro subscription".to_string());
}
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
let target_ids = request.target_profile_ids.clone();
let results = cookie_manager::CookieManager::copy_cookies(&app_handle, request).await?;
// Trigger sync for target profiles that have sync enabled
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
let sync_ids: Vec<String> = target_ids
.iter()
.filter(|tid| {
profiles
.iter()
.any(|p| p.id.to_string() == **tid && p.is_sync_enabled())
})
.cloned()
.collect();
if !sync_ids.is_empty() {
tauri::async_runtime::spawn(async move {
for id in sync_ids {
scheduler.queue_profile_sync(id).await;
}
});
}
}
}
Ok(results)
}
#[tauri::command]
@@ -318,7 +403,25 @@ async fn import_cookies_from_file(
{
return Err("Cookie import requires an active Pro subscription".to_string());
}
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await
let result =
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await?;
// Trigger sync for the profile if sync is enabled
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.is_sync_enabled() {
let pid = profile_id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
}
Ok(result)
}
#[tauri::command]
@@ -756,6 +859,62 @@ async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
)
}
#[tauri::command]
async fn generate_sample_fingerprint(
app_handle: tauri::AppHandle,
browser: String,
version: String,
config_json: String,
) -> Result<String, String> {
let temp_profile = crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "temp_fingerprint_gen".to_string(),
browser: browser.clone(),
version: version.clone(),
process_id: None,
proxy_id: None,
vpn_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: crate::profile::types::SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
if browser == "camoufox" {
let config: crate::camoufox_manager::CamoufoxConfig =
serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?;
let manager = crate::camoufox_manager::CamoufoxManager::instance();
manager
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
.await
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
} else if browser == "wayfern" {
let config: crate::wayfern_manager::WayfernConfig =
serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?;
let manager = crate::wayfern_manager::WayfernManager::instance();
manager
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
.await
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
} else {
Err(format!(
"Unsupported browser for fingerprint generation: {browser}"
))
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -818,6 +977,12 @@ pub fn run() {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
// Extract icons and metadata for existing extensions that don't have them yet
{
let mgr = extension_manager::ExtensionManager::new();
mgr.ensure_icons_extracted();
}
// Start the daemon for tray icon
if let Err(e) = daemon_spawn::ensure_daemon_running() {
log::warn!("Failed to start daemon: {e}");
@@ -961,6 +1126,70 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await;
});
// Auto-start MCP server if it was previously enabled
{
let mcp_handle = app.handle().clone();
let settings_mgr = settings_manager::SettingsManager::instance();
if let Ok(settings) = settings_mgr.load_settings() {
if settings.mcp_enabled {
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
});
}
}
}
// Clear stale process IDs from profiles (processes that died while app was closed)
{
let profile_manager = crate::profile::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
let system = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing()
.with_processes(sysinfo::ProcessRefreshKind::everything()),
);
for profile in profiles {
if let Some(pid) = profile.process_id {
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
if system.process(sysinfo_pid).is_none() {
log::info!(
"Clearing stale process_id {} for profile {}",
pid,
profile.name
);
let mut updated = profile.clone();
updated.process_id = None;
let _ = profile_manager.save_profile(&updated);
}
}
}
}
}
// Immediately bump non-running profiles to the latest installed browser version.
// This runs synchronously before any network calls so profiles are updated on launch.
{
let app_handle_bump = app.handle().clone();
match auto_updater::AutoUpdater::instance()
.update_profiles_to_latest_installed(&app_handle_bump)
{
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Startup: bumped {} profiles to latest installed versions: {:?}",
updated.len(),
updated
);
}
}
Err(e) => {
log::error!("Startup: failed to bump profiles to latest installed versions: {e}");
}
}
}
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately
@@ -1187,6 +1416,20 @@ pub fn run() {
);
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
scheduler.mark_profile_running(&profile_id).await;
} else {
scheduler.mark_profile_stopped(&profile_id).await;
// Queue sync after profile stops (if sync is enabled)
if profile.is_sync_enabled() {
log::info!("Profile '{}' stopped, queuing sync", profile.name);
scheduler.queue_profile_sync(profile_id.clone()).await;
}
}
}
last_running_states.insert(profile_id, is_running);
} else {
// Update the state even if unchanged to ensure we have it tracked
@@ -1291,7 +1534,7 @@ pub fn run() {
}
}
Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e);
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
@@ -1314,6 +1557,13 @@ pub fn run() {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
// Request wayfern token on startup for paid users
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
}
}
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
});
@@ -1379,6 +1629,7 @@ pub fn run() {
update_stored_proxy,
delete_stored_proxy,
check_proxy_validity,
fetch_dynamic_proxy,
get_cached_proxy_check,
export_proxies,
import_proxies_json,
@@ -1386,6 +1637,7 @@ pub fn run() {
import_proxies_from_parsed,
update_camoufox_config,
update_wayfern_config,
generate_sample_fingerprint,
get_profile_groups,
get_groups_with_profile_counts,
create_profile_group,
@@ -1394,6 +1646,7 @@ pub fn run() {
assign_profiles_to_group,
delete_selected_profiles,
list_extensions,
get_extension_icon,
add_extension,
update_extension,
delete_extension,
@@ -1464,13 +1717,21 @@ pub fn run() {
cloud_auth::cloud_logout,
cloud_auth::cloud_get_proxy_usage,
cloud_auth::cloud_get_countries,
cloud_auth::cloud_get_states,
cloud_auth::cloud_get_regions,
cloud_auth::cloud_get_cities,
cloud_auth::cloud_get_isps,
cloud_auth::create_cloud_location_proxy,
cloud_auth::restart_sync_service,
cloud_auth::cloud_get_wayfern_token,
cloud_auth::cloud_refresh_wayfern_token,
// Team lock commands
team_lock::get_team_locks,
team_lock::get_team_lock_status,
// Synchronizer commands
synchronizer::start_sync_session,
synchronizer::stop_sync_session,
synchronizer::remove_sync_follower,
synchronizer::get_sync_sessions,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -1514,6 +1775,9 @@ mod tests {
"set_extension_sync_enabled",
"set_extension_group_sync_enabled",
"get_team_lock_status",
"generate_sample_fingerprint",
"cloud_get_wayfern_token",
"cloud_refresh_wayfern_token",
];
// Extract command names from the generate_handler! macro in this file
File diff suppressed because it is too large Load Diff
+3
View File
@@ -5,6 +5,7 @@ use std::process::Command;
// Platform-specific modules
#[cfg(target_os = "macos")]
#[allow(dead_code)]
pub mod macos {
use super::*;
use sysinfo::{Pid, System};
@@ -468,6 +469,7 @@ end try
}
#[cfg(target_os = "windows")]
#[allow(dead_code)]
pub mod windows {
use super::*;
@@ -680,6 +682,7 @@ pub mod windows {
}
#[cfg(target_os = "linux")]
#[allow(dead_code)]
pub mod linux {
use super::*;
+8 -23
View File
@@ -1242,10 +1242,7 @@ impl ProfileManager {
let profile_path_match = cmd.iter().any(|s| {
let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match
if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "zen"
{
if profile.browser == "camoufox" {
arg == profile_data_path_str
|| arg == format!("-profile={profile_data_path_str}")
|| (arg == "-profile"
@@ -1253,7 +1250,7 @@ impl ProfileManager {
.iter()
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
} else {
// For Chromium-based browsers, check for user-data-dir
// For Chromium-based browsers (Wayfern), check for user-data-dir
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
}
@@ -1262,7 +1259,6 @@ impl ProfileManager {
if profile_path_match {
is_running = true;
found_pid = Some(pid);
// Found existing browser process
}
}
}
@@ -1275,16 +1271,12 @@ impl ProfileManager {
// Check if this is the right browser executable first
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"firefox" => {
exe_name.contains("firefox")
&& !exe_name.contains("developer")
&& !exe_name.contains("camoufox")
"camoufox" => exe_name.contains("camoufox") || exe_name.contains("firefox"),
"wayfern" => {
exe_name.contains("wayfern")
|| exe_name.contains("chromium")
|| exe_name.contains("chrome")
}
"firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"),
"zen" => exe_name.contains("zen"),
"chromium" => exe_name.contains("chromium"),
"brave" => exe_name.contains("brave"),
// Camoufox is handled via CamoufoxManager, not PID-based checking
_ => false,
};
@@ -1300,13 +1292,6 @@ impl ProfileManager {
let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match
if profile.browser == "camoufox" {
// Camoufox uses user_data_dir like Chromium browsers
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
} else if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "zen"
{
arg == profile_data_path_str
|| arg == format!("-profile={profile_data_path_str}")
|| (arg == "-profile"
@@ -1314,7 +1299,7 @@ impl ProfileManager {
.iter()
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
} else {
// For Chromium-based browsers, check for user-data-dir
// For Chromium-based browsers (Wayfern), check for user-data-dir
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
}
+295 -91
View File
@@ -4,22 +4,38 @@ use std::collections::HashSet;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use crate::browser::BrowserType;
use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedProfile {
pub browser: String,
pub mapped_browser: String,
pub name: String,
pub path: String,
pub description: String,
}
fn map_browser_type(browser: &str) -> &str {
match browser {
"firefox" | "firefox-developer" | "zen" => "camoufox",
"chromium" | "brave" => "wayfern",
"camoufox" => "camoufox",
"wayfern" => "wayfern",
_ => "wayfern",
}
}
pub struct ProfileImporter {
base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
profile_manager: &'static ProfileManager,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
impl ProfileImporter {
@@ -28,6 +44,8 @@ impl ProfileImporter {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
profile_manager: ProfileManager::instance(),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
}
}
@@ -35,31 +53,18 @@ impl ProfileImporter {
&PROFILE_IMPORTER
}
/// Detect existing browser profiles on the system
pub fn detect_existing_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new();
// Detect Firefox profiles
detected_profiles.extend(self.detect_firefox_profiles()?);
// Detect Chrome profiles
detected_profiles.extend(self.detect_chrome_profiles()?);
// Detect Brave profiles
detected_profiles.extend(self.detect_brave_profiles()?);
// Detect Firefox Developer Edition profiles
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
// Detect Chromium profiles
detected_profiles.extend(self.detect_chromium_profiles()?);
// Detect Zen Browser profiles
detected_profiles.extend(self.detect_zen_browser_profiles()?);
// Remove duplicates based on path
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
.into_iter()
@@ -69,7 +74,6 @@ impl ProfileImporter {
Ok(unique_profiles)
}
/// Detect Firefox profiles
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -84,12 +88,10 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
// Primary location in AppData\Roaming
let app_data = self.base_dirs.data_dir();
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
// Also check AppData\Local for portable installations
let local_app_data = self.base_dirs.data_local_dir();
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
if firefox_local_dir.exists() {
@@ -106,7 +108,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Firefox Developer Edition profiles
fn detect_firefox_developer_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
@@ -114,13 +115,11 @@ impl ProfileImporter {
#[cfg(target_os = "macos")]
{
// Firefox Developer Edition on macOS uses separate profile directories
let firefox_dev_alt_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox Developer Edition/Profiles");
// Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates
if firefox_dev_alt_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
}
@@ -129,7 +128,6 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
// Firefox Developer Edition on Windows typically uses separate directories
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
@@ -138,7 +136,6 @@ impl ProfileImporter {
#[cfg(target_os = "linux")]
{
// Firefox Developer Edition on Linux uses separate directories
let firefox_dev_dir = self
.base_dirs
.home_dir()
@@ -151,7 +148,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Chrome profiles
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -180,7 +176,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Chromium profiles
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -209,7 +204,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Brave profiles
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -241,7 +235,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Zen Browser profiles
fn detect_zen_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
@@ -272,7 +265,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Scan Firefox-style profiles directory
fn scan_firefox_profiles_dir(
&self,
profiles_dir: &Path,
@@ -284,7 +276,6 @@ impl ProfileImporter {
return Ok(profiles);
}
// Read profiles.ini file if it exists
let profiles_ini = profiles_dir
.parent()
.unwrap_or(profiles_dir)
@@ -295,7 +286,6 @@ impl ProfileImporter {
}
}
// Also scan directory for any profile folders not in profiles.ini
if let Ok(entries) = fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -307,11 +297,11 @@ impl ProfileImporter {
.and_then(|n| n.to_str())
.unwrap_or("Unknown Profile");
// Check if this profile was already found in profiles.ini
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
if !already_added {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} Profile - {}",
self.get_browser_display_name(browser_type),
@@ -329,7 +319,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Parse Firefox profiles.ini file
fn parse_firefox_profiles_ini(
&self,
content: &str,
@@ -346,7 +335,6 @@ impl ProfileImporter {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
// Save previous profile if complete
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
@@ -370,6 +358,7 @@ impl ProfileImporter {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
@@ -377,7 +366,6 @@ impl ProfileImporter {
}
}
// Start new section
current_section = line[1..line.len() - 1].to_string();
profile_name.clear();
profile_path.clear();
@@ -398,7 +386,6 @@ impl ProfileImporter {
}
}
// Handle last profile
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
@@ -422,6 +409,7 @@ impl ProfileImporter {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
@@ -432,7 +420,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Scan Chrome-style profiles directory
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
@@ -444,11 +431,11 @@ impl ProfileImporter {
return Ok(profiles);
}
// Check for Default profile
let default_profile = browser_dir.join("Default");
if default_profile.exists() && default_profile.join("Preferences").exists() {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} - Default Profile",
self.get_browser_display_name(browser_type)
@@ -458,7 +445,6 @@ impl ProfileImporter {
});
}
// Check for Profile X directories
if let Ok(entries) = fs::read_dir(browser_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -466,9 +452,10 @@ impl ProfileImporter {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
let profile_number = &dir_name[8..]; // Remove "Profile " prefix
let profile_number = &dir_name[8..];
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} - Profile {}",
self.get_browser_display_name(browser_type),
@@ -485,7 +472,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Get browser display name
fn get_browser_display_name(&self, browser_type: &str) -> &str {
match browser_type {
"firefox" => "Firefox",
@@ -493,28 +479,36 @@ impl ProfileImporter {
"chromium" => "Chrome/Chromium",
"brave" => "Brave",
"zen" => "Zen Browser",
"camoufox" => "Camoufox",
"wayfern" => "Wayfern",
_ => "Unknown Browser",
}
}
/// Import a profile from an existing browser profile
pub fn import_profile(
#[allow(clippy::too_many_arguments)]
pub async fn import_profile(
&self,
app_handle: &tauri::AppHandle,
source_path: &str,
browser_type: &str,
new_profile_name: &str,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
// Validate that source path exists
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err("Source profile path does not exist".into());
}
// Validate browser type
let _browser_type = BrowserType::from_str(browser_type)
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
let mapped = map_browser_type(browser_type);
if let Some(ref pid) = proxy_id {
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
crate::cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
}
}
// Check if a profile with this name already exists
let existing_profiles = self.profile_manager.list_profiles()?;
if existing_profiles
.iter()
@@ -523,7 +517,6 @@ impl ProfileImporter {
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
}
// Generate UUID for new profile and create the directory structure
let profile_id = uuid::Uuid::new_v4();
let profiles_dir = self.profile_manager.get_profiles_dir();
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
@@ -532,32 +525,227 @@ impl ProfileImporter {
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Copy all files from source to destination profile subdirectory
Self::copy_directory_recursive(source_path, &new_profile_data_dir)?;
// Create the profile metadata without overwriting the imported data
// We need to find a suitable version for this browser type
let available_versions = self.get_default_version_for_browser(browser_type)?;
let version = self.get_default_version_for_browser(mapped)?;
let profile = crate::profile::BrowserProfile {
let final_camoufox_config = if mapped == "camoufox" {
let mut config = camoufox_config.unwrap_or_default();
if config.executable_path.is_none() {
let mut browser_dir = self.profile_manager.get_binaries_dir();
browser_dir.push(mapped);
browser_dir.push(&version);
#[cfg(target_os = "macos")]
let binary_path = browser_dir
.join("Camoufox.app")
.join("Contents")
.join("MacOS")
.join("camoufox");
#[cfg(target_os = "windows")]
let binary_path = browser_dir.join("camoufox.exe");
#[cfg(target_os = "linux")]
let binary_path = browser_dir.join("camoufox");
config.executable_path = Some(binary_path.to_string_lossy().to_string());
}
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
match self
.camoufox_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default();
if config.executable_path.is_none() {
let mut browser_dir = self.profile_manager.get_binaries_dir();
browser_dir.push(mapped);
browser_dir.push(&version);
#[cfg(target_os = "macos")]
let binary_path = browser_dir
.join("Chromium.app")
.join("Contents")
.join("MacOS")
.join("Chromium");
#[cfg(target_os = "windows")]
let binary_path = browser_dir.join("chrome.exe");
#[cfg(target_os = "linux")]
let binary_path = browser_dir.join("chrome");
config.executable_path = Some(binary_path.to_string_lossy().to_string());
}
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
match self
.wayfern_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
let profile = BrowserProfile {
id: profile_id,
name: new_profile_name.to_string(),
browser: browser_type.to_string(),
version: available_versions,
proxy_id: None,
browser: mapped.to_string(),
version,
proxy_id,
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
camoufox_config: final_camoufox_config,
wayfern_config: final_wayfern_config,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: crate::profile::types::SyncMode::Disabled,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: Some(crate::profile::types::get_host_os()),
host_os: Some(get_host_os()),
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
@@ -565,7 +753,6 @@ impl ProfileImporter {
created_by_email: None,
};
// Save the profile metadata
self.profile_manager.save_profile(&profile)?;
log::info!(
@@ -577,12 +764,10 @@ impl ProfileImporter {
Ok(())
}
/// Get a default version for a browser type
fn get_default_version_for_browser(
&self,
browser_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Check if any version of the browser is downloaded
let downloaded_versions = self
.downloaded_browsers_registry
.get_downloaded_versions(browser_type);
@@ -591,15 +776,16 @@ impl ProfileImporter {
return Ok(version.clone());
}
// If no downloaded versions found, return an error
Err(format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
).into())
Err(
format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
)
.into(),
)
}
/// Recursively copy directory contents
pub fn copy_directory_recursive(
source: &Path,
destination: &Path,
@@ -624,7 +810,6 @@ impl ProfileImporter {
}
}
// Tauri commands
#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
let importer = ProfileImporter::instance();
@@ -635,17 +820,41 @@ pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String>
#[tauri::command]
pub async fn import_browser_profile(
app_handle: tauri::AppHandle,
source_path: String,
browser_type: String,
new_profile_name: String,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), String> {
let fingerprint_os = camoufox_config
.as_ref()
.and_then(|c| c.os.as_deref())
.or_else(|| wayfern_config.as_ref().and_then(|c| c.os.as_deref()));
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let importer = ProfileImporter::instance();
importer
.import_profile(&source_path, &browser_type, &new_profile_name)
.import_profile(
&app_handle,
&source_path,
&browser_type,
&new_profile_name,
proxy_id,
camoufox_config,
wayfern_config,
)
.await
.map_err(|e| format!("Failed to import profile: {e}"))
}
// Global singleton instance
lazy_static::lazy_static! {
static ref PROFILE_IMPORTER: ProfileImporter = ProfileImporter::new();
}
@@ -658,10 +867,7 @@ mod tests {
fn create_test_profile_importer() -> (ProfileImporter, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
// Set up a temporary home directory for testing
env::set_var("HOME", temp_dir.path());
let importer = ProfileImporter::new();
(importer, temp_dir)
}
@@ -669,7 +875,6 @@ mod tests {
#[test]
fn test_profile_importer_creation() {
let (_importer, _temp_dir) = create_test_profile_importer();
// Test passes if no panic occurs
}
#[test]
@@ -693,19 +898,25 @@ mod tests {
);
}
#[test]
fn test_map_browser_type() {
assert_eq!(map_browser_type("firefox"), "camoufox");
assert_eq!(map_browser_type("firefox-developer"), "camoufox");
assert_eq!(map_browser_type("zen"), "camoufox");
assert_eq!(map_browser_type("chromium"), "wayfern");
assert_eq!(map_browser_type("brave"), "wayfern");
assert_eq!(map_browser_type("camoufox"), "camoufox");
assert_eq!(map_browser_type("wayfern"), "wayfern");
assert_eq!(map_browser_type("something_else"), "wayfern");
}
#[test]
fn test_detect_existing_profiles_no_panic() {
let (importer, _temp_dir) = create_test_profile_importer();
// This should not panic even if no browser profiles exist
let result = importer.detect_existing_profiles();
assert!(result.is_ok(), "detect_existing_profiles should not fail");
let _profiles = result.unwrap();
// We can't assert specific profiles since they depend on the system
// but we can verify the result is a valid Vec
// We can't assert specific profiles since they depend on the system
// but we can verify the result is a valid Vec (length check is always true for Vec, but shows intent)
}
#[test]
@@ -764,12 +975,10 @@ mod tests {
fn test_parse_firefox_profiles_ini_valid() {
let (importer, temp_dir) = create_test_profile_importer();
// Create a mock profile directory
let profiles_dir = temp_dir.path().join("profiles");
let profile_dir = profiles_dir.join("test.profile");
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
// Create a prefs.js file to make it look like a valid profile
let prefs_file = profile_dir.join("prefs.js");
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
@@ -788,31 +997,27 @@ Path=test.profile
assert_eq!(profiles.len(), 1, "Should find one profile");
assert_eq!(profiles[0].name, "Firefox - Test Profile");
assert_eq!(profiles[0].browser, "firefox");
assert_eq!(profiles[0].mapped_browser, "camoufox");
}
#[test]
fn test_copy_directory_recursive() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
// Create source directory structure
let source_dir = temp_dir.path().join("source");
let source_subdir = source_dir.join("subdir");
fs::create_dir_all(&source_subdir).expect("Should create source directories");
// Create some test files
let source_file1 = source_dir.join("file1.txt");
let source_file2 = source_subdir.join("file2.txt");
fs::write(&source_file1, "content1").expect("Should create file1");
fs::write(&source_file2, "content2").expect("Should create file2");
// Create destination directory
let dest_dir = temp_dir.path().join("dest");
// Copy recursively
let result = ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir);
assert!(result.is_ok(), "Should copy directory successfully");
// Verify files were copied
let dest_file1 = dest_dir.join("file1.txt");
let dest_file2 = dest_dir.join("subdir").join("file2.txt");
@@ -830,8 +1035,7 @@ Path=test.profile
fn test_get_default_version_for_browser_no_versions() {
let (importer, _temp_dir) = create_test_profile_importer();
// This should fail since no versions are downloaded in test environment
let result = importer.get_default_version_for_browser("firefox");
let result = importer.get_default_version_for_browser("camoufox");
assert!(
result.is_err(),
"Should fail when no versions are available"
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -1062,14 +1062,16 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
let matcher = bypass_matcher.clone();
tokio::task::spawn(async move {
// Read first bytes to detect CONNECT requests
// CONNECT requests need special handling for tunneling
// Use a larger buffer to ensure we can detect CONNECT even with partial reads
// Wait for the stream to have readable data before attempting to read.
// This prevents read() from returning 0 on a fresh connection before
// the client's data arrives.
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {
log::error!("DEBUG: Connection closed immediately (0 bytes read)");
}
Ok(0) => {}
Ok(n) => {
// Check if this looks like a CONNECT request
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
+52 -1
View File
@@ -42,7 +42,7 @@ impl ProxyConfig {
}
pub fn get_storage_dir() -> PathBuf {
crate::app_dirs::proxies_dir()
crate::app_dirs::proxy_workers_dir()
}
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
@@ -137,3 +137,54 @@ pub fn is_process_running(pid: u32) -> bool {
);
system.process(sysinfo::Pid::from_u32(pid)).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_process_running_detects_current_process() {
let pid = std::process::id();
assert!(
is_process_running(pid),
"is_process_running must detect the current process (PID {pid})"
);
}
#[test]
fn test_is_process_running_returns_false_for_dead_pid() {
// Spawn a short-lived child and wait for it to exit
let child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
vec!["/C", "exit"]
} else {
vec![]
})
.spawn()
.expect("failed to spawn child");
let pid = child.id();
let mut child = child;
child.wait().expect("child failed");
assert!(
!is_process_running(pid),
"is_process_running must return false for a dead process (PID {pid})"
);
}
#[test]
fn test_is_process_running_returns_false_for_nonexistent_pid() {
// PID 0 is the "System Idle Process" on Windows and sysinfo reports it as running,
// so only assert on non-Windows platforms where PID 0 is not a real user process.
#[cfg(not(windows))]
assert!(
!is_process_running(0),
"is_process_running must return false for PID 0"
);
// Very high PID unlikely to exist
assert!(
!is_process_running(u32::MAX),
"is_process_running must return false for PID u32::MAX"
);
}
}
+28 -12
View File
@@ -55,6 +55,8 @@ pub struct AppSettings {
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub disable_auto_updates: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -89,6 +91,7 @@ impl Default for AppSettings {
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
}
}
}
@@ -197,7 +200,7 @@ impl SettingsManager {
) -> 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;
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
@@ -387,7 +390,7 @@ impl SettingsManager {
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token_bytes: [u8; 32] = {
use rand::RngCore;
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
@@ -731,11 +734,17 @@ pub async fn save_app_settings(
.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);
// Check if a token already exists on disk before generating a new one
let existing = manager.get_api_token(&app_handle).await.ok().flatten();
if let Some(t) = existing {
settings.api_token = Some(t);
} 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);
}
}
}
@@ -755,11 +764,17 @@ pub async fn save_app_settings(
.await
.map_err(|e| format!("Failed to store MCP token: {e}"))?;
} else {
let token = manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
// Check if a token already exists on disk before generating a new one
let existing = manager.get_mcp_token(&app_handle).await.ok().flatten();
if let Some(t) = existing {
settings.mcp_token = Some(t);
} else {
let token = manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
}
}
}
@@ -1020,6 +1035,7 @@ mod tests {
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
};
let save_result = manager.save_settings(&test_settings);
+94 -44
View File
@@ -127,6 +127,14 @@ impl SyncClient {
}
pub async fn list(&self, prefix: &str) -> SyncResult<ListResponse> {
self.list_page(prefix, None).await
}
async fn list_page(
&self,
prefix: &str,
continuation_token: Option<String>,
) -> SyncResult<ListResponse> {
let response = self
.client
.post(self.url("list"))
@@ -134,7 +142,7 @@ impl SyncClient {
.json(&ListRequest {
prefix: prefix.to_string(),
max_keys: Some(1000),
continuation_token: None,
continuation_token,
})
.send()
.await
@@ -152,6 +160,27 @@ impl SyncClient {
.map_err(|e| SyncError::SerializationError(e.to_string()))
}
/// List all objects under a prefix, paginating through all results
pub async fn list_all(&self, prefix: &str) -> SyncResult<Vec<ListObject>> {
let mut all_objects = Vec::new();
let mut continuation_token: Option<String> = None;
loop {
let response = self.list_page(prefix, continuation_token).await?;
all_objects.extend(response.objects);
if !response.is_truncated {
break;
}
continuation_token = response.next_continuation_token;
if continuation_token.is_none() {
break;
}
}
Ok(all_objects)
}
pub async fn upload_bytes(
&self,
presigned_url: &str,
@@ -210,63 +239,84 @@ impl SyncClient {
&self,
items: Vec<(String, Option<String>)>,
) -> SyncResult<PresignUploadBatchResponse> {
let request = PresignUploadBatchRequest {
items: items
.into_iter()
.map(|(key, content_type)| PresignUploadBatchItem { key, content_type })
.collect(),
expires_in: Some(3600),
};
let chunk_size = 500;
let mut all_items = Vec::new();
let response = self
.client
.post(self.url("presign-upload-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
for chunk in items.chunks(chunk_size) {
let request = PresignUploadBatchRequest {
items: chunk
.iter()
.map(|(key, content_type)| PresignUploadBatchItem {
key: key.clone(),
content_type: content_type.clone(),
})
.collect(),
expires_in: Some(3600),
};
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
let response = self
.client
.post(self.url("presign-upload-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
let batch_response: PresignUploadBatchResponse = response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
all_items.extend(batch_response.items);
}
response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))
Ok(PresignUploadBatchResponse { items: all_items })
}
pub async fn presign_download_batch(
&self,
keys: Vec<String>,
) -> SyncResult<PresignDownloadBatchResponse> {
let request = PresignDownloadBatchRequest {
keys,
expires_in: Some(3600),
};
let chunk_size = 500;
let mut all_items = Vec::new();
let response = self
.client
.post(self.url("presign-download-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
for chunk in keys.chunks(chunk_size) {
let request = PresignDownloadBatchRequest {
keys: chunk.to_vec(),
expires_in: Some(3600),
};
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
let response = self
.client
.post(self.url("presign-download-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
let batch_response: PresignDownloadBatchResponse = response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
all_items.extend(batch_response.items);
}
response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))
Ok(PresignDownloadBatchResponse { items: all_items })
}
pub async fn delete_prefix(
File diff suppressed because it is too large Load Diff
+150 -11
View File
@@ -9,24 +9,44 @@ use std::time::SystemTime;
use super::types::{SyncError, SyncResult};
/// Default exclude patterns for volatile Chromium profile files
/// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync
/// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`.
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"Cache/**",
"Code Cache/**",
"GPUCache/**",
"GrShaderCache/**",
"ShaderCache/**",
"Service Worker/CacheStorage/**",
"Crashpad/**",
"Crash Reports/**",
"BrowserMetrics/**",
"blob_storage/**",
// Chromium caches (re-downloadable / re-generated)
"**/Cache/**",
"**/Code Cache/**",
"**/GPUCache/**",
"**/GrShaderCache/**",
"**/ShaderCache/**",
"**/DawnCache/**",
"**/DawnGraphiteCache/**",
"**/Service Worker/CacheStorage/**",
"**/Service Worker/ScriptCache/**",
// Chromium transient / volatile data
"**/Session Storage/**",
"**/blob_storage/**",
"**/Crashpad/**",
"**/Crash Reports/**",
"**/BrowserMetrics/**",
"**/optimization_guide_model_store/**",
"**/Safe Browsing/**",
"**/component_crx_cache/**",
// Firefox/Camoufox caches (re-downloadable / re-generated)
"**/cache2/**",
"**/startupCache/**",
"**/safebrowsing/**",
"**/storage/temporary/**",
"**/crashes/**",
"**/minidumps/**",
// Common volatile files
"*.log",
"*.tmp",
"**/LOG",
"**/LOG.old",
"**/LOCK",
"**/*-journal",
"**/*-wal",
".donut-sync/**",
];
@@ -388,6 +408,19 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
let remote_files: HashMap<&str, &ManifestFileEntry> =
remote.files.iter().map(|f| (f.path.as_str(), f)).collect();
// Safety: if local is empty but remote has files, always download from remote.
// This prevents data loss when profile data files are deleted but metadata
// survives — the newly generated manifest would have updated_at=NOW, which
// would appear "newer" and cause all remote files to be deleted.
if local.files.is_empty() && !remote.files.is_empty() {
log::info!(
"Local manifest is empty but remote has {} files — downloading from remote to recover",
remote.files.len()
);
diff.files_to_download = remote.files.clone();
return diff;
}
// Compare timestamps to determine direction
let local_updated = local.updated_at_datetime();
let remote_updated = remote.updated_at_datetime();
@@ -528,6 +561,66 @@ mod tests {
assert_eq!(manifest.files[0].path, "file1.txt");
}
#[test]
fn test_generate_manifest_excludes_nested_caches() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile_root");
fs::create_dir_all(&profile_dir).unwrap();
// Simulate real Chromium structure: profile/Default/Cache/...
let default_dir = profile_dir.join("profile/Default");
fs::create_dir_all(&default_dir).unwrap();
fs::write(default_dir.join("Cookies"), "keep").unwrap();
fs::create_dir_all(default_dir.join("Cache")).unwrap();
fs::write(default_dir.join("Cache/data_0"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Code Cache/js")).unwrap();
fs::write(default_dir.join("Code Cache/js/abc"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("GPUCache")).unwrap();
fs::write(default_dir.join("GPUCache/data_0"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Session Storage")).unwrap();
fs::write(default_dir.join("Session Storage/000003.log"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Local Storage/leveldb")).unwrap();
fs::write(default_dir.join("Local Storage/leveldb/000001.ldb"), "keep").unwrap();
// Caches at user-data-dir level
fs::create_dir_all(profile_dir.join("profile/ShaderCache")).unwrap();
fs::write(profile_dir.join("profile/ShaderCache/data"), "exclude").unwrap();
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root
fs::write(profile_dir.join("metadata.json"), "keep").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
assert!(
paths.contains(&"metadata.json"),
"metadata.json should be synced"
);
assert!(
paths.contains(&"profile/Default/Cookies"),
"Cookies should be synced"
);
assert!(
paths.contains(&"profile/Default/Local Storage/leveldb/000001.ldb"),
"Local Storage should be synced"
);
assert!(
!paths.iter().any(|p| p.contains("Cache")),
"Cache directories should be excluded: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.contains("Session Storage")),
"Session Storage should be excluded: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.contains("Crashpad")),
"Crashpad should be excluded: {paths:?}"
);
}
#[test]
fn test_compute_diff_upload_all_when_no_remote() {
let local = SyncManifest {
@@ -658,4 +751,50 @@ mod tests {
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
assert!(deserialized.encrypted);
}
#[test]
fn test_compute_diff_empty_local_downloads_from_remote() {
// When local has no files but remote does, always download from remote.
// This prevents data loss when profile data is deleted but metadata survives.
let local = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: Utc::now().to_rfc3339(),
updated_at: Utc::now().to_rfc3339(), // NOW — appears newer than remote
exclude_globs: vec![],
files: vec![],
encrypted: false,
};
let remote = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
exclude_globs: vec![],
files: vec![
ManifestFileEntry {
path: "Cookies".to_string(),
size: 100,
mtime: 1000,
hash: "abc".to_string(),
},
ManifestFileEntry {
path: "Local State".to_string(),
size: 200,
mtime: 1000,
hash: "def".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, Some(&remote));
// Must download all remote files, NOT delete them
assert_eq!(diff.files_to_download.len(), 2);
assert!(diff.files_to_upload.is_empty());
assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty());
}
}
+145 -92
View File
@@ -164,10 +164,24 @@ impl SyncScheduler {
let profile_manager = ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
return profile.process_id.is_some();
if profile.process_id.is_some() {
return true;
}
}
}
// Check if locked by another team member (profile in use remotely)
if crate::team_lock::TEAM_LOCK
.is_locked_by_another(profile_id)
.await
{
log::debug!(
"Profile {} is locked by another team member, treating as running",
profile_id
);
return true;
}
false
}
@@ -276,17 +290,38 @@ impl SyncScheduler {
for profile in sync_enabled_profiles {
let profile_id = profile.id.to_string();
let is_running = profile.process_id.is_some();
let is_team_locked = crate::team_lock::TEAM_LOCK
.is_locked_by_another(&profile_id)
.await;
let should_wait = is_running || is_team_locked;
// Track running state in the scheduler
if is_running {
self.mark_profile_running(&profile_id).await;
}
if should_wait {
log::info!(
"Profile '{}' is {} — will sync after it becomes available",
profile.name,
if is_running {
"running locally"
} else {
"locked by a team member"
}
);
}
// Emit initial status
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": if is_running { "waiting" } else { "syncing" }
"status": if should_wait { "waiting" } else { "syncing" }
}),
);
// Queue for immediate sync (or wait if running)
// Queue for sync — running profiles will be deferred by the scheduler
self.queue_profile_sync_immediate(profile_id).await;
}
}
@@ -361,97 +396,112 @@ impl SyncScheduler {
ready
};
// Mark all profiles as in-flight and filter out duplicates
let mut to_sync = Vec::new();
for profile_id in profiles_to_sync {
// Mark as in-flight to prevent duplicate syncs
{
let mut in_flight = self.in_flight_profiles.lock().await;
if in_flight.contains(&profile_id) {
log::debug!("Profile {} already in-flight, skipping", profile_id);
continue;
}
in_flight.insert(profile_id.clone());
}
log::info!("Executing queued sync for profile {}", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "syncing"
}),
);
let profile_to_sync = {
let profile_manager = ProfileManager::instance();
profile_manager.list_profiles().ok().and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
})
};
let Some(profile) = profile_to_sync else {
// Remove from in-flight
let mut in_flight = self.in_flight_profiles.lock().await;
in_flight.remove(&profile_id);
let mut in_flight = self.in_flight_profiles.lock().await;
if in_flight.contains(&profile_id) {
log::debug!("Profile {} already in-flight, skipping", profile_id);
continue;
};
let result = match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => engine.sync_profile(app_handle, &profile).await,
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
Err(super::types::SyncError::NotConfigured)
}
};
// Remove from in-flight and check if sync just completed
let sync_just_completed = {
let mut in_flight = self.in_flight_profiles.lock().await;
in_flight.remove(&profile_id);
// If this was the last in-flight profile and there are no pending profiles, sync just completed
in_flight.is_empty()
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
match result {
Ok(()) => {
log::info!("Profile {} synced successfully", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "error",
"error": e.to_string()
}),
);
}
}
in_flight.insert(profile_id.clone());
to_sync.push(profile_id);
}
// Trigger cleanup after sync completes if this was the last profile
if sync_just_completed {
log::debug!("All profile syncs completed, triggering cleanup");
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
// Sync all profiles in parallel
let mut sync_set = tokio::task::JoinSet::new();
for profile_id in to_sync {
let app = app_handle.clone();
let in_flight = self.in_flight_profiles.clone();
sync_set.spawn(async move {
log::info!("Executing queued sync for profile {}", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "syncing"
}),
);
let profile_to_sync = {
let profile_manager = ProfileManager::instance();
profile_manager.list_profiles().ok().and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
})
};
let Some(profile) = profile_to_sync else {
let mut inf = in_flight.lock().await;
inf.remove(&profile_id);
return;
};
let result = match SyncEngine::create_from_settings(&app).await {
Ok(engine) => engine.sync_profile(&app, &profile).await,
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
Err(super::types::SyncError::NotConfigured)
}
};
{
let mut inf = in_flight.lock().await;
inf.remove(&profile_id);
}
match result {
Ok(()) => {
log::info!("Profile {} synced successfully", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "error",
"error": e.to_string()
}),
);
}
}
});
}
// Wait for all parallel syncs to finish
while let Some(result) = sync_set.join_next().await {
if let Err(e) = result {
log::error!("Profile sync task panicked: {e}");
}
}
// Trigger cleanup if everything is done
let all_done = {
let in_flight = self.in_flight_profiles.lock().await;
in_flight.is_empty()
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
if all_done {
log::debug!("All profile syncs completed, triggering cleanup");
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
}
}
}
@@ -497,7 +547,8 @@ impl SyncScheduler {
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
@@ -563,7 +614,8 @@ impl SyncScheduler {
"group-sync-status",
serde_json::json!({
"id": group_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
@@ -626,7 +678,8 @@ impl SyncScheduler {
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -143,12 +143,7 @@ impl VersionUpdater {
pub async fn check_and_run_startup_update(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Only run if an update is actually needed
if !Self::should_run_background_update() {
log::debug!("No startup version update needed");
return Ok(());
}
// Always check for updates on launch
if let Some(ref app_handle) = self.app_handle {
log::info!("Running startup version update...");
+6 -6
View File
@@ -73,11 +73,11 @@ struct WgRxToken {
}
impl RxToken for WgRxToken {
fn consume<R, F>(mut self, f: F) -> R
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
F: FnOnce(&[u8]) -> R,
{
f(&mut self.data)
f(&self.data)
}
}
@@ -173,7 +173,7 @@ fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> {
))
}
std::net::IpAddr::V6(v6) => {
let smol_ip = smoltcp::wire::Ipv6Address::from_bytes(&v6.octets());
let smol_ip = smoltcp::wire::Ipv6Address::from(v6.octets());
Ok((
IpCidr::new(IpAddress::Ipv6(smol_ip), prefix),
IpAddress::Ipv6(smol_ip),
@@ -331,7 +331,7 @@ impl WireGuardSocks5Server {
// Set default gateway
match local_ip {
IpAddress::Ipv4(v4) => {
let octets = v4.as_bytes();
let octets = v4.octets();
let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1);
iface
.routes_mut()
@@ -523,7 +523,7 @@ impl WireGuardSocks5Server {
IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3]))
}
std::net::IpAddr::V6(v6) => {
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from_bytes(&v6.octets()))
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from(v6.octets()))
}
};
+1 -1
View File
@@ -6,7 +6,7 @@ use aes_gcm::{
Aes256Gcm, Nonce,
};
use chrono::Utc;
use rand::Rng;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
+157 -22
View File
@@ -311,12 +311,18 @@ impl WayfernManager {
"windows"
});
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
let mut refresh_params = json!({ "operatingSystem": os });
if let Some(ref token) = wayfern_token {
refresh_params
.as_object_mut()
.unwrap()
.insert("wayfernToken".to_string(), json!(token));
}
let refresh_result = self
.send_cdp_command(
&ws_url,
"Wayfern.refreshFingerprint",
json!({ "operatingSystem": os }),
)
.send_cdp_command(&ws_url, "Wayfern.refreshFingerprint", refresh_params)
.await;
if let Err(e) = refresh_result {
@@ -336,18 +342,69 @@ impl WayfernManager {
// Normalize the fingerprint: convert JSON string fields to proper types
let mut normalized = Self::normalize_fingerprint(fp);
// Add default timezone/geolocation if not present
// Wayfern's Bayesian network generator doesn't include these fields,
// so we need to add sensible defaults
if let Some(obj) = normalized.as_object_mut() {
if !obj.contains_key("timezone") {
obj.insert("timezone".to_string(), json!("America/New_York"));
// Apply geolocation based on proxy IP or geoip config
let geoip_option = config.geoip.as_ref();
let should_geolocate = match geoip_option {
Some(serde_json::Value::Bool(false)) => false,
_ => true, // Default to auto-detect
};
if should_geolocate {
let geo_result = async {
let ip = match geoip_option {
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
_ => {
// Auto-detect IP, optionally through proxy
crate::ip_utils::fetch_public_ip(config.proxy.as_deref())
.await
.map_err(|e| format!("Failed to fetch public IP: {e}"))?
}
};
crate::camoufox::geolocation::get_geolocation(&ip)
.map_err(|e| format!("Failed to get geolocation for IP {ip}: {e}"))
}
if !obj.contains_key("timezoneOffset") {
obj.insert("timezoneOffset".to_string(), json!(300)); // EST = UTC-5 = 300 minutes
.await;
match geo_result {
Ok(geo) => {
if let Some(obj) = normalized.as_object_mut() {
obj.insert("timezone".to_string(), json!(geo.timezone));
// Calculate timezone offset from IANA timezone name
if let Ok(tz) = geo.timezone.parse::<chrono_tz::Tz>() {
use chrono::Offset;
let now = chrono::Utc::now().with_timezone(&tz);
let offset_seconds = now.offset().fix().local_minus_utc();
let offset_minutes = -(offset_seconds / 60);
obj.insert("timezoneOffset".to_string(), json!(offset_minutes));
}
obj.insert("latitude".to_string(), json!(geo.latitude));
obj.insert("longitude".to_string(), json!(geo.longitude));
let locale_str = geo.locale.as_string();
obj.insert("language".to_string(), json!(&locale_str));
obj.insert(
"languages".to_string(),
json!([&locale_str, &geo.locale.language]),
);
}
log::info!(
"Applied geolocation to Wayfern fingerprint: {} ({})",
geo.locale.as_string(),
geo.timezone
);
}
Err(e) => {
log::warn!("Geolocation failed, using defaults: {e}");
if let Some(obj) = normalized.as_object_mut() {
if !obj.contains_key("timezone") {
obj.insert("timezone".to_string(), json!("America/New_York"));
}
if !obj.contains_key("timezoneOffset") {
obj.insert("timezoneOffset".to_string(), json!(300));
}
}
}
}
// Note: latitude/longitude are intentionally not set by default
// as they reveal precise location. Users should set these manually if needed.
}
normalized
@@ -397,6 +454,7 @@ impl WayfernManager {
proxy_url: Option<&str>,
ephemeral: bool,
extension_paths: &[String],
remote_debugging_port: Option<u16>,
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let executable_path = if let Some(path) = &config.executable_path {
let p = PathBuf::from(path);
@@ -414,7 +472,10 @@ impl WayfernManager {
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
};
let port = Self::find_free_port().await?;
let port = match remote_debugging_port {
Some(p) => p,
None => Self::find_free_port().await?,
};
log::info!("Launching Wayfern on CDP port {port}");
let mut args = vec![
@@ -453,6 +514,15 @@ impl WayfernManager {
args.push(format!("--load-extension={}", extension_paths.join(",")));
}
// Pass wayfern token as CLI flag so the browser can gate CDP features
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
} else {
log::warn!("No wayfern token available — CDP gated methods will be blocked");
}
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
// This ensures fingerprint is applied at navigation commit time
@@ -506,7 +576,15 @@ impl WayfernManager {
}
// Denormalize fingerprint for Wayfern CDP (convert arrays/objects to JSON strings)
let fingerprint_for_cdp = Self::denormalize_fingerprint(fingerprint);
let mut fingerprint_for_cdp = Self::denormalize_fingerprint(fingerprint);
// Normalize languages: if it's a comma-separated string, convert to array
if let Some(obj) = fingerprint_for_cdp.as_object_mut() {
if let Some(serde_json::Value::String(s)) = obj.get("languages").cloned() {
let arr: Vec<&str> = s.split(',').map(|l| l.trim()).collect();
obj.insert("languages".to_string(), json!(arr));
}
}
log::info!(
"Fingerprint prepared for CDP command, fields: {:?}",
@@ -528,16 +606,21 @@ impl WayfernManager {
);
}
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
let mut fingerprint_params = fingerprint_for_cdp.clone();
if let Some(ref token) = wayfern_token {
if let Some(obj) = fingerprint_params.as_object_mut() {
obj.insert("wayfernToken".to_string(), json!(token));
}
}
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
// Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped
match self
.send_cdp_command(
ws_url,
"Wayfern.setFingerprint",
fingerprint_for_cdp.clone(),
)
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => log::info!(
@@ -552,6 +635,38 @@ impl WayfernManager {
log::warn!("No fingerprint found in config, browser will use default fingerprint");
}
// Set geolocation override via CDP so navigator.geolocation.getCurrentPosition() matches
if let Some(fingerprint_json) = &config.fingerprint {
if let Ok(fp) = serde_json::from_str::<serde_json::Value>(fingerprint_json) {
let fp_obj = if fp.get("fingerprint").is_some() {
fp.get("fingerprint").unwrap()
} else {
&fp
};
if let (Some(lat), Some(lng)) = (
fp_obj.get("latitude").and_then(|v| v.as_f64()),
fp_obj.get("longitude").and_then(|v| v.as_f64()),
) {
let accuracy = fp_obj
.get("accuracy")
.and_then(|v| v.as_f64())
.unwrap_or(100.0);
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
let _ = self
.send_cdp_command(
ws_url,
"Emulation.setGeolocationOverride",
json!({ "latitude": lat, "longitude": lng, "accuracy": accuracy }),
)
.await;
log::info!("Set geolocation override: lat={lat}, lng={lng}");
}
}
}
}
}
// Navigate to URL via CDP - fingerprint will be applied at navigation commit time
if let Some(url) = url {
log::info!("Navigating to URL via CDP: {}", url);
@@ -658,6 +773,25 @@ impl WayfernManager {
Ok(())
}
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for instance in inner.instances.values() {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
return instance.cdp_port;
}
}
}
None
}
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
@@ -840,6 +974,7 @@ impl WayfernManager {
proxy_url,
profile.ephemeral,
&[],
None,
)
.await
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.15.0",
"version": "0.17.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+178 -1
View File
@@ -914,7 +914,7 @@ async fn test_bypass_rules_in_config() -> Result<(), Box<dyn std::error::Error +
sleep(Duration::from_millis(500)).await;
// Read the proxy config file from disk to verify bypass rules are persisted
let proxies_dir = donutbrowser_lib::app_dirs::proxies_dir();
let proxies_dir = donutbrowser_lib::app_dirs::proxy_workers_dir();
let config_file = proxies_dir.join(format!("{proxy_id}.json"));
assert!(
@@ -1121,3 +1121,180 @@ async fn test_no_bypass_rules_all_through_upstream(
Ok(())
}
/// Start a minimal SOCKS5 proxy that tunnels connections to the real destination.
/// Returns (port, JoinHandle).
async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
while let Ok((mut client, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// SOCKS5 handshake: client sends version + methods
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap_or(0);
if n < 2 || buf[0] != 0x05 {
return;
}
// Reply: version 5, no auth required
client.write_all(&[0x05, 0x00]).await.ok();
// Read connect request: VER CMD RSV ATYP DST.ADDR DST.PORT
let n = client.read(&mut buf).await.unwrap_or(0);
if n < 7 || buf[1] != 0x01 {
client
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.ok();
return;
}
let (target_host, target_port) = match buf[3] {
0x01 => {
// IPv4
if n < 10 {
return;
}
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
(ip, port)
}
0x03 => {
// Domain
let domain_len = buf[4] as usize;
if n < 5 + domain_len + 2 {
return;
}
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
let port = u16::from_be_bytes([buf[5 + domain_len], buf[6 + domain_len]]);
(domain, port)
}
_ => return,
};
// Connect to target
let target =
match tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)).await {
Ok(t) => t,
Err(_) => {
client
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.ok();
return;
}
};
// Success reply
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
.await
.ok();
// Bidirectional relay
let (mut cr, mut cw) = tokio::io::split(client);
let (mut tr, mut tw) = tokio::io::split(target);
tokio::select! {
_ = tokio::io::copy(&mut cr, &mut tw) => {}
_ = tokio::io::copy(&mut tr, &mut cw) => {}
}
});
}
});
sleep(Duration::from_millis(100)).await;
(port, handle)
}
/// Test that a SOCKS5 upstream proxy works end-to-end through donut-proxy.
/// Starts a mock SOCKS5 server, a mock HTTP target server,
/// then routes requests through donut-proxy -> SOCKS5 -> target.
#[tokio::test]
#[serial]
async fn test_local_proxy_with_socks5_upstream(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// Start a mock HTTP server as the final destination
let (target_port, target_handle) = start_mock_http_server("SOCKS5-TARGET-RESPONSE").await;
println!("Mock target HTTP server on port {target_port}");
// Start a mock SOCKS5 proxy
let (socks_port, socks_handle) = start_mock_socks5_server().await;
println!("Mock SOCKS5 server on port {socks_port}");
// Helper to start a socks5 proxy
async fn start_socks5_proxy(
binary_path: &std::path::PathBuf,
socks_port: u16,
) -> Result<(String, u16), Box<dyn std::error::Error + Send + Sync>> {
let output = TestUtils::execute_command(
binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&socks_port.to_string(),
"--type",
"socks5",
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Proxy start failed: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let id = config["id"].as_str().unwrap().to_string();
let port = config["localPort"].as_u64().unwrap() as u16;
// Wait for proxy to be fully ready by verifying it accepts and responds
for _ in 0..20 {
sleep(Duration::from_millis(100)).await;
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
break;
}
}
// Extra settle time for the accept loop to be fully initialized
sleep(Duration::from_millis(200)).await;
Ok((id, port))
}
// Test 1: HTTP request through donut-proxy -> SOCKS5 -> target
let (proxy_id, local_port) = start_socks5_proxy(&binary_path, socks_port).await?;
tracker.track_proxy(proxy_id);
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = vec![0u8; 8192];
let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut response))
.await
.map_err(|_| "HTTP request through SOCKS5 timed out")?
.map_err(|e| format!("Read error: {e}"))?;
let response_str = String::from_utf8_lossy(&response[..n]);
assert!(
response_str.contains("SOCKS5-TARGET-RESPONSE"),
"HTTP request should be tunneled through SOCKS5 to target, got: {}",
&response_str[..response_str.len().min(500)]
);
println!("SOCKS5 upstream proxy test passed");
tracker.cleanup_all().await;
target_handle.abort();
socks_handle.abort();
Ok(())
}
+207
View File
@@ -841,3 +841,210 @@ async fn test_profile_bypass_rules_sync() {
client.delete(&test_key, None).await.unwrap();
client.delete(&empty_key, None).await.unwrap();
}
#[tokio::test]
async fn test_encrypted_profile_sync() {
use donutbrowser_lib::sync::encryption::{
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
};
ensure_sync_server_available().await;
let client = TestClient::new();
let temp_dir = TempDir::new().unwrap();
let profile_id = uuid::Uuid::new_v4().to_string();
let test_key = format!("profiles/{}.tar.gz.enc", profile_id);
let bundle = create_test_profile_bundle(temp_dir.path());
let salt = generate_salt();
let password = "test-e2e-encryption-password";
let key = derive_profile_key(password, &salt).unwrap();
let encrypted = encrypt_bytes(&key, &bundle).unwrap();
assert_ne!(
encrypted, bundle,
"Encrypted data should differ from plaintext"
);
assert!(
encrypted.len() > bundle.len(),
"Encrypted data includes nonce + auth tag overhead"
);
let presign = client
.presign_upload(&test_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign.url, &encrypted, "application/octet-stream")
.await
.unwrap();
let stat = client.stat(&test_key).await.unwrap();
assert!(stat.exists);
assert_eq!(stat.size, Some(encrypted.len() as u64));
let download_presign = client.presign_download(&test_key).await.unwrap();
let downloaded = client.download_bytes(&download_presign.url).await.unwrap();
assert_eq!(downloaded.len(), encrypted.len());
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
assert_eq!(
decrypted, bundle,
"Decrypted content should match original bundle"
);
let extract_dir = temp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir).unwrap();
let metadata = extract_bundle(&decrypted, &extract_dir);
assert_eq!(metadata["id"], "test-profile-id");
assert_eq!(metadata["name"], "Test Profile");
assert_eq!(metadata["browser"], "chromium");
assert_eq!(metadata["version"], "120.0.0");
assert!(metadata["sync_enabled"].as_bool().unwrap());
let tags = metadata["tags"].as_array().unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0], "test");
assert_eq!(tags[1], "e2e");
let test_file = extract_dir.join("profile").join("test_file.txt");
assert!(test_file.exists());
assert_eq!(fs::read_to_string(test_file).unwrap(), "test content");
let wrong_key = derive_profile_key("wrong-password", &salt).unwrap();
assert!(
decrypt_bytes(&wrong_key, &downloaded).is_err(),
"Decryption with wrong key should fail"
);
let different_salt = generate_salt();
let wrong_salt_key = derive_profile_key(password, &different_salt).unwrap();
assert!(
decrypt_bytes(&wrong_salt_key, &downloaded).is_err(),
"Decryption with key derived from wrong salt should fail"
);
client.delete(&test_key, None).await.unwrap();
let final_stat = client.stat(&test_key).await.unwrap();
assert!(!final_stat.exists);
}
#[tokio::test]
async fn test_encrypted_delta_sync() {
use donutbrowser_lib::sync::encryption::{
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
};
ensure_sync_server_available().await;
let client = TestClient::new();
let profile_id = uuid::Uuid::new_v4().to_string();
let salt = generate_salt();
let password = "delta-sync-test-password";
let key = derive_profile_key(password, &salt).unwrap();
let file1_key = format!("profiles/{}/files/file1.txt.enc", profile_id);
let file2_key = format!("profiles/{}/files/file2.txt.enc", profile_id);
let file3_key = format!("profiles/{}/files/file3.txt.enc", profile_id);
let content1 = b"file one content";
let content2 = b"file two content";
let content3 = b"file three content";
let encrypted1 = encrypt_bytes(&key, content1).unwrap();
let encrypted2 = encrypt_bytes(&key, content2).unwrap();
let encrypted3 = encrypt_bytes(&key, content3).unwrap();
let presign1 = client
.presign_upload(&file1_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign1.url, &encrypted1, "application/octet-stream")
.await
.unwrap();
let presign2 = client
.presign_upload(&file2_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign2.url, &encrypted2, "application/octet-stream")
.await
.unwrap();
let presign3 = client
.presign_upload(&file3_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign3.url, &encrypted3, "application/octet-stream")
.await
.unwrap();
for (file_key, expected_content) in [
(&file1_key, content1.as_slice()),
(&file2_key, content2.as_slice()),
(&file3_key, content3.as_slice()),
] {
let dl_presign = client.presign_download(file_key).await.unwrap();
let downloaded = client.download_bytes(&dl_presign.url).await.unwrap();
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
assert_eq!(
decrypted, expected_content,
"Decrypted content mismatch for {file_key}"
);
}
let stat1_before = client.stat(&file1_key).await.unwrap();
let stat2_before = client.stat(&file2_key).await.unwrap();
let stat3_before = client.stat(&file3_key).await.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let updated_content2 = b"file two content -- updated with new data";
let encrypted2_updated = encrypt_bytes(&key, updated_content2).unwrap();
let presign2_update = client
.presign_upload(&file2_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(
&presign2_update.url,
&encrypted2_updated,
"application/octet-stream",
)
.await
.unwrap();
let stat2_after = client.stat(&file2_key).await.unwrap();
assert_ne!(
stat2_before.size, stat2_after.size,
"File2 size should have changed after update"
);
let stat1_after = client.stat(&file1_key).await.unwrap();
let stat3_after = client.stat(&file3_key).await.unwrap();
assert_eq!(
stat1_before.size, stat1_after.size,
"File1 should be unchanged"
);
assert_eq!(
stat3_before.size, stat3_after.size,
"File3 should be unchanged"
);
let dl_presign2 = client.presign_download(&file2_key).await.unwrap();
let downloaded2 = client.download_bytes(&dl_presign2.url).await.unwrap();
let decrypted2 = decrypt_bytes(&key, &downloaded2).unwrap();
assert_eq!(
decrypted2,
updated_content2.to_vec(),
"Updated file2 should decrypt to new content"
);
client.delete(&file1_key, None).await.unwrap();
client.delete(&file2_key, None).await.unwrap();
client.delete(&file3_key, None).await.unwrap();
}
+101 -62
View File
@@ -11,6 +11,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
import { GroupBadges } from "@/components/group-badges";
@@ -28,6 +29,7 @@ import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
@@ -38,6 +40,7 @@ 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 { useSyncSessions } from "@/hooks/use-sync-session";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
@@ -56,14 +59,7 @@ import type {
WayfernConfig,
} from "@/types";
type BrowserTypeString =
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "camoufox"
| "wayfern";
type BrowserTypeString = "camoufox" | "wayfern";
interface PendingUrl {
id: string;
@@ -96,6 +92,11 @@ export default function Home() {
const { vpnConfigs } = useVpnEvents();
// Synchronizer sessions
const { getProfileSyncInfo } = useSyncSessions();
const [syncLeaderProfile, setSyncLeaderProfile] =
useState<BrowserProfile | null>(null);
// Wayfern terms and commercial trial hooks
const {
termsAccepted,
@@ -146,6 +147,14 @@ export default function Home() {
useState(false);
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
useState(false);
const [
extensionGroupAssignmentDialogOpen,
setExtensionGroupAssignmentDialogOpen,
] = useState(false);
const [
selectedProfilesForExtensionGroup,
setSelectedProfilesForExtensionGroup,
] = useState<string[]>([]);
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
useState(false);
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
@@ -701,6 +710,22 @@ export default function Home() {
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignProfilesToGroup]);
const handleAssignExtensionGroup = useCallback((profileIds: string[]) => {
setSelectedProfilesForExtensionGroup(profileIds);
setExtensionGroupAssignmentDialogOpen(true);
}, []);
const handleBulkExtensionGroupAssignment = useCallback(() => {
if (selectedProfiles.length === 0) return;
handleAssignExtensionGroup(selectedProfiles);
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignExtensionGroup]);
const handleExtensionGroupAssignmentComplete = useCallback(() => {
setExtensionGroupAssignmentDialogOpen(false);
setSelectedProfilesForExtensionGroup([]);
}, []);
const handleAssignProfilesToProxy = useCallback((profileIds: string[]) => {
setSelectedProfilesForProxy(profileIds);
setProxyAssignmentDialogOpen(true);
@@ -784,31 +809,29 @@ export default function Home() {
useEffect(() => {
let unlistenStatus: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
const profilesWithTransfer = new Set<string>();
(async () => {
try {
unlistenStatus = await listen<{
profile_id: string;
status: string;
error?: string;
profile_name?: string;
}>("profile-sync-status", (event) => {
const { profile_id, status, error } = event.payload;
const { profile_id, status, error, profile_name } = event.payload;
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
const name = profile_name || profile?.name || "Unknown";
if (status === "syncing") {
showToast({
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: Number.POSITIVE_INFINITY,
onCancel: () => dismissToast(toastId),
});
} else if (status === "synced") {
if (status === "synced") {
dismissToast(toastId);
showSuccessToast(`Profile '${name}' synced successfully`);
if (profilesWithTransfer.has(profile_id)) {
profilesWithTransfer.delete(profile_id);
showSuccessToast(`Profile '${name}' synced successfully`);
}
} else if (status === "error") {
dismissToast(toastId);
profilesWithTransfer.delete(profile_id);
showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
);
@@ -820,17 +843,39 @@ export default function Home() {
phase: string;
total_files?: number;
total_bytes?: number;
completed_files?: number;
completed_bytes?: number;
speed_bytes_per_sec?: number;
eta_seconds?: number;
failed_count?: number;
profile_name?: string;
}>("profile-sync-progress", (event) => {
const { profile_id, phase, total_files, total_bytes } = event.payload;
if (phase !== "started") return;
const payload = event.payload;
const toastId = `sync-${payload.profile_id}`;
const profile = profiles.find((p) => p.id === payload.profile_id);
const name = payload.profile_name || profile?.name || "Unknown";
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
showSyncProgressToast(name, total_files ?? 0, total_bytes ?? 0, {
id: toastId,
});
if (
payload.phase === "started" ||
payload.phase === "uploading" ||
payload.phase === "downloading"
) {
profilesWithTransfer.add(payload.profile_id);
showSyncProgressToast(
name,
{
completed_files: payload.completed_files ?? 0,
total_files: payload.total_files ?? 0,
completed_bytes: payload.completed_bytes ?? 0,
total_bytes: payload.total_bytes ?? 0,
speed_bytes_per_sec: payload.speed_bytes_per_sec ?? 0,
eta_seconds: payload.eta_seconds ?? 0,
failed_count: payload.failed_count ?? 0,
phase: payload.phase,
},
{ id: toastId },
);
}
});
} catch (error) {
console.error("Failed to listen for sync events:", error);
@@ -896,37 +941,6 @@ export default function Home() {
profiles.length,
]);
// Show deprecation warning for unsupported profiles (with names)
useEffect(() => {
if (profiles.length === 0) return;
const deprecatedProfiles = profiles.filter(
(p) => p.release_type === "nightly" && p.browser !== "firefox-developer",
);
if (deprecatedProfiles.length > 0) {
const deprecatedNames = deprecatedProfiles.map((p) => p.name).join(", ");
// Use a stable id to avoid duplicate toasts on re-renders
showToast({
id: "deprecated-profiles-warning",
type: "error",
title: "Some profiles will be deprecated soon",
description: `The following profiles will be deprecated soon: ${deprecatedNames}. Nightly profiles (except Firefox Developers Edition) will be removed in upcoming versions. Please check GitHub for migration instructions.`,
duration: 15000,
action: {
label: "Learn more",
onClick: () => {
const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions/66",
});
window.dispatchEvent(event);
},
},
});
}
}, [profiles]);
// Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
useEffect(() => {
if (profiles.length === 0) return;
@@ -1042,6 +1056,7 @@ export default function Home() {
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
crossOsUnlocked={crossOsUnlocked}
/>
</div>
<div className="w-full mt-2.5">
@@ -1072,10 +1087,14 @@ export default function Home() {
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
onBulkCopyCookies={handleBulkCopyCookies}
onBulkExtensionGroupAssignment={handleBulkExtensionGroupAssignment}
onAssignExtensionGroup={handleAssignExtensionGroup}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => setSyncLeaderProfile(profile)}
/>
</div>
</main>
@@ -1113,6 +1132,7 @@ export default function Home() {
onClose={() => {
setImportProfileDialogOpen(false);
}}
crossOsUnlocked={crossOsUnlocked}
/>
<ProxyManagementDialog
@@ -1192,6 +1212,16 @@ export default function Home() {
profiles={profiles}
/>
<ExtensionGroupAssignmentDialog
isOpen={extensionGroupAssignmentDialogOpen}
onClose={() => {
setExtensionGroupAssignmentDialogOpen(false);
}}
selectedProfiles={selectedProfilesForExtensionGroup}
onAssignmentComplete={handleExtensionGroupAssignmentComplete}
profiles={profiles}
/>
<ProxyAssignmentDialog
isOpen={proxyAssignmentDialogOpen}
onClose={() => {
@@ -1269,13 +1299,14 @@ export default function Home() {
onAccepted={checkTerms}
/>
{/* Commercial Trial Modal - shown once when trial expires */}
{/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */}
<CommercialTrialModal
isOpen={
!termsLoading &&
termsAccepted === true &&
trialStatus?.type === "Expired" &&
!trialAcknowledged
!trialAcknowledged &&
!crossOsUnlocked
}
onClose={checkTrialStatus}
/>
@@ -1295,6 +1326,14 @@ export default function Home() {
windowResizeWarningResolver.current = null;
}}
/>
<SyncFollowerDialog
isOpen={syncLeaderProfile !== null}
onClose={() => setSyncLeaderProfile(null)}
leaderProfile={syncLeaderProfile}
allProfiles={profiles}
runningProfiles={runningProfiles}
/>
</div>
);
}
@@ -164,6 +164,8 @@ export function CamoufoxConfigDialog({
readOnly={isRunning}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={profile.version}
profileBrowser="wayfern"
/>
) : (
<SharedCamoufoxConfigForm
@@ -174,6 +176,8 @@ export function CamoufoxConfigDialog({
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={profile.version}
profileBrowser="camoufox"
/>
)}
</div>
+2 -2
View File
@@ -462,8 +462,8 @@ export function CookieManagementDialog({
{importResult && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10">
<div className="font-medium text-green-600 dark:text-green-400">
<div className="p-4 rounded-lg bg-success/10">
<div className="font-medium text-success">
Successfully imported {importResult.cookies_imported}{" "}
cookies ({importResult.cookies_replaced} replaced)
</div>
+1 -1
View File
@@ -91,7 +91,7 @@ export function CreateGroupDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+335 -207
View File
@@ -4,11 +4,22 @@ import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -18,13 +29,16 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -34,6 +48,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type {
BrowserReleaseTypes,
CamoufoxConfig,
@@ -52,14 +67,7 @@ const getCurrentOS = (): CamoufoxOS => {
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "camoufox"
| "wayfern";
type BrowserTypeString = "camoufox" | "wayfern";
interface CreateProfileDialogProps {
isOpen: boolean;
@@ -88,24 +96,12 @@ interface BrowserOption {
const browserOptions: BrowserOption[] = [
{
value: "firefox",
label: "Firefox",
value: "camoufox",
label: "Camoufox",
},
{
value: "firefox-developer",
label: "Firefox Developer Edition",
},
{
value: "chromium",
label: "Chromium",
},
{
value: "brave",
label: "Brave",
},
{
value: "zen",
label: "Zen Browser",
value: "wayfern",
label: "Wayfern",
},
];
@@ -127,6 +123,7 @@ export function CreateProfileDialog({
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>(null);
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
@@ -238,23 +235,9 @@ export function CreateProfileDialog({
// 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)
if (browser === "camoufox" || browser === "wayfern") {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable)
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
} else if (browser === "firefox-developer") {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.nightly)
filtered.nightly = rawReleaseTypes.nightly;
setReleaseTypes(filtered);
} else {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable)
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
}
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable) filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
setReleaseTypesError(null);
}
} catch (error) {
@@ -266,11 +249,7 @@ export function CreateProfileDialog({
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;
}
fallback.stable = latest;
setReleaseTypes(fallback);
setReleaseTypesError(null);
} else if (loadingBrowserRef.current === browser) {
@@ -335,17 +314,9 @@ export function CreateProfileDialog({
// Helper function to get the best available version respecting rules
const getBestAvailableVersion = useCallback(
(browserType?: string) => {
(_browserType?: string) => {
if (!releaseTypes) return null;
// Firefox Developer Edition: nightly-only
if (browserType === "firefox-developer" && releaseTypes.nightly) {
return {
version: releaseTypes.nightly,
releaseType: "nightly" as const,
};
}
// All others: stable-only
if (releaseTypes.stable) {
return { version: releaseTypes.stable, releaseType: "stable" as const };
}
@@ -363,11 +334,9 @@ export function CreateProfileDialog({
const browserDownloaded = downloadedVersionsMap[browserType ?? ""] ?? [];
if (browserDownloaded.length > 0) {
const fallbackVersion = browserDownloaded[0];
const releaseType =
browserType === "firefox-developer" ? "nightly" : "stable";
return {
version: fallbackVersion,
releaseType: releaseType as "stable" | "nightly",
releaseType: "stable" as const,
};
}
return null;
@@ -557,8 +526,13 @@ export function CreateProfileDialog({
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
? "Create New Profile"
: "Configure Profile"}
? t("createProfile.title")
: t("createProfile.configureTitle", {
browser:
selectedBrowser === "wayfern"
? t("createProfile.chromiumLabel")
: t("createProfile.firefoxLabel"),
})}
</DialogTitle>
</DialogHeader>
@@ -576,62 +550,54 @@ export function CreateProfileDialog({
<>
<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 a browser with anti-detection capabilities
</p>
</div>
<div className="space-y-3 pt-8">
{/* Wayfern (Chromium) - First */}
<Button
onClick={() => handleBrowserSelect("wayfern")}
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 IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.chromiumLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.chromiumSubtitle")}
</div>
</div>
</Button>
<div className="space-y-3">
{/* Wayfern (Chromium) - First */}
<Button
onClick={() => handleBrowserSelect("wayfern")}
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 IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
{/* Camoufox (Firefox) - Second */}
<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 IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-left">
<div className="font-medium">Wayfern</div>
<div className="text-sm text-muted-foreground">
Anti-Detect Browser
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.firefoxSubtitle")}
</div>
</Button>
{/* Camoufox (Firefox) - Second */}
<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 IconComponent =
getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
</div>
<div className="text-left">
<div className="font-medium">Camoufox</div>
<div className="text-sm text-muted-foreground">
Anti-Detect Browser
</div>
</div>
</Button>
</div>
</div>
</Button>
</div>
</TabsContent>
@@ -759,8 +725,8 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
<p className="text-sm text-yellow-500">
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
Wayfern is not available on your platform
yet.
</p>
@@ -823,6 +789,10 @@ export function CreateProfileDialog({
isCreating
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("wayfern")?.version
}
profileBrowser="wayfern"
/>
</div>
) : selectedBrowser === "camoufox" ? (
@@ -857,8 +827,8 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
<p className="text-sm text-yellow-500">
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
Camoufox is not available on your platform
yet.
</p>
@@ -915,6 +885,14 @@ export function CreateProfileDialog({
</div>
)}
{crossOsUnlocked && (
<Alert className="border-warning/50 bg-warning/10">
<AlertDescription className="text-sm">
{t("createProfile.camoufoxWarning")}
</AlertDescription>
</Alert>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
@@ -922,6 +900,10 @@ export function CreateProfileDialog({
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
</div>
) : (
@@ -1039,52 +1021,125 @@ export function CreateProfileDialog({
</RippleButton>
</div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(
value === "none" ? undefined : value,
)
}
<Popover
open={proxyPopoverOpen}
onOpenChange={setProxyPopoverOpen}
>
<SelectTrigger>
<SelectValue placeholder="No proxy / VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
No proxy / VPN
</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem
key={proxy.id}
value={proxy.id}
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
className="w-full justify-between font-normal"
>
{(() => {
if (!selectedProxyId)
return "No proxy / VPN";
if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find(
(v) =>
v.id === selectedProxyId.slice(4),
);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
: "No proxy / VPN";
}
const proxy = storedProxies.find(
(p) => p.id === selectedProxyId,
);
return proxy?.name ?? "No proxy / VPN";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput placeholder="Search proxies or VPNs..." />
<CommandList>
<CommandEmpty>
No proxies or VPNs found.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
setSelectedProxyId(undefined);
setProxyPopoverOpen(false);
}}
>
{proxy.name}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem
key={vpn.id}
value={`vpn-${vpn.id}`}
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}{" "}
{vpn.name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
)}
/>
None
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
setSelectedProxyId(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
{vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() => {
setSelectedProxyId(
`vpn-${vpn.id}`,
);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies or VPNs available. Add one to route
@@ -1257,52 +1312,125 @@ export function CreateProfileDialog({
</RippleButton>
</div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(
value === "none" ? undefined : value,
)
}
<Popover
open={proxyPopoverOpen}
onOpenChange={setProxyPopoverOpen}
>
<SelectTrigger>
<SelectValue placeholder="No proxy / VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
No proxy / VPN
</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem
key={proxy.id}
value={proxy.id}
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
className="w-full justify-between font-normal"
>
{(() => {
if (!selectedProxyId)
return "No proxy / VPN";
if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find(
(v) =>
v.id === selectedProxyId.slice(4),
);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
: "No proxy / VPN";
}
const proxy = storedProxies.find(
(p) => p.id === selectedProxyId,
);
return proxy?.name ?? "No proxy / VPN";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput placeholder="Search proxies or VPNs..." />
<CommandList>
<CommandEmpty>
No proxies or VPNs found.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
setSelectedProxyId(undefined);
setProxyPopoverOpen(false);
}}
>
{proxy.name}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem
key={vpn.id}
value={`vpn-${vpn.id}`}
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}{" "}
{vpn.name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
)}
/>
None
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
setSelectedProxyId(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
{vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() => {
setSelectedProxyId(
`vpn-${vpn.id}`,
);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies or VPNs available. Add one to route
+84 -2
View File
@@ -116,6 +116,20 @@ interface TwilightUpdateToastProps extends BaseToastProps {
hasUpdate?: boolean;
}
interface SyncProgressToastProps extends BaseToastProps {
type: "sync-progress";
progress?: {
completed_files: number;
total_files: number;
completed_bytes: number;
total_bytes: number;
speed_bytes_per_sec: number;
eta_seconds: number;
failed_count: number;
phase: string;
};
}
type ToastProps =
| LoadingToastProps
| SuccessToastProps
@@ -123,7 +137,38 @@ type ToastProps =
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps
| TwilightUpdateToastProps;
| TwilightUpdateToastProps
| SyncProgressToastProps;
function formatBytesCompact(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** i;
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
function formatSpeedCompact(bytesPerSec: number): string {
if (bytesPerSec >= 1024 * 1024) {
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
}
return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
}
function formatEtaCompact(seconds: number): string {
if (seconds >= 3600) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
if (seconds >= 60) {
return `${Math.floor(seconds / 60)} min`;
}
return `${Math.round(seconds)}s`;
}
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
@@ -153,6 +198,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
@@ -237,6 +286,39 @@ export function UnifiedToast(props: ToastProps) {
</div>
)}
{/* Sync progress */}
{type === "sync-progress" &&
progress &&
"completed_files" in progress && (
<div className="mt-1">
<p className="text-xs text-muted-foreground">
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "}
{progress.completed_files}/{progress.total_files} files
{" \u2022 "}
{formatBytesCompact(progress.completed_bytes)} /{" "}
{formatBytesCompact(progress.total_bytes)}
{progress.speed_bytes_per_sec > 0 && (
<>
{" \u2022 "}
{formatSpeedCompact(progress.speed_bytes_per_sec)}
</>
)}
{progress.eta_seconds > 0 &&
progress.completed_files < progress.total_files && (
<>
{" \u2022 ~"}
{formatEtaCompact(progress.eta_seconds)} remaining
</>
)}
</p>
{progress.failed_count > 0 && (
<p className="text-xs text-destructive mt-0.5">
{progress.failed_count} file(s) failed
</p>
)}
</div>
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
@@ -265,7 +347,7 @@ export function UnifiedToast(props: ToastProps) {
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground">
Extracting browser files...
Extracting browser files... Please do not close the app.
</p>
)}
{stage === "verifying" && (
+2 -2
View File
@@ -117,7 +117,7 @@ function DataTableActionBarAction({
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
className="border bg-accent font-semibold text-foreground dark:bg-card [&>span]:hidden"
>
<p>{tooltip}</p>
</TooltipContent>
@@ -155,7 +155,7 @@ function DataTableActionBarSelection<TData>({
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
>
<p>Clear selection</p>
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
+2 -2
View File
@@ -162,7 +162,7 @@ export function DeleteGroupDialog({
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
className="text-sm text-red-600"
className="text-sm text-destructive"
>
Delete profiles along with the group
</Label>
@@ -181,7 +181,7 @@ export function DeleteGroupDialog({
)}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+1 -1
View File
@@ -101,7 +101,7 @@ export function EditGroupDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
@@ -0,0 +1,188 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BrowserProfile, ExtensionGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ExtensionGroupAssignmentDialogProps {
isOpen: boolean;
onClose: () => void;
selectedProfiles: string[];
onAssignmentComplete: () => void;
profiles?: BrowserProfile[];
}
export function ExtensionGroupAssignmentDialog({
isOpen,
onClose,
selectedProfiles,
onAssignmentComplete,
profiles = [],
}: ExtensionGroupAssignmentDialogProps) {
const { t } = useTranslation();
const [groups, setGroups] = useState<ExtensionGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isAssigning, setIsAssigning] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadGroups = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const groupList = await invoke<ExtensionGroup[]>("list_extension_groups");
setGroups(groupList);
} catch (err) {
console.error("Failed to load extension groups:", err);
setError(
err instanceof Error ? err.message : "Failed to load extension groups",
);
} finally {
setIsLoading(false);
}
}, []);
const handleAssign = useCallback(async () => {
setIsAssigning(true);
setError(null);
try {
for (const profileId of selectedProfiles) {
await invoke("assign_extension_group_to_profile", {
profileId,
extensionGroupId: selectedGroupId,
});
}
toast.success(t("extensions.assignSuccess"));
onAssignmentComplete();
onClose();
} catch (err) {
console.error("Failed to assign extension group:", err);
const errorMessage =
err instanceof Error ? err.message : "Failed to assign extension group";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsAssigning(false);
}
}, [selectedProfiles, selectedGroupId, onAssignmentComplete, onClose, t]);
useEffect(() => {
if (isOpen) {
void loadGroups();
setSelectedGroupId(null);
setError(null);
}
}, [isOpen, loadGroups]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("extensions.assignTitle")}</DialogTitle>
<DialogDescription>
{t("extensions.assignDescription", {
count: selectedProfiles.length,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("extensions.assignTitle")}:</Label>
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => {
const profile = profiles.find(
(p: BrowserProfile) => p.id === profileId,
);
const displayName = profile ? profile.name : profileId;
return (
<li key={profileId} className="truncate">
{displayName}
</li>
);
})}
</ul>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="extension-group-select">
{t("extensions.extensionGroup")}:
</Label>
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("common.buttons.loading")}
</div>
) : (
<Select
value={selectedGroupId || "none"}
onValueChange={(value) => {
setSelectedGroupId(value === "none" ? null : value);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("extensions.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isAssigning}
>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isAssigning}
onClick={() => void handleAssign()}
disabled={isLoading}
>
{t("common.buttons.apply")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -176,7 +176,7 @@ export function GroupAssignmentDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+25 -9
View File
@@ -43,15 +43,16 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot(
group: GroupWithCount,
liveStatus: SyncStatus | undefined,
errorMessage?: string,
): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: group.last_sync
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -59,14 +60,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
return {
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
@@ -95,6 +104,9 @@ export function GroupManagementDialog({
const [groupSyncStatus, setGroupSyncStatus] = useState<
Record<string, SyncStatus>
>({});
const [groupSyncErrors, setGroupSyncErrors] = useState<
Record<string, string>
>({});
const [groupInUse, setGroupInUse] = useState<Record<string, boolean>>({});
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
{},
@@ -105,14 +117,17 @@ export function GroupManagementDialog({
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ id: string; status: string }>(
unlisten = await listen<{ id: string; status: string; error?: string }>(
"group-sync-status",
(event) => {
const { id, status } = event.payload;
const { id, status, error } = event.payload;
setGroupSyncStatus((prev) => ({
...prev,
[id]: status as SyncStatus,
}));
if (error) {
setGroupSyncErrors((prev) => ({ ...prev, [id]: error }));
}
},
);
};
@@ -216,7 +231,7 @@ export function GroupManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Manage Profile Groups</DialogTitle>
<DialogDescription>
@@ -240,7 +255,7 @@ export function GroupManagementDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
@@ -272,6 +287,7 @@ export function GroupManagementDialog({
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
+185 -15
View File
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
@@ -10,6 +11,7 @@ import {
LuUsers,
LuX,
} from "react-icons/lu";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -20,8 +22,151 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { ProBadge } from "./ui/pro-badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const CLICK_THRESHOLD = 5;
const CLICK_WINDOW_MS = 2000;
const GRAVITY = 2200;
const BOUNCE_DAMPING = 0.6;
const INITIAL_HORIZONTAL_SPEED = 350;
const SPIN_SPEED = 720;
const MIN_BOUNCE_VELOCITY = 60;
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
function useLogoEasterEgg() {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
const triggerFall = useCallback(() => {
const el = logoRef.current;
if (!el || isFalling) return;
setIsFalling(true);
const rect = el.getBoundingClientRect();
const startX = rect.left;
const startY = rect.top;
const floorY = window.innerHeight;
const leftWall = 0;
const rightWall = window.innerWidth;
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "fixed";
clone.style.left = `${startX}px`;
clone.style.top = `${startY}px`;
clone.style.zIndex = "9999";
clone.style.pointerEvents = "none";
clone.style.margin = "0";
document.body.appendChild(clone);
el.style.visibility = "hidden";
let x = 0;
let y = 0;
let vy = -500;
let vx = -INITIAL_HORIZONTAL_SPEED;
let rotation = 0;
let lastTime = performance.now();
const animate = (time: number) => {
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
// Floor bounce
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) {
vy = -Math.abs(vy) * BOUNCE_DAMPING;
} else {
vy = -MIN_BOUNCE_VELOCITY * 3;
}
}
// Left wall bounce only — right wall lets it fly off screen
const currentLeft = startX + x;
if (currentLeft <= leftWall && vx < 0) {
x = leftWall - startX;
vx = Math.abs(vx) * 1.1;
}
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
// Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow)
const currentTop = startY + y;
const offScreenRight = startX + x > rightWall + 50;
const offScreenBottom = currentTop > floorY + 100;
const offScreenTop = currentTop + rect.height < -200;
if (offScreenRight || offScreenBottom || offScreenTop) {
clone.remove();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
triggerFall();
} else {
setWobbleKey((k) => k + 1);
}
}, [isFalling, isHidden, triggerFall]);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
};
}
type Props = {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
@@ -33,6 +178,7 @@ type Props = {
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
crossOsUnlocked?: boolean;
};
const HomeHeader = ({
@@ -46,26 +192,47 @@ const HomeHeader = ({
onExtensionManagementDialogOpen,
searchQuery,
onSearchQueryChange,
crossOsUnlocked = false,
}: Props) => {
const { t } = useTranslation();
const handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
detail: "https://donutbrowser.com",
});
window.dispatchEvent(event);
};
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
} = useLogoEasterEgg();
return (
<div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center">
<button
type="button"
className="p-1 cursor-pointer"
title="Open donutbrowser.com"
onClick={handleLogoClick}
>
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
</button>
{!isHidden ? (
<button
ref={logoRef}
type="button"
className="p-1 cursor-pointer select-none"
onClick={handleClick}
onPointerDown={() => setIsPressed(true)}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
>
<Logo
key={wobbleKey}
className={cn(
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
isPressed && "scale-90",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
/>
</button>
) : (
<div className="p-1 w-10 h-10" />
)}
<CardTitle>Donut</CardTitle>
</div>
<div className="flex gap-2 items-center">
@@ -134,12 +301,15 @@ const HomeHeader = ({
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!crossOsUnlocked}
className={cn(!crossOsUnlocked && "opacity-50")}
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
{!crossOsUnlocked && <ProBadge className="ml-auto" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
+409 -294
View File
@@ -2,10 +2,12 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -23,19 +25,29 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
if (["firefox", "firefox-developer", "zen"].includes(browser))
return "camoufox";
return "wayfern";
};
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
crossOsUnlocked?: boolean;
}
export function ImportProfileDialog({
isOpen,
onClose,
crossOsUnlocked,
}: ImportProfileDialogProps) {
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[],
@@ -45,6 +57,12 @@ export function ImportProfileDialog({
const [importMode, setImportMode] = useState<"auto-detect" | "manual">(
"auto-detect",
);
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
"select",
);
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
// Auto-detect state
const [selectedDetectedProfile, setSelectedDetectedProfile] = useState<
@@ -61,6 +79,7 @@ export function ImportProfileDialog({
const { supportedBrowsers, isLoading: isLoadingSupport } =
useBrowserSupport();
const { storedProxies } = useProxyEvents();
const importableBrowsers = supportedBrowsers;
@@ -72,14 +91,11 @@ export function ImportProfileDialog({
);
setDetectedProfiles(profiles);
// Auto-switch to manual mode if no profiles detected
if (profiles.length === 0) {
setImportMode("manual");
} else {
// Auto-select first profile if available
setSelectedDetectedProfile(profiles[0].path);
// Generate default name from the detected profile
const profile = profiles[0];
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
@@ -93,6 +109,10 @@ export function ImportProfileDialog({
}
}, []);
const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
const handleBrowseFolder = async () => {
try {
const selected = await open({
@@ -110,40 +130,65 @@ export function ImportProfileDialog({
}
};
const handleAutoDetectImport = useCallback(async () => {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
const handleImport = useCallback(async () => {
let sourcePath: string;
let browserType: string;
let newProfileName: string;
if (importMode === "auto-detect") {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
sourcePath = profile.path;
browserType = profile.browser;
newProfileName = autoDetectProfileName.trim();
} else {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
sourcePath = manualProfilePath.trim();
browserType = manualBrowserType;
newProfileName = manualProfileName.trim();
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
const mappedBrowser =
importMode === "auto-detect" && selectedProfile
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
: getMappedBrowser(browserType);
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: profile.path,
browserType: profile.browser,
newProfileName: autoDetectProfileName.trim(),
sourcePath,
browserType,
newProfileName,
proxyId: selectedProxyId ?? null,
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
});
toast.success(
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
);
toast.success(`Successfully imported profile "${newProfileName}"`);
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(profile.browser);
const browserDisplayName = getBrowserDisplayName(browserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
@@ -157,63 +202,30 @@ export function ImportProfileDialog({
setIsImporting(false);
}
}, [
importMode,
selectedDetectedProfile,
autoDetectProfileName,
detectedProfiles,
manualBrowserType,
manualProfilePath,
manualProfileName,
selectedProxyId,
camoufoxConfig,
wayfernConfig,
onClose,
selectedProfile,
]);
const handleManualImport = useCallback(async () => {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: manualProfilePath.trim(),
browserType: manualBrowserType,
newProfileName: manualProfileName.trim(),
});
toast.success(
`Successfully imported profile "${manualProfileName.trim()}"`,
);
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(manualBrowserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
duration: 8000,
},
);
} else {
toast.error(`Failed to import profile: ${errorMessage}`);
}
} finally {
setIsImporting(false);
}
}, [manualBrowserType, manualProfilePath, manualProfileName, onClose]);
const handleClose = () => {
setCurrentStep("select");
setCamoufoxConfig({});
setWayfernConfig({});
setSelectedProxyId(undefined);
setSelectedDetectedProfile(null);
setAutoDetectProfileName("");
setManualBrowserType(null);
setManualProfilePath("");
setManualProfileName("");
// Only reset to auto-detect if there are profiles available
if (detectedProfiles.length > 0) {
setImportMode("auto-detect");
} else {
@@ -222,7 +234,6 @@ export function ImportProfileDialog({
onClose();
};
// Update auto-detect profile name when selection changes
useEffect(() => {
if (selectedDetectedProfile) {
const profile = detectedProfiles.find(
@@ -236,9 +247,38 @@ export function ImportProfileDialog({
}
}, [selectedDetectedProfile, detectedProfiles]);
const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
const currentMappedBrowser = useMemo(() => {
if (importMode === "auto-detect" && selectedProfile) {
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
}
if (importMode === "manual" && manualBrowserType) {
return manualBrowserType as "camoufox" | "wayfern";
}
return null;
}, [importMode, selectedProfile, manualBrowserType]);
const canProceedToNext = useMemo(() => {
if (importMode === "auto-detect") {
return (
!isLoading &&
!!selectedDetectedProfile &&
!!autoDetectProfileName.trim()
);
}
return (
!!manualBrowserType &&
!!manualProfilePath.trim() &&
!!manualProfileName.trim()
);
}, [
importMode,
isLoading,
selectedDetectedProfile,
autoDetectProfileName,
manualBrowserType,
manualProfilePath,
manualProfileName,
]);
useEffect(() => {
if (isOpen) {
@@ -254,247 +294,322 @@ export function ImportProfileDialog({
</DialogHeader>
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{/* Mode Selection */}
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</RippleButton>
</div>
{currentStep === "select" && (
<>
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</RippleButton>
</div>
{/* Auto-Detect Mode */}
{importMode === "auto-detect" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Detected Browser Profiles</h3>
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in custom
locations.
</p>
</div>
) : (
{importMode === "auto-detect" && (
<div className="space-y-4">
<div>
<Label htmlFor="detected-profile-select" className="mb-2">
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(profile.browser);
return (
<SelectItem key={profile.path} value={profile.path}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<h3 className="text-lg font-medium">
Detected Browser Profiles
</h3>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
)}
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in
custom locations.
</p>
</div>
) : (
<div className="space-y-4">
<div>
<Label
htmlFor="detected-profile-select"
className="mb-2"
>
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(
profile.browser,
);
return (
<SelectItem
key={profile.path}
value={profile.path}
>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
</div>
<span className="text-xs text-muted-foreground">
{" "}
{getBrowserDisplayName(
profile.mapped_browser,
)}
</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
</p>
</div>
)}
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
)}
</div>
)}
{importMode === "manual" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{importableBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
</div>
)}
</div>
</>
)}
{/* Manual Import Mode */}
{importMode === "manual" && (
{currentStep === "configure" && currentMappedBrowser && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<Alert>
<AlertDescription>
This profile will be imported as a{" "}
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
profile.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{importableBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
<div>
<Label className="mb-2">Proxy (Optional)</Label>
<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>
{currentMappedBrowser === "camoufox" ? (
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={(key, value) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
) : (
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
)}
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
{importMode === "auto-detect" ? (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleAutoDetectImport();
}}
disabled={
!selectedDetectedProfile ||
!autoDetectProfileName.trim() ||
isLoading
}
>
Import
</LoadingButton>
{currentStep === "select" ? (
<>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<RippleButton
disabled={!canProceedToNext}
onClick={() => {
setCurrentStep("configure");
}}
>
Next
</RippleButton>
</>
) : (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleManualImport();
}}
disabled={
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
}
>
Import
</LoadingButton>
<>
<RippleButton
variant="outline"
onClick={() => {
setCurrentStep("select");
}}
>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleImport();
}}
>
Import
</LoadingButton>
</>
)}
</DialogFooter>
</DialogContent>
+7 -29
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -42,6 +43,7 @@ export function IntegrationsDialog({
isOpen,
onClose,
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
api_enabled: false,
api_port: 10108,
@@ -320,7 +322,7 @@ export function IntegrationsDialog({
<p className="text-xs text-muted-foreground">
Allow AI assistants like Claude Desktop to control browsers.
{!termsAccepted && (
<span className="ml-1 text-orange-600">
<span className="ml-1 text-warning">
(Accept Wayfern terms in Settings first)
</span>
)}
@@ -329,20 +331,7 @@ export function IntegrationsDialog({
</div>
{mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
Claude Desktop Configuration
</Label>
<p className="text-xs text-muted-foreground">
Copy this configuration to your Claude Desktop config file
at{" "}
<code className="bg-muted px-1 rounded">
~/.config/claude/claude_desktop_config.json
</code>
</p>
</div>
<div className="space-y-3 p-4 rounded-md border bg-muted/40">
<div className="relative">
<pre className="p-3 text-xs font-mono rounded-md bg-background border overflow-x-auto whitespace-pre">
{showMcpToken
@@ -369,20 +358,9 @@ export function IntegrationsDialog({
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Available Tools
</Label>
<ul className="list-disc ml-5 space-y-0.5 text-xs text-muted-foreground">
<li>list_profiles - List browser profiles</li>
<li>run_profile - Launch a browser</li>
<li>kill_profile - Stop a running browser</li>
<li>get_profile_status - Check if browser is running</li>
<li>list_groups, create_group, etc. - Manage groups</li>
<li>list_proxies, create_proxy, etc. - Manage proxies</li>
</ul>
</div>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpCopyHint")}
</p>
</div>
)}
</TabsContent>
+160 -52
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -29,26 +30,31 @@ export function LocationProxyDialog({
onClose,
}: LocationProxyDialogProps) {
const [countries, setCountries] = useState<LocationItem[]>([]);
const [states, setStates] = useState<LocationItem[]>([]);
const [regions, setRegions] = useState<LocationItem[]>([]);
const [cities, setCities] = useState<LocationItem[]>([]);
const [isps, setIsps] = useState<LocationItem[]>([]);
const [selectedCountry, setSelectedCountry] = useState("");
const [selectedState, setSelectedState] = useState("");
const [selectedRegion, setSelectedRegion] = useState("");
const [selectedCity, setSelectedCity] = useState("");
const [selectedIsp, setSelectedIsp] = useState("");
const [proxyName, setProxyName] = useState("");
const [isLoadingCountries, setIsLoadingCountries] = useState(false);
const [isLoadingStates, setIsLoadingStates] = useState(false);
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
const [isLoadingCities, setIsLoadingCities] = useState(false);
const [isLoadingIsps, setIsLoadingIsps] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const handleClose = useCallback(() => {
setSelectedCountry("");
setSelectedState("");
setSelectedRegion("");
setSelectedCity("");
setSelectedIsp("");
setProxyName("");
setStates([]);
setRegions([]);
setCities([]);
setIsps([]);
onClose();
}, [onClose]);
@@ -65,52 +71,87 @@ export function LocationProxyDialog({
.finally(() => setIsLoadingCountries(false));
}, [isOpen]);
// Fetch states when country changes
// Fetch regions when country changes
useEffect(() => {
if (!selectedCountry) {
setStates([]);
setRegions([]);
return;
}
setIsLoadingStates(true);
setSelectedState("");
setIsLoadingRegions(true);
setSelectedRegion("");
setSelectedCity("");
setSelectedIsp("");
setCities([]);
invoke<LocationItem[]>("cloud_get_states", { country: selectedCountry })
.then((data) => setStates(data))
.catch((err) => console.error("Failed to fetch states:", err))
.finally(() => setIsLoadingStates(false));
setIsps([]);
invoke<LocationItem[]>("cloud_get_regions", { country: selectedCountry })
.then((data) => setRegions(data))
.catch((err) => console.error("Failed to fetch regions:", err))
.finally(() => setIsLoadingRegions(false));
}, [selectedCountry]);
// Fetch cities when state changes
// Fetch cities when country or region changes (cities can be loaded without region)
useEffect(() => {
if (!selectedCountry || !selectedState) {
if (!selectedCountry) {
setCities([]);
return;
}
setIsLoadingCities(true);
setSelectedCity("");
invoke<LocationItem[]>("cloud_get_cities", {
const args: { country: string; region?: string } = {
country: selectedCountry,
state: selectedState,
})
};
if (selectedRegion) {
args.region = selectedRegion;
}
invoke<LocationItem[]>("cloud_get_cities", args)
.then((data) => setCities(data))
.catch((err) => console.error("Failed to fetch cities:", err))
.finally(() => setIsLoadingCities(false));
}, [selectedCountry, selectedState]);
}, [selectedCountry, selectedRegion]);
// Fetch ISPs when country/region/city changes
useEffect(() => {
if (!selectedCountry) {
setIsps([]);
return;
}
setIsLoadingIsps(true);
setSelectedIsp("");
const args: { country: string; region?: string; city?: string } = {
country: selectedCountry,
};
if (selectedRegion) args.region = selectedRegion;
if (selectedCity) args.city = selectedCity;
invoke<LocationItem[]>("cloud_get_isps", args)
.then((data) => setIsps(data))
.catch((err) => console.error("Failed to fetch ISPs:", err))
.finally(() => setIsLoadingIsps(false));
}, [selectedCountry, selectedRegion, selectedCity]);
// Auto-generate name from selections
useEffect(() => {
const parts: string[] = [];
const countryItem = countries.find((c) => c.code === selectedCountry);
if (countryItem) parts.push(countryItem.name);
const stateItem = states.find((s) => s.code === selectedState);
if (stateItem) parts.push(stateItem.name);
const regionItem = regions.find((s) => s.code === selectedRegion);
if (regionItem) parts.push(regionItem.name);
const cityItem = cities.find((c) => c.code === selectedCity);
if (cityItem) parts.push(cityItem.name);
const ispItem = isps.find((i) => i.code === selectedIsp);
if (ispItem) parts.push(ispItem.name);
if (parts.length > 0) {
setProxyName(parts.join(" - "));
}
}, [selectedCountry, selectedState, selectedCity, countries, states, cities]);
}, [
selectedCountry,
selectedRegion,
selectedCity,
selectedIsp,
countries,
regions,
cities,
isps,
]);
const handleCreate = useCallback(async () => {
if (!selectedCountry || !proxyName.trim()) return;
@@ -119,8 +160,9 @@ export function LocationProxyDialog({
await invoke("create_cloud_location_proxy", {
name: proxyName.trim(),
country: selectedCountry,
state: selectedState || null,
region: selectedRegion || null,
city: selectedCity || null,
isp: selectedIsp || null,
});
toast.success("Location proxy created");
await emit("stored-proxies-changed");
@@ -133,14 +175,26 @@ export function LocationProxyDialog({
} finally {
setIsCreating(false);
}
}, [selectedCountry, selectedState, selectedCity, proxyName, handleClose]);
}, [
selectedCountry,
selectedRegion,
selectedCity,
selectedIsp,
proxyName,
handleClose,
]);
const countryOptions = countries.map((c) => ({
value: c.code,
label: c.name,
}));
const stateOptions = states.map((s) => ({ value: s.code, label: s.name }));
const regionOptions = regions.map((s) => ({ value: s.code, label: s.name }));
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
const LoadingSpinner = () => (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -148,48 +202,102 @@ export function LocationProxyDialog({
<DialogHeader>
<DialogTitle>Create Location Proxy</DialogTitle>
<DialogDescription>
Create a geo-targeted proxy from your cloud credentials
Create a geo-targeted proxy with a 24-hour sticky session
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Country - always visible */}
<div className="space-y-2">
<Label>Country (required)</Label>
<Label className="flex items-center gap-2">
Country (required)
{isLoadingCountries && <LoadingSpinner />}
</Label>
<Combobox
options={countryOptions}
value={selectedCountry}
onValueChange={setSelectedCountry}
placeholder={isLoadingCountries ? "Loading..." : "Select country"}
placeholder={
isLoadingCountries ? "Loading countries..." : "Select country"
}
searchPlaceholder="Search countries..."
disabled={isLoadingCountries}
/>
</div>
{selectedCountry && stateOptions.length > 0 && (
<div className="space-y-2">
<Label>State (optional)</Label>
<Combobox
options={stateOptions}
value={selectedState}
onValueChange={setSelectedState}
placeholder={isLoadingStates ? "Loading..." : "Select state"}
searchPlaceholder="Search states..."
/>
</div>
)}
{/* Region - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
Region (optional)
{isLoadingRegions && <LoadingSpinner />}
</Label>
<Combobox
options={regionOptions}
value={selectedRegion}
onValueChange={setSelectedRegion}
placeholder={
!selectedCountry
? "Select a country first"
: isLoadingRegions
? "Loading regions..."
: regionOptions.length === 0
? "No regions available"
: "Select region"
}
searchPlaceholder="Search regions..."
disabled={!selectedCountry || isLoadingRegions}
/>
</div>
{selectedState && cityOptions.length > 0 && (
<div className="space-y-2">
<Label>City (optional)</Label>
<Combobox
options={cityOptions}
value={selectedCity}
onValueChange={setSelectedCity}
placeholder={isLoadingCities ? "Loading..." : "Select city"}
searchPlaceholder="Search cities..."
/>
</div>
)}
{/* City - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
City (optional)
{isLoadingCities && <LoadingSpinner />}
</Label>
<Combobox
options={cityOptions}
value={selectedCity}
onValueChange={setSelectedCity}
placeholder={
!selectedCountry
? "Select a country first"
: isLoadingCities
? "Loading cities..."
: cityOptions.length === 0
? "No cities available"
: "Select city"
}
searchPlaceholder="Search cities..."
disabled={!selectedCountry || isLoadingCities}
/>
</div>
{/* ISP - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
ISP (optional)
{isLoadingIsps && <LoadingSpinner />}
</Label>
<Combobox
options={ispOptions}
value={selectedIsp}
onValueChange={setSelectedIsp}
placeholder={
!selectedCountry
? "Select a country first"
: isLoadingIsps
? "Loading ISPs..."
: ispOptions.length === 0
? "No ISPs available"
: "Select ISP"
}
searchPlaceholder="Search ISPs..."
disabled={!selectedCountry || isLoadingIsps}
/>
</div>
{/* Name */}
<div className="space-y-2">
<Label>Name</Label>
<Input
+5 -5
View File
@@ -116,7 +116,7 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full dark:bg-blue-900">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-primary/15 rounded-full">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
@@ -129,8 +129,8 @@ export function PermissionDialog({
<div className="space-y-4">
{isCurrentPermissionGranted && (
<div className="p-3 bg-green-50 rounded-lg dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-sm text-success">
Permission granted! Browsers launched from Donut Browser can
now access your {permissionType}.
</p>
@@ -138,8 +138,8 @@ export function PermissionDialog({
)}
{!isCurrentPermissionGranted && (
<div className="p-3 bg-amber-50 rounded-lg dark:bg-amber-900/20">
<p className="text-sm text-amber-800 dark:text-amber-200">
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-sm text-warning">
Permission not granted. Click the button below to request
access to your {permissionType}.
</p>
+189 -43
View File
@@ -23,11 +23,16 @@ import {
LuCookie,
LuInfo,
LuLock,
LuPuzzle,
LuTrash2,
LuTriangleAlert,
LuUsers,
} from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProfileInfoDialog } from "@/components/profile-info-dialog";
import {
ProfileBypassRulesDialog,
ProfileInfoDialog,
} from "@/components/profile-info-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -79,6 +84,7 @@ import type {
LocationItem,
ProxyCheckResult,
StoredProxy,
SyncSessionInfo,
TrafficSnapshot,
VpnConfig,
} from "@/types";
@@ -200,6 +206,16 @@ type TableMeta = {
// Team locks
isProfileLockedByAnother: (profileId: string) => boolean;
getProfileLockEmail: (profileId: string) => string | undefined;
// Synchronizer
getProfileSyncInfo: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync: (profile: BrowserProfile) => void;
};
type SyncStatusDot = {
@@ -230,21 +246,21 @@ function getProfileSyncStatusDot(
switch (status) {
case "syncing":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Syncing...",
animate: true,
encrypted,
};
case "waiting":
return {
color: "bg-yellow-500",
tooltip: "Waiting to sync",
color: "bg-warning",
tooltip: "Close the profile to sync",
animate: false,
encrypted,
};
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: profile.last_sync
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -253,7 +269,7 @@ function getProfileSyncStatusDot(
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
encrypted,
@@ -261,7 +277,7 @@ function getProfileSyncStatusDot(
case "disabled":
if (profile.last_sync) {
return {
color: "bg-gray-400",
color: "bg-muted-foreground",
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
animate: false,
encrypted: false,
@@ -791,10 +807,20 @@ interface ProfilesDataTableProps {
onBulkGroupAssignment?: () => void;
onBulkProxyAssignment?: () => void;
onBulkCopyCookies?: () => void;
onBulkExtensionGroupAssignment?: () => void;
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
syncUnlocked?: boolean;
getProfileSyncInfo?: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void;
}
export function ProfilesDataTable({
@@ -816,10 +842,14 @@ export function ProfilesDataTable({
onBulkGroupAssignment,
onBulkProxyAssignment,
onBulkCopyCookies,
onBulkExtensionGroupAssignment,
onAssignExtensionGroup,
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked = false,
syncUnlocked = false,
getProfileSyncInfo,
onLaunchWithSync,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -886,6 +916,8 @@ export function ProfilesDataTable({
const [isDeleting, setIsDeleting] = React.useState(false);
const [profileForInfoDialog, setProfileForInfoDialog] =
React.useState<BrowserProfile | null>(null);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
@@ -941,8 +973,7 @@ export function ProfilesDataTable({
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed);
const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked;
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
if (countriesLoaded || !canCreateLocationProxy) return;
@@ -953,7 +984,7 @@ export function ProfilesDataTable({
} catch (e) {
console.error("Failed to load countries:", e);
}
}, [countriesLoaded, canCreateLocationProxy]);
}, [countriesLoaded]);
// Load cached check results for proxies
React.useEffect(() => {
@@ -1038,8 +1069,9 @@ export function ProfilesDataTable({
await invoke("create_cloud_location_proxy", {
name: country.name,
country: country.code,
state: null,
region: null,
city: null,
isp: null,
});
await emit("stored-proxies-changed");
// Wait briefly for proxy list to update, then find and assign the new proxy
@@ -1068,6 +1100,7 @@ export function ProfilesDataTable({
isUpdating,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
);
// Listen for sync status events
@@ -1517,6 +1550,10 @@ export function ProfilesDataTable({
isProfileLockedByAnother: isProfileLocked,
getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail,
// Synchronizer
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
onLaunchWithSync: onLaunchWithSync ?? (() => {}),
}),
[
t,
@@ -1566,11 +1603,12 @@ export function ProfilesDataTable({
crossOsUnlocked,
syncUnlocked,
countries,
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
isProfileLocked,
getLockInfo,
getProfileSyncInfo,
onLaunchWithSync,
],
);
@@ -1795,23 +1833,81 @@ export function ProfilesDataTable({
}
};
const syncInfo = meta.getProfileSyncInfo(profile.id);
const isLeader = syncInfo?.isLeader === true;
const isFollower = syncInfo?.isLeader === false;
const isDesynced = isFollower && syncInfo?.failedAtUrl != null;
const stopTooltip = isLeader
? meta.t("profiles.synchronizer.stopLeader")
: isFollower
? meta.t("profiles.synchronizer.stopFollower", {
leaderName: syncInfo?.session.leader_profile_name ?? "",
})
: tooltipContent;
const handleStop = async () => {
if (isLeader && syncInfo) {
// Stop leader: invoke stop_sync_session which kills leader + all followers
try {
await invoke("stop_sync_session", {
sessionId: syncInfo.session.id,
});
} catch (error) {
console.error("Failed to stop sync session:", error);
}
} else if (isFollower && syncInfo) {
// Stop follower: remove from session
try {
await invoke("remove_sync_follower", {
sessionId: syncInfo.session.id,
followerProfileId: profile.id,
});
} catch (error) {
console.error("Failed to remove sync follower:", error);
}
} else {
await handleProfileStop(profile);
}
};
const buttonVariant = isRunning
? isFollower
? "secondary"
: "destructive"
: "default";
return (
<div className="flex gap-2 items-center">
{isDesynced && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="w-4 h-4 text-warning" />
</span>
</TooltipTrigger>
<TooltipContent>
{meta.t("profiles.synchronizer.desyncedTooltip", {
url: syncInfo?.failedAtUrl ?? "",
})}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<RippleButton
variant={isRunning ? "destructive" : "default"}
variant={buttonVariant}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
className={cn(
"min-w-[70px] h-7",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
)}
onClick={() =>
isRunning
? handleProfileStop(profile)
? void handleStop()
: handleProfileLaunch(profile)
}
>
@@ -1827,8 +1923,10 @@ export function ProfilesDataTable({
</RippleButton>
</span>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent>{tooltipContent}</TooltipContent>
{(stopTooltip || tooltipContent) && (
<TooltipContent>
{isRunning ? stopTooltip : tooltipContent}
</TooltipContent>
)}
</Tooltip>
</div>
@@ -1919,12 +2017,13 @@ export function ProfilesDataTable({
);
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
const lockedEmail = meta.getProfileLockEmail(profile.id);
const isLocked = meta.isProfileLockedByAnother(profile.id);
@@ -1979,12 +2078,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
return (
<TagsCell
@@ -2007,12 +2107,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
return (
<NoteCell
@@ -2033,12 +2134,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
const hasProxyOverride = Object.hasOwn(
meta.proxyOverrides,
@@ -2177,28 +2279,35 @@ export function ProfilesDataTable({
/>
None
</CommandItem>
{meta.storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id && !effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
{meta.storedProxies
.filter(
(proxy: StoredProxy) =>
!proxy.is_cloud_managed &&
!proxy.is_cloud_derived,
)
.map((proxy: StoredProxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id &&
!effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{meta.vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
@@ -2505,7 +2614,10 @@ export function ProfilesDataTable({
onConfigureCamoufox={onConfigureCamoufox}
onCopyCookiesToProfile={onCopyCookiesToProfile}
onOpenCookieManagement={onOpenCookieManagement}
onAssignExtensionGroup={onAssignExtensionGroup}
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
setProfileToDelete(profile);
@@ -2538,6 +2650,27 @@ export function ProfilesDataTable({
<FiWifi />
</DataTableActionBarAction>
)}
{onBulkExtensionGroupAssignment && (
<DataTableActionBarAction
tooltip={
crossOsUnlocked
? "Assign Extension Group"
: "Assign Extension Group (Pro)"
}
onClick={onBulkExtensionGroupAssignment}
size="icon"
disabled={!crossOsUnlocked}
>
<span className="relative">
<LuPuzzle />
{!crossOsUnlocked && (
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
PRO
</span>
)}
</span>
</DataTableActionBarAction>
)}
{onBulkCopyCookies && (
<DataTableActionBarAction
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
@@ -2545,7 +2678,14 @@ export function ProfilesDataTable({
size="icon"
disabled={!crossOsUnlocked}
>
<LuCookie />
<span className="relative">
<LuCookie />
{!crossOsUnlocked && (
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
PRO
</span>
)}
</span>
</DataTableActionBarAction>
)}
{onBulkDelete && (
@@ -2568,6 +2708,12 @@ export function ProfilesDataTable({
profileName={trafficDialogProfile.name}
/>
)}
<ProfileBypassRulesDialog
isOpen={bypassRulesProfile !== null}
onClose={() => setBypassRulesProfile(null)}
profileId={bypassRulesProfile?.id ?? null}
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
/>
</>
);
}
+276 -262
View File
@@ -14,10 +14,12 @@ import {
LuGlobe,
LuGroup,
LuPlus,
LuPuzzle,
LuRefreshCw,
LuSettings,
LuShieldCheck,
LuTrash2,
LuUsers,
LuX,
} from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
@@ -60,8 +62,11 @@ interface ProfileInfoDialogProps {
onConfigureCamoufox?: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenBypassRules?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
isRunning?: boolean;
isDisabled?: boolean;
@@ -103,8 +108,11 @@ export function ProfileInfoDialog({
onConfigureCamoufox,
onCopyCookiesToProfile,
onOpenCookieManagement,
onAssignExtensionGroup,
onOpenBypassRules,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
crossOsUnlocked = false,
isRunning = false,
isDisabled = false,
@@ -117,10 +125,6 @@ export function ProfileInfoDialog({
const [extensionGroupName, setExtensionGroupName] = React.useState<
string | null
>(null);
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
const [newRule, setNewRule] = React.useState("");
const [bypassRulesDialogOpen, setBypassRulesDialogOpen] =
React.useState(false);
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
@@ -159,12 +163,8 @@ export function ProfileInfoDialog({
React.useEffect(() => {
if (!isOpen) {
setCopied(false);
setNewRule("");
}
if (isOpen && profile) {
setBypassRules(profile.proxy_bypass_rules ?? []);
}
}, [isOpen, profile]);
}, [isOpen]);
if (!profile) return null;
@@ -206,31 +206,6 @@ export function ProfileInfoDialog({
action();
};
const updateBypassRules = async (rules: string[]) => {
if (!profile) return;
try {
await invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules,
});
setBypassRules(rules);
} catch {
// ignore
}
};
const handleAddRule = () => {
const trimmed = newRule.trim();
if (!trimmed || bypassRules.includes(trimmed)) return;
const updated = [...bypassRules, trimmed];
setNewRule("");
void updateBypassRules(updated);
};
const handleRemoveRule = (rule: string) => {
void updateBypassRules(bypassRules.filter((r) => r !== rule));
};
const releaseLabel =
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1);
@@ -245,6 +220,7 @@ export function ProfileInfoDialog({
disabled?: boolean;
destructive?: boolean;
proBadge?: boolean;
runningBadge?: boolean;
hidden?: boolean;
};
@@ -268,20 +244,31 @@ export function ProfileInfoDialog({
onClick: () =>
handleAction(() => onAssignProfilesToGroup?.([profile.id])),
disabled: isDisabled,
runningBadge: isRunning,
},
{
icon: <LuFingerprint className="w-4 h-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => handleAction(() => onConfigureCamoufox?.(profile)),
disabled: isDisabled,
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
icon: <LuUsers className="w-4 h-4" />,
label: t("profiles.synchronizer.launchWithSync"),
onClick: () => handleAction(() => onLaunchWithSync?.(profile)),
disabled: isDisabled || isRunning || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
icon: <LuCopy className="w-4 h-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning && crossOsUnlocked,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
@@ -293,6 +280,7 @@ export function ProfileInfoDialog({
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning && crossOsUnlocked,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
@@ -303,12 +291,22 @@ export function ProfileInfoDialog({
label: t("profiles.actions.clone"),
onClick: () => handleAction(() => onCloneProfile?.(profile)),
disabled: isDisabled,
runningBadge: isRunning,
hidden: profile.ephemeral === true,
},
{
icon: <LuPuzzle className="w-4 h-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning && crossOsUnlocked,
hidden: profile.ephemeral === true,
},
{
icon: <LuShieldCheck className="w-4 h-4" />,
label: t("profileInfo.network.bypassRulesTitle"),
onClick: () => setBypassRulesDialogOpen(true),
onClick: () => handleAction(() => onOpenBypassRules?.(profile)),
},
{
icon: <LuTrash2 className="w-4 h-4" />,
@@ -322,247 +320,259 @@ export function ProfileInfoDialog({
const visibleActions = actions.filter((a) => !a.hidden);
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="info" className="flex-1 min-h-0 flex flex-col">
<TabsList className="w-full shrink-0">
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
</TabsList>
<TabsContent value="info" className="flex-1 min-h-0 flex flex-col">
<ScrollArea className="flex-1 min-h-0">
<div className="flex flex-col gap-4 py-3 pr-3">
{/* Hero */}
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2.5 shrink-0">
<ProfileIcon className="w-8 h-8 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold truncate">
{profile.name}
</h3>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<Badge variant="secondary" className="text-xs">
{getBrowserDisplayName(profile.browser)}{" "}
{profile.version}
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="info">
<TabsList className="w-full">
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
</TabsList>
<TabsContent value="info">
<div className="overflow-y-auto max-h-[calc(80vh-12rem)] pr-1">
<div className="flex flex-col gap-4 py-3">
{/* Hero */}
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2.5 shrink-0">
<ProfileIcon className="w-8 h-8 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold truncate">
{profile.name}
</h3>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<Badge variant="secondary" className="text-xs">
{getBrowserDisplayName(profile.browser)}{" "}
{profile.version}
</Badge>
<Badge variant="outline" className="text-xs">
{releaseLabel}
</Badge>
{isRunning && (
<Badge className="text-xs bg-primary/15 text-primary border-primary/25">
{t("common.status.running")}
</Badge>
)}
{profile.ephemeral && (
<Badge variant="outline" className="text-xs">
{releaseLabel}
{t("profiles.ephemeralBadge")}
</Badge>
{isRunning && (
<Badge className="text-xs bg-primary/15 text-primary border-primary/25">
{t("common.status.running")}
</Badge>
)}
{profile.ephemeral && (
<Badge variant="outline" className="text-xs">
{t("profiles.ephemeralBadge")}
</Badge>
)}
{showCrossOs && profile.host_os && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_os)}
</Badge>
)}
</div>
</div>
</div>
{/* Profile ID */}
<div className="flex items-center gap-2 rounded-md bg-muted/50 border px-3 py-2">
<span className="text-xs text-muted-foreground shrink-0">
ID
</span>
<span className="font-mono text-xs truncate flex-1">
{profile.id}
</span>
<button
type="button"
onClick={() => void handleCopyId()}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{copied ? (
<LuClipboardCheck className="w-3.5 h-3.5" />
) : (
<LuClipboard className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Network & Organization */}
<div className="grid grid-cols-2 gap-2">
<InfoCard
label={t("profileInfo.fields.proxyVpn")}
value={networkLabel}
/>
<InfoCard
label={t("profileInfo.fields.group")}
value={groupName ?? t("profileInfo.values.none")}
/>
<InfoCard
label={t("profileInfo.fields.extensionGroup")}
value={extensionGroupName ?? t("profileInfo.values.none")}
/>
<InfoCard
label={t("profileInfo.fields.lastLaunched")}
value={
profile.last_launch
? formatRelativeTime(profile.last_launch)
: t("profileInfo.values.never")
}
/>
</div>
{/* Sync */}
<div className="rounded-md bg-muted/50 border px-3 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">
{t("profileInfo.fields.syncStatus")}
</p>
<p className="text-sm mt-0.5">{syncLabel}</p>
</div>
<Badge
variant={
syncMode === "Disabled" ? "outline" : "secondary"
}
className="text-xs shrink-0"
>
{syncMode === "Disabled"
? t("sync.mode.disabled")
: syncStatus?.status === "syncing"
? t("common.status.syncing")
: t("common.status.synced")}
</Badge>
</div>
{/* Tags */}
{hasTags && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("profileInfo.fields.tags")}
</span>
<div className="flex flex-wrap gap-1.5">
{profile.tags?.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-xs"
>
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Note */}
{hasNote && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("profileInfo.fields.note")}
</span>
<p className="text-sm rounded-md bg-muted/50 border px-3 py-2 whitespace-pre-wrap break-words">
{profile.note}
</p>
</div>
)}
{/* Team */}
{profile.created_by_email && (
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
<p className="text-xs text-muted-foreground">
{t("sync.team.title")}
</p>
<p className="text-sm mt-0.5">
{t("sync.team.createdBy", {
email: profile.created_by_email,
})}
</p>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="settings"
className="flex-1 min-h-0 flex flex-col"
>
<ScrollArea className="flex-1 min-h-0">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
{showCrossOs && profile.host_os && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_os)}
</Badge>
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.proBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProfileBypassRulesDialog
isOpen={bypassRulesDialogOpen}
onClose={() => setBypassRulesDialogOpen(false)}
bypassRules={bypassRules}
newRule={newRule}
onNewRuleChange={setNewRule}
onAddRule={handleAddRule}
onRemoveRule={handleRemoveRule}
/>
</>
{/* Profile ID */}
<div className="flex items-center gap-2 rounded-md bg-muted/50 border px-3 py-2">
<span className="text-xs text-muted-foreground shrink-0">
ID
</span>
<span className="font-mono text-xs truncate flex-1">
{profile.id}
</span>
<button
type="button"
onClick={() => void handleCopyId()}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{copied ? (
<LuClipboardCheck className="w-3.5 h-3.5" />
) : (
<LuClipboard className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Network & Organization */}
<div className="grid grid-cols-2 gap-2">
<InfoCard
label={t("profileInfo.fields.proxyVpn")}
value={networkLabel}
/>
<InfoCard
label={t("profileInfo.fields.group")}
value={groupName ?? t("profileInfo.values.none")}
/>
<InfoCard
label={t("profileInfo.fields.extensionGroup")}
value={extensionGroupName ?? t("profileInfo.values.none")}
/>
<InfoCard
label={t("profileInfo.fields.lastLaunched")}
value={
profile.last_launch
? formatRelativeTime(profile.last_launch)
: t("profileInfo.values.never")
}
/>
</div>
{/* Sync */}
<div className="rounded-md bg-muted/50 border px-3 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">
{t("profileInfo.fields.syncStatus")}
</p>
<p className="text-sm mt-0.5">{syncLabel}</p>
</div>
<Badge
variant={syncMode === "Disabled" ? "outline" : "secondary"}
className="text-xs shrink-0"
>
{syncMode === "Disabled"
? t("sync.mode.disabled")
: syncStatus?.status === "syncing"
? t("common.status.syncing")
: t("common.status.synced")}
</Badge>
</div>
{/* Tags */}
{hasTags && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("profileInfo.fields.tags")}
</span>
<div className="flex flex-wrap gap-1.5">
{profile.tags?.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-xs"
>
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Note */}
{hasNote && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("profileInfo.fields.note")}
</span>
<p className="text-sm rounded-md bg-muted/50 border px-3 py-2 whitespace-pre-wrap break-words">
{profile.note}
</p>
</div>
)}
{/* Team */}
{profile.created_by_email && (
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
<p className="text-xs text-muted-foreground">
{t("sync.team.title")}
</p>
<p className="text-sm mt-0.5">
{t("sync.team.createdBy", {
email: profile.created_by_email,
})}
</p>
</div>
)}
</div>
</div>
</TabsContent>
<TabsContent value="settings">
<div className="overflow-y-auto max-h-[calc(80vh-12rem)]">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.runningBadge && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
{t("common.status.running")}
</span>
)}
{action.proBadge && !action.runningBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
interface ProfileBypassRulesDialogProps {
isOpen: boolean;
onClose: () => void;
bypassRules: string[];
newRule: string;
onNewRuleChange: (value: string) => void;
onAddRule: () => void;
onRemoveRule: (rule: string) => void;
profileId: string | null;
initialRules?: string[];
}
function ProfileBypassRulesDialog({
export function ProfileBypassRulesDialog({
isOpen,
onClose,
bypassRules,
newRule,
onNewRuleChange,
onAddRule,
onRemoveRule,
profileId,
initialRules,
}: ProfileBypassRulesDialogProps) {
const { t } = useTranslation();
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
const [newRule, setNewRule] = React.useState("");
React.useEffect(() => {
if (isOpen) {
setBypassRules(initialRules ?? []);
setNewRule("");
}
}, [isOpen, initialRules]);
const updateBypassRules = async (rules: string[]) => {
if (!profileId) return;
try {
await invoke("update_profile_proxy_bypass_rules", {
profileId,
rules,
});
setBypassRules(rules);
} catch {
// ignore
}
};
const handleAddRule = () => {
const trimmed = newRule.trim();
if (!trimmed || bypassRules.includes(trimmed)) return;
const updated = [...bypassRules, trimmed];
setNewRule("");
void updateBypassRules(updated);
};
const handleRemoveRule = (rule: string) => {
void updateBypassRules(bypassRules.filter((r) => r !== rule));
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -578,14 +588,18 @@ function ProfileBypassRulesDialog({
<div className="flex gap-2">
<Input
value={newRule}
onChange={(e) => onNewRuleChange(e.target.value)}
onChange={(e) => setNewRule(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onAddRule();
if (e.key === "Enter") handleAddRule();
}}
placeholder={t("profileInfo.network.rulePlaceholder")}
className="flex-1 text-sm"
/>
<Button size="sm" onClick={onAddRule} disabled={!newRule.trim()}>
<Button
size="sm"
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
@@ -604,7 +618,7 @@ function ProfileBypassRulesDialog({
<span className="font-mono text-xs truncate">{rule}</span>
<button
type="button"
onClick={() => onRemoveRule(rule)}
onClick={() => handleRemoveRule(rule)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
+22 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
@@ -53,6 +53,7 @@ export function ProfileSyncDialog({
const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
const [userChangedMode, setUserChangedMode] = useState(false);
const hasConfig = isCloudSyncEligible || hasSelfHostedConfig;
@@ -72,17 +73,21 @@ export function ProfileSyncDialog({
}
}, []);
useEffect(() => {
if (isOpen && profile) {
setSyncMode(profile.sync_mode ?? "Disabled");
setUserChangedMode(false);
void checkSyncConfig();
}
}, [isOpen, profile, checkSyncConfig]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open && profile) {
setSyncMode(profile.sync_mode ?? "Disabled");
void checkSyncConfig();
}
if (!open) {
onClose();
}
},
[profile, onClose, checkSyncConfig],
[onClose],
);
const handleModeChange = useCallback(
@@ -113,6 +118,7 @@ export function ProfileSyncDialog({
syncMode: newMode,
});
setSyncMode(newMode as SyncMode);
setUserChangedMode(true);
showSuccessToast(
newMode !== "Disabled"
? t("sync.mode.enabledToast")
@@ -273,14 +279,16 @@ export function ProfileSyncDialog({
</div>
</RadioGroup>
{syncMode === "Encrypted" && !hasE2ePassword && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t(
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
</div>
)}
{syncMode === "Encrypted" &&
!hasE2ePassword &&
userChangedMode && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t(
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
</div>
)}
<div className="space-y-2">
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
+124 -52
View File
@@ -3,9 +3,19 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -16,14 +26,11 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -51,6 +58,7 @@ export function ProxyAssignmentDialog({
"none",
);
const [isAssigning, setIsAssigning] = useState(false);
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleValueChange = useCallback((value: string) => {
@@ -126,13 +134,6 @@ export function ProxyAssignmentDialog({
}
}, [isOpen]);
const selectValue =
selectionType === "none"
? "none"
: selectionType === "vpn"
? `vpn-${selectedId}`
: (selectedId ?? "none");
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
@@ -166,47 +167,118 @@ export function ProxyAssignmentDialog({
<div className="space-y-2">
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
<Select value={selectValue} onValueChange={handleValueChange}>
<SelectTrigger>
<SelectValue placeholder="Select a proxy or VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem key={vpn.id} value={`vpn-${vpn.id}`}>
<span className="flex items-center gap-1">
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight"
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
className="w-full justify-between font-normal"
>
{(() => {
if (selectionType === "none") return "None";
if (selectionType === "vpn") {
const vpn = vpnConfigs.find((v) => v.id === selectedId);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
: "None";
}
const proxy = storedProxies.find(
(p) => p.id === selectedId,
);
return proxy ? proxy.name : "None";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
<Command>
<CommandInput placeholder="Search proxies or VPNs..." />
<CommandList>
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
handleValueChange("none");
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "none"
? "opacity-100"
: "opacity-0",
)}
/>
None
</CommandItem>
{storedProxies
.filter(
(proxy) =>
!proxy.is_cloud_managed && !proxy.is_cloud_derived,
)
.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
handleValueChange(proxy.id);
setProxyPopoverOpen(false);
}}
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
</Badge>
{vpn.name}
</span>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
{vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() => {
handleValueChange(`vpn-${vpn.id}`);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "vpn" && selectedId === vpn.id
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+3 -1
View File
@@ -50,7 +50,9 @@ export function ProxyCheckButton({
try {
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: proxy.proxy_settings,
proxySettings: proxy.dynamic_proxy_url
? undefined
: proxy.proxy_settings,
});
setLocalResult(result);
onCheckComplete?.(result);
+339 -147
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
@@ -20,10 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ProxySettings, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
interface RegularFormData {
name: string;
proxy_type: string;
host: string;
@@ -32,6 +34,14 @@ interface ProxyFormData {
password: string;
}
interface DynamicFormData {
name: string;
url: string;
format: string;
}
type ProxyMode = "regular" | "dynamic";
interface ProxyFormDialogProps {
isOpen: boolean;
onClose: () => void;
@@ -43,8 +53,11 @@ export function ProxyFormDialog({
onClose,
editingProxy,
}: ProxyFormDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<ProxyFormData>({
const [isTesting, setIsTesting] = useState(false);
const [mode, setMode] = useState<ProxyMode>("regular");
const [regularForm, setRegularForm] = useState<RegularFormData>({
name: "",
proxy_type: "http",
host: "",
@@ -52,9 +65,14 @@ export function ProxyFormDialog({
username: "",
password: "",
});
const [dynamicForm, setDynamicForm] = useState<DynamicFormData>({
name: "",
url: "",
format: "json",
});
const resetForm = useCallback(() => {
setFormData({
setRegularForm({
name: "",
proxy_type: "http",
host: "",
@@ -62,62 +80,134 @@ export function ProxyFormDialog({
username: "",
password: "",
});
setDynamicForm({
name: "",
url: "",
format: "json",
});
setMode("regular");
}, []);
// Load editing proxy data when dialog opens
useEffect(() => {
if (isOpen) {
if (editingProxy) {
setFormData({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
if (editingProxy.dynamic_proxy_url) {
setMode("dynamic");
setDynamicForm({
name: editingProxy.name,
url: editingProxy.dynamic_proxy_url,
format: editingProxy.dynamic_proxy_format || "json",
});
} else {
setMode("regular");
setRegularForm({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
}
} else {
resetForm();
}
}
}, [isOpen, editingProxy, resetForm]);
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) {
toast.error("Proxy name is required");
const handleTestDynamic = useCallback(async () => {
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
return;
}
setIsTesting(true);
try {
const settings = await invoke<ProxySettings>("fetch_dynamic_proxy", {
url: dynamicForm.url.trim(),
format: dynamicForm.format,
});
toast.success(
t("proxies.dynamic.testSuccess", {
host: settings.host,
port: settings.port,
}),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t("proxies.dynamic.testFailed", { error: errorMessage }));
} finally {
setIsTesting(false);
}
}, [dynamicForm, t]);
if (!formData.host.trim() || !formData.port) {
toast.error("Host and port are required");
return;
const handleSubmit = useCallback(async () => {
if (mode === "regular") {
if (!regularForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!regularForm.host.trim() || !regularForm.port) {
toast.error(
t("proxies.form.hostPortRequired", "Host and port are required"),
);
return;
}
} else {
if (!dynamicForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
return;
}
}
setIsSubmitting(true);
try {
const proxySettings = {
proxy_type: formData.proxy_type,
host: formData.host.trim(),
port: formData.port,
username: formData.username.trim() || undefined,
password: formData.password.trim() || undefined,
};
if (editingProxy) {
// Update existing proxy
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy updated successfully");
if (mode === "dynamic") {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
toast.success(t("toasts.success.proxyUpdated"));
} else {
// Create new proxy
await invoke("create_stored_proxy", {
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy created successfully");
if (mode === "dynamic") {
await invoke("create_stored_proxy", {
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("create_stored_proxy", {
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
toast.success(t("toasts.success.proxyCreated"));
}
onClose();
@@ -129,7 +219,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
}, [formData, editingProxy, onClose]);
}, [mode, regularForm, dynamicForm, editingProxy, onClose, t]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
@@ -137,125 +227,227 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
const isFormValid =
formData.name.trim() &&
formData.host.trim() &&
formData.port > 0 &&
formData.port <= 65535;
const isRegularValid =
regularForm.name.trim() &&
regularForm.host.trim() &&
regularForm.port > 0 &&
regularForm.port <= 65535;
const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim();
const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid;
const isEditingDynamic = editingProxy?.dynamic_proxy_url != null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingProxy ? "Edit Proxy" : "Create New Proxy"}
{editingProxy ? t("proxies.edit") : t("proxies.add")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proxy-name">Proxy Name</Label>
<Input
id="proxy-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
{!editingProxy && (
<Tabs value={mode} onValueChange={(v) => setMode(v as ProxyMode)}>
<TabsList className="w-full">
<TabsTrigger value="regular" className="flex-1">
{t("proxies.tabs.regular")}
</TabsTrigger>
<TabsTrigger value="dynamic" className="flex-1">
{t("proxies.tabs.dynamic")}
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={formData.proxy_type}
onValueChange={(value) =>
setFormData({ ...formData, proxy_type: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editingProxy && isEditingDynamic && (
<p className="text-xs text-muted-foreground">
{t("proxies.dynamic.description")}
</p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={formData.host}
onChange={(e) =>
setFormData({ ...formData, host: e.target.value })
}
placeholder="e.g. 127.0.0.1"
disabled={isSubmitting}
/>
</div>
{mode === "regular" ? (
<>
<div className="grid gap-2">
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
<Input
id="proxy-name"
value={regularForm.name}
onChange={(e) =>
setRegularForm({ ...regularForm, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({
...formData,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder="e.g. 8080"
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label>{t("proxies.form.type")}</Label>
<Select
value={regularForm.proxy_type}
onValueChange={(value) =>
setRegularForm({ ...regularForm, proxy_type: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label>
<Input
id="proxy-username"
value={formData.username}
onChange={(e) =>
setFormData({
...formData,
username: e.target.value,
})
}
placeholder="Proxy username"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={regularForm.host}
onChange={(e) =>
setRegularForm({ ...regularForm, host: e.target.value })
}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label>
<Input
id="proxy-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({
...formData,
password: e.target.value,
})
}
placeholder="Proxy password"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={regularForm.port}
onChange={(e) =>
setRegularForm({
...regularForm,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{t("proxies.form.username")} (
{t("proxies.form.usernamePlaceholder")})
</Label>
<Input
id="proxy-username"
value={regularForm.username}
onChange={(e) =>
setRegularForm({
...regularForm,
username: e.target.value,
})
}
placeholder={t("proxies.form.usernamePlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")} (
{t("proxies.form.passwordPlaceholder")})
</Label>
<Input
id="proxy-password"
type="password"
value={regularForm.password}
onChange={(e) =>
setRegularForm({
...regularForm,
password: e.target.value,
})
}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
</>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="dynamic-name">{t("proxies.form.name")}</Label>
<Input
id="dynamic-name"
value={dynamicForm.name}
onChange={(e) =>
setDynamicForm({ ...dynamicForm, name: e.target.value })
}
placeholder="e.g. My Tunnel"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dynamic-url">{t("proxies.dynamic.url")}</Label>
<Input
id="dynamic-url"
value={dynamicForm.url}
onChange={(e) =>
setDynamicForm({ ...dynamicForm, url: e.target.value })
}
placeholder={t("proxies.dynamic.urlPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>{t("proxies.dynamic.format")}</Label>
<Select
value={dynamicForm.format}
onValueChange={(value) =>
setDynamicForm({ ...dynamicForm, format: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
{t("proxies.dynamic.formatJson")}
</SelectItem>
<SelectItem value="text">
{t("proxies.dynamic.formatText")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{dynamicForm.format === "json"
? t("proxies.dynamic.formatJsonHint")
: t("proxies.dynamic.formatTextHint")}
</p>
</div>
<RippleButton
variant="outline"
size="sm"
onClick={handleTestDynamic}
disabled={isSubmitting || isTesting || !dynamicForm.url.trim()}
>
{isTesting
? t("proxies.dynamic.testing")
: t("proxies.dynamic.testUrl")}
</RippleButton>
</>
)}
</div>
<DialogFooter>
@@ -264,14 +456,14 @@ export function ProxyFormDialog({
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
{t("common.cancel", "Cancel")}
</RippleButton>
<LoadingButton
isLoading={isSubmitting}
onClick={handleSubmit}
disabled={!isFormValid}
>
{editingProxy ? "Update Proxy" : "Create Proxy"}
{editingProxy ? t("proxies.edit") : t("proxies.add")}
</LoadingButton>
</DialogFooter>
</DialogContent>
+4 -4
View File
@@ -429,14 +429,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between">
<span className="text-sm">Imported:</span>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
<span className="text-sm font-medium text-success">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">Skipped (duplicates):</span>
<span className="text-sm font-medium text-yellow-600 dark:text-yellow-400">
<span className="text-sm font-medium text-warning">
{importResult.skipped_count}
</span>
</div>
@@ -444,7 +444,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">Errors:</span>
<span className="text-sm font-medium text-red-600 dark:text-red-400">
<span className="text-sm font-medium text-destructive">
{importResult.errors.length}
</span>
</div>
@@ -459,7 +459,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-red-600 dark:text-red-400"
className="text-xs text-destructive"
>
{error}
</div>
+337 -368
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { GoGlobe, GoPlus } from "react-icons/go";
import { GoPlus } from "react-icons/go";
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -40,8 +40,6 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
import { FlagIcon } from "./flag-icon";
import { LocationProxyDialog } from "./location-proxy-dialog";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
import { VpnCheckButton } from "./vpn-check-button";
@@ -53,15 +51,16 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot(
item: { sync_enabled?: boolean; last_sync?: number },
liveStatus: SyncStatus | undefined,
errorMessage?: string,
): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -69,14 +68,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
return {
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
@@ -93,7 +100,6 @@ export function ProxyManagementDialog({
const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showLocationDialog, setShowLocationDialog] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@@ -104,6 +110,9 @@ export function ProxyManagementDialog({
const [proxySyncStatus, setProxySyncStatus] = useState<
Record<string, SyncStatus>
>({});
const [proxySyncErrors, setProxySyncErrors] = useState<
Record<string, string>
>({});
const [proxyInUse, setProxyInUse] = useState<Record<string, boolean>>({});
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
{},
@@ -119,6 +128,9 @@ export function ProxyManagementDialog({
const [vpnSyncStatus, setVpnSyncStatus] = useState<
Record<string, SyncStatus>
>({});
const [vpnSyncErrors, setVpnSyncErrors] = useState<Record<string, string>>(
{},
);
const [vpnInUse, setVpnInUse] = useState<Record<string, boolean>>({});
const [isTogglingVpnSync, setIsTogglingVpnSync] = useState<
Record<string, boolean>
@@ -126,50 +138,28 @@ export function ProxyManagementDialog({
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
used_mb: number;
limit_mb: number;
} | null>(null);
// Sort cloud-managed proxies first
const storedProxies = [...rawProxies].sort((a, b) => {
if (a.is_cloud_managed && !b.is_cloud_managed) return -1;
if (!a.is_cloud_managed && b.is_cloud_managed) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
// Fetch cloud proxy usage
useEffect(() => {
const fetchUsage = async () => {
try {
const usage = await invoke<{
used_mb: number;
limit_mb: number;
remaining_mb: number;
} | null>("cloud_get_proxy_usage");
setCloudProxyUsage(usage);
} catch {
// ignore
}
};
if (isOpen) {
void fetchUsage();
}
}, [isOpen]);
// Filter out cloud-managed and cloud-derived proxies (cloud proxies are deprecated)
const storedProxies = rawProxies
.filter((p) => !p.is_cloud_managed && !p.is_cloud_derived)
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
// Listen for proxy sync status events
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ id: string; status: string }>(
unlisten = await listen<{ id: string; status: string; error?: string }>(
"proxy-sync-status",
(event) => {
const { id, status } = event.payload;
const { id, status, error } = event.payload;
setProxySyncStatus((prev) => ({
...prev,
[id]: status as SyncStatus,
}));
if (error) {
setProxySyncErrors((prev) => ({ ...prev, [id]: error }));
}
},
);
};
@@ -185,14 +175,17 @@ export function ProxyManagementDialog({
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ id: string; status: string }>(
unlisten = await listen<{ id: string; status: string; error?: string }>(
"vpn-sync-status",
(event) => {
const { id, status } = event.payload;
const { id, status, error } = event.payload;
setVpnSyncStatus((prev) => ({
...prev,
[id]: status as SyncStatus,
}));
if (error) {
setVpnSyncErrors((prev) => ({ ...prev, [id]: error }));
}
},
);
};
@@ -370,7 +363,7 @@ export function ProxyManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Proxies & VPNs</DialogTitle>
<DialogDescription>
@@ -378,138 +371,116 @@ export function ProxyManagementDialog({
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="proxies">
<TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1">
Proxies
</TabsTrigger>
<TabsTrigger value="vpns" className="flex-1">
VPNs
</TabsTrigger>
</TabsList>
<ScrollArea className="overflow-y-auto flex-1">
<Tabs defaultValue="proxies">
<TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1">
Proxies
</TabsTrigger>
<TabsTrigger value="vpns" className="flex-1">
VPNs
</TabsTrigger>
</TabsList>
<TabsContent value="proxies">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowExportDialog(true)}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="w-4 h-4" />
Export
</RippleButton>
</div>
<div className="flex gap-2">
{storedProxies.some((p) => p.is_cloud_managed) && (
<TabsContent value="proxies">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowLocationDialog(true)}
onClick={() => setShowImportDialog(true)}
className="flex gap-2 items-center"
>
<GoGlobe className="w-4 h-4" />
Location
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
)}
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowExportDialog(true)}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="w-4 h-4" />
Export
</RippleButton>
</div>
<div className="flex gap-2">
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
</div>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading proxies...
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const isCloud = proxy.is_cloud_managed === true;
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
);
const isDerived = proxy.is_cloud_derived === true;
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading proxies...
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
proxySyncErrors[proxy.id],
);
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{isDerived && proxy.geo_country && (
<FlagIcon
countryCode={proxy.geo_country}
className="shrink-0"
/>
)}
{!isCloud && !isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{proxy.name}
{proxy.dynamic_proxy_url && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0"
>
Dynamic
</Badge>
)}
</div>
{isCloud && cloudProxyUsage && (
<span className="text-xs text-muted-foreground">
{cloudProxyUsage.used_mb} /{" "}
{cloudProxyUsage.limit_mb} MB used
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
{isCloud ? (
<Badge variant="outline">Cloud</Badge>
) : (
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
@@ -540,30 +511,32 @@ export function ProxyManagementDialog({
)}
</TooltipContent>
</Tooltip>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProfileId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
{!isCloud && !isDerived && (
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={
proxyCheckResults[proxy.id]
}
setCheckingProfileId={
setCheckingProxyId
}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -580,8 +553,6 @@ export function ProxyManagementDialog({
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
{!isCloud && (
<Tooltip>
<TooltipTrigger asChild>
<span>
@@ -613,199 +584,202 @@ export function ProxyManagementDialog({
)}
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
<TabsContent value="vpns">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<TabsContent value="vpns">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowVpnImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
</div>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowVpnImportDialog(true)}
onClick={handleCreateVpn}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
<RippleButton
size="sm"
onClick={handleCreateVpn}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
{isLoadingVpns ? (
<div className="text-sm text-muted-foreground">
Loading VPNs...
</div>
) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground">
No VPN configs created yet. Import or create one using the
buttons above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-16">Type</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vpnConfigs.map((vpn) => {
const syncDot = getSyncStatusDot(
vpn,
vpnSyncStatus[vpn.id],
);
return (
<TableRow key={vpn.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{isLoadingVpns ? (
<div className="text-sm text-muted-foreground">
Loading VPNs...
</div>
) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground">
No VPN configs created yet. Import or create one using the
buttons above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-16">Type</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vpnConfigs.map((vpn) => {
const syncDot = getSyncStatusDot(
vpn,
vpnSyncStatus[vpn.id],
vpnSyncErrors[vpn.id],
);
return (
<TableRow key={vpn.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
handleToggleVpnSync(vpn)
}
disabled={
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{vpnInUse[vpn.id] ? (
<p>
Sync cannot be disabled while this VPN
is used by synced profiles
</p>
) : (
<p>
{vpn.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditVpn(vpn)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit VPN</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteVpn(vpn)}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
handleToggleVpnSync(vpn)
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
disabled={
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
{vpnInUse[vpn.id] ? (
<p>
Cannot delete: in use by{" "}
{vpnUsage[vpn.id]} profile
{vpnUsage[vpn.id] > 1 ? "s" : ""}
Sync cannot be disabled while this
VPN is used by synced profiles
</p>
) : (
<p>Delete VPN</p>
<p>
{vpn.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
</Tabs>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditVpn(vpn)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit VPN</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteVpn(vpn)
}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{vpnUsage[vpn.id]} profile
{vpnUsage[vpn.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete VPN</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
</Tabs>
</ScrollArea>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
@@ -837,11 +811,6 @@ export function ProxyManagementDialog({
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
/>
<LocationProxyDialog
isOpen={showLocationDialog}
onClose={() => setShowLocationDialog(false)}
/>
<VpnFormDialog
isOpen={showVpnForm}
onClose={handleVpnFormClose}
+32 -3
View File
@@ -9,6 +9,7 @@ import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ColorPicker,
ColorPickerAlpha,
@@ -60,6 +61,7 @@ interface AppSettings {
api_enabled: boolean;
api_port: number;
api_token?: string;
disable_auto_updates?: boolean;
}
interface CustomThemeState {
@@ -116,6 +118,7 @@ export function SettingsDialog({
const [requestingPermission, setRequestingPermission] =
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const [isLinux, setIsLinux] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [e2ePassword, setE2ePassword] = useState("");
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
@@ -167,7 +170,7 @@ export function SettingsDialog({
const getStatusBadge = useCallback((isGranted: boolean) => {
if (isGranted) {
return (
<Badge variant="default" className="text-green-800 bg-green-100">
<Badge variant="default" className="text-success-foreground bg-success">
Granted
</Badge>
);
@@ -486,6 +489,8 @@ export function SettingsDialog({
const userAgent = navigator.userAgent;
const isMac = userAgent.includes("Mac");
setIsMacOS(isMac);
const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win");
setIsLinux(isLin);
if (isMac) {
loadPermissions().catch(console.error);
@@ -547,7 +552,8 @@ export function SettingsDialog({
JSON.stringify(originalSettings.custom_theme ?? {})) ||
(settings.theme !== "custom" &&
JSON.stringify(settings.custom_theme ?? {}) !==
JSON.stringify(originalSettings.custom_theme ?? {}));
JSON.stringify(originalSettings.custom_theme ?? {})) ||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -1012,7 +1018,7 @@ export function SettingsDialog({
</div>
) : (
<div className="space-y-1">
<p className="text-sm font-medium text-orange-600">
<p className="text-sm font-medium text-warning">
Trial expired
</p>
<p className="text-xs text-muted-foreground">
@@ -1028,6 +1034,29 @@ export function SettingsDialog({
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>
{!isLinux && (
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<Checkbox
id="disable-auto-updates"
checked={settings.disable_auto_updates || false}
onCheckedChange={(checked) =>
updateSetting("disable_auto_updates", checked as boolean)
}
/>
<div className="space-y-1">
<Label
htmlFor="disable-auto-updates"
className="text-sm font-medium"
>
{t("settings.disableAutoUpdates")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings.disableAutoUpdatesDescription")}
</p>
</div>
</div>
)}
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
+42 -1
View File
@@ -1,7 +1,9 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import MultipleSelector, { type Option } from "@/components/multiple-selector";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
@@ -33,6 +35,8 @@ interface SharedCamoufoxConfigFormProps {
browserType?: "camoufox" | "wayfern"; // Browser type to customize form options
crossOsUnlocked?: boolean; // Allow selecting non-current OS (paid feature)
limitedMode?: boolean; // Blur and disable advanced fields while keeping basic options accessible
profileVersion?: string;
profileBrowser?: string;
}
// Determine if fingerprint editing should be disabled
@@ -124,6 +128,8 @@ export function SharedCamoufoxConfigForm({
browserType = "camoufox",
crossOsUnlocked = false,
limitedMode = false,
profileVersion,
profileBrowser,
}: SharedCamoufoxConfigFormProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(
@@ -132,6 +138,26 @@ export function SharedCamoufoxConfigForm({
const [fingerprintConfig, setFingerprintConfig] =
useState<CamoufoxFingerprintConfig>({});
const [currentOS] = useState<CamoufoxOS>(getCurrentOS);
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
const handleGenerateFingerprint = async () => {
if (!profileVersion) return;
const browser = profileBrowser || browserType || "camoufox";
setIsGeneratingFingerprint(true);
try {
const configJson = JSON.stringify(config);
const result = await invoke<string>("generate_sample_fingerprint", {
browser,
version: profileVersion,
configJson,
});
onConfigChange("fingerprint", result);
} catch (error) {
console.error("Failed to generate fingerprint:", error);
} finally {
setIsGeneratingFingerprint(false);
}
};
// Get selected OS (defaults to current OS)
const selectedOS = config.os || currentOS;
@@ -223,7 +249,22 @@ export function SharedCamoufoxConfigForm({
<div className="space-y-6">
{/* Operating System Selection */}
<div className="space-y-3">
<Label>{t("fingerprint.osLabel")}</Label>
<div className="flex items-center justify-between">
<Label>{t("fingerprint.osLabel")}</Label>
{profileVersion && (!isCreating || crossOsUnlocked) && (
<LoadingButton
isLoading={isGeneratingFingerprint}
onClick={handleGenerateFingerprint}
disabled={readOnly}
variant="outline"
size="sm"
>
{isCreating
? t("fingerprint.generateFingerprint")
: t("fingerprint.refreshFingerprint")}
</LoadingButton>
)}
</div>
<Select
value={selectedOS}
onValueChange={(value: CamoufoxOS) => onConfigChange("os", value)}
+48 -23
View File
@@ -32,6 +32,14 @@ interface SyncConfigDialogProps {
onClose: (loginOccurred?: boolean) => void;
}
interface ProxyUsage {
used_mb: number;
limit_mb: number;
remaining_mb: number;
recurring_limit_mb: number;
extra_limit_mb: number;
}
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const { t } = useTranslation();
@@ -59,6 +67,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const [isVerifying, setIsVerifying] = useState(false);
const [activeTab, setActiveTab] = useState<string>("cloud");
const [liveProxyUsage, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "testing" | "connected" | "error"
@@ -99,6 +108,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
setCodeSent(false);
setOtpCode("");
setEmail("");
invoke<ProxyUsage | null>("cloud_get_proxy_usage")
.then(setLiveProxyUsage)
.catch(() => setLiveProxyUsage(null));
}
}, [isOpen, loadSettings]);
@@ -257,7 +269,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
{isLoggedIn && user ? (
<div className="grid gap-4 py-4">
<div className="flex gap-2 items-center text-sm">
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.cloud.connected")}
</div>
@@ -288,26 +300,39 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
})}
</span>
</div>
{user.proxyBandwidthLimitMb > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Proxy Bandwidth</span>
<span>
{user.proxyBandwidthUsedMb} /{" "}
{user.proxyBandwidthLimitMb +
(user.proxyBandwidthExtraMb || 0)}{" "}
MB
</span>
</div>
)}
{(user.proxyBandwidthExtraMb || 0) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Extra Bandwidth</span>
<span>
{user.proxyBandwidthExtraMb >= 1000
? `${(user.proxyBandwidthExtraMb / 1000).toFixed(1)} GB`
: `${user.proxyBandwidthExtraMb} MB`}
</span>
</div>
{liveProxyUsage && (
<>
<div className="flex justify-between">
<span className="text-muted-foreground">
Recurring Proxy Bandwidth
</span>
<span>
{Math.max(
0,
liveProxyUsage.recurring_limit_mb -
liveProxyUsage.used_mb,
)}{" "}
/ {liveProxyUsage.recurring_limit_mb} MB remaining
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
Extra Proxy Bandwidth
</span>
<span>
{Math.max(
0,
liveProxyUsage.remaining_mb -
Math.max(
0,
liveProxyUsage.recurring_limit_mb -
liveProxyUsage.used_mb,
),
)}{" "}
/ {liveProxyUsage.extra_limit_mb} MB remaining
</span>
</div>
</>
)}
{user.teamName && (
<>
@@ -505,13 +530,13 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-red-500" />
<div className="w-2 h-2 rounded-full bg-destructive" />
{t("sync.status.disconnected")}
</div>
)}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isCrossOsProfile } from "@/lib/browser-utils";
import { showErrorToast } from "@/lib/toast-utils";
import type {
BrowserProfile,
SyncSessionInfo,
WayfernFingerprintConfig,
} from "@/types";
import { RippleButton } from "./ui/ripple";
function getScreenSize(
profile: BrowserProfile,
): { w: number; h: number } | null {
const fp = profile.wayfern_config?.fingerprint;
if (!fp) return null;
try {
const parsed: WayfernFingerprintConfig = JSON.parse(fp);
const w = parsed.screenWidth ?? parsed.windowInnerWidth;
const h = parsed.screenHeight ?? parsed.windowInnerHeight;
if (w && h) return { w, h };
} catch {
// ignore
}
return null;
}
interface SyncFollowerDialogProps {
isOpen: boolean;
onClose: () => void;
leaderProfile: BrowserProfile | null;
allProfiles: BrowserProfile[];
runningProfiles: Set<string>;
}
export function SyncFollowerDialog({
isOpen,
onClose,
leaderProfile,
allProfiles,
runningProfiles,
}: SyncFollowerDialogProps) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const eligibleProfiles = allProfiles.filter(
(p) =>
p.id !== leaderProfile?.id &&
p.browser === "wayfern" &&
!runningProfiles.has(p.id) &&
!isCrossOsProfile(p),
);
const leaderScreenSize = useMemo(
() => (leaderProfile ? getScreenSize(leaderProfile) : null),
[leaderProfile],
);
const handleToggle = useCallback((id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}, []);
const handleStart = useCallback(() => {
if (!leaderProfile || selectedIds.size === 0) return;
const ids = Array.from(selectedIds);
const leaderId = leaderProfile.id;
setSelectedIds(new Set());
onClose();
invoke<SyncSessionInfo>("start_sync_session", {
leaderProfileId: leaderId,
followerProfileIds: ids,
}).catch((err) => {
console.error("Failed to start sync session:", err);
showErrorToast(err instanceof Error ? err.message : String(err));
});
}, [leaderProfile, selectedIds, onClose]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
setSelectedIds(new Set());
onClose();
}
},
[onClose],
);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("profiles.synchronizer.selectFollowers")}
</DialogTitle>
<DialogDescription>
{t("profiles.synchronizer.selectFollowersDesc")}
</DialogDescription>
</DialogHeader>
{leaderProfile && (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2 rounded-md bg-primary/10 border border-primary/20">
<Badge variant="default" className="text-xs">
{t("profiles.synchronizer.leader")}
</Badge>
<span className="text-sm font-medium truncate">
{leaderProfile.name}
</span>
</div>
<div className="border rounded-md">
<ScrollArea className="h-[150px]">
<div className="space-y-1 p-2">
{eligibleProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("profiles.synchronizer.wayfernOnly")}
</p>
) : (
eligibleProfiles.map((profile) => {
const followerSize = getScreenSize(profile);
const isFlaky =
leaderScreenSize &&
followerSize &&
(leaderScreenSize.w !== followerSize.w ||
leaderScreenSize.h !== followerSize.h);
return (
<div
key={profile.id}
className="flex items-center gap-3 p-2 rounded-md hover:bg-accent cursor-pointer"
onClick={() =>
handleToggle(
profile.id,
!selectedIds.has(profile.id),
)
}
onKeyDown={() => {}}
role="button"
tabIndex={0}
>
<Checkbox
checked={selectedIds.has(profile.id)}
onCheckedChange={(checked) =>
handleToggle(profile.id, checked === true)
}
/>
<span className="text-sm truncate flex-1">
{profile.name}
</span>
{isFlaky && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-warning border-warning/50 shrink-0"
>
{t("profiles.synchronizer.flakyBadge")}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
{t("profiles.synchronizer.flakyTooltip")}
</TooltipContent>
</Tooltip>
)}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
</div>
)}
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => handleOpenChange(false)}
>
{t("common.buttons.cancel")}
</RippleButton>
<RippleButton disabled={selectedIds.size === 0} onClick={handleStart}>
{t("profiles.synchronizer.startSession")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+5 -1
View File
@@ -11,6 +11,10 @@ import {
XAxis,
YAxis,
} from "recharts";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
Dialog,
@@ -192,7 +196,7 @@ export function TrafficDetailsDialog({
// Tooltip render function
const renderTooltip = React.useCallback(
(props: TooltipContentProps<number, string>) => {
(props: TooltipContentProps<ValueType, NameType>) => {
const { active, payload, label } = props;
if (!active || !payload?.length) return null;
+1 -1
View File
@@ -63,4 +63,4 @@ function AlertDescription({
);
}
export { Alert, AlertTitle, AlertDescription };
export { Alert, AlertDescription, AlertTitle };
+4 -4
View File
@@ -81,10 +81,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+9 -6
View File
@@ -6,7 +6,10 @@ import type {
Props as DefaultLegendContentProps,
LegendPayload,
} from "recharts/types/component/DefaultLegendContent";
import type { Payload } from "recharts/types/component/DefaultTooltipContent";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { cn } from "@/lib/utils";
@@ -111,7 +114,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps<number, string> &
TooltipContentProps<ValueType, NameType> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
@@ -195,8 +198,8 @@ const ChartTooltipContent = React.forwardRef<
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item: Payload<number, string>) => item.type !== "none")
.map((item: Payload<number, string>, index: number) => {
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;
@@ -370,9 +373,9 @@ function getPayloadConfigFromPayload(
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
};
+4 -1
View File
@@ -32,6 +32,7 @@ interface ComboboxProps {
placeholder?: string;
searchPlaceholder?: string;
className?: string;
disabled?: boolean;
}
export function Combobox({
@@ -41,16 +42,18 @@ export function Combobox({
placeholder = "Select option...",
searchPlaceholder = "Search...",
className,
disabled,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between", className)}
>
{value

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