Compare commits

..

135 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
zhom 266ecda1c7 chore: copy 2026-03-02 23:35:04 +04:00
zhom 0d793e4cd8 style: show lock icon for encrypted profiles 2026-03-02 19:12:11 +04:00
zhom 23d25928fc refactor: add cleanup for expired subscriptions 2026-03-02 18:49:47 +04:00
zhom 3cb68c53ad style: fix scrolling 2026-03-02 16:24:10 +04:00
zhom acd572ed23 feat: teams plan 2026-03-02 15:49:26 +04:00
zhom 9822ad4e3f chore: update dependencies 2026-03-02 12:40:12 +04:00
zhom 01d600f97e feat: set default search engine on camoufox 2026-03-02 12:37:35 +04:00
zhom e1461693da chore: linting 2026-03-02 12:37:35 +04:00
zhom 576119e5a3 refactor: better process management on linux 2026-03-02 12:37:35 +04:00
zhom 1ff17e6833 docs: appimages 2026-03-02 12:37:35 +04:00
zhom 2ffa37371d chore: proxy bypass integration tests 2026-03-02 12:37:34 +04:00
zhom 6fa0f1348a Merge pull request #223 from zhom/dependabot/npm_and_yarn/frontend-dependencies-dc31c4ae62
deps(deps): bump the frontend-dependencies group across 1 directory with 77 updates
2026-03-02 12:37:28 +04:00
dependabot[bot] e298496fb7 deps(deps): bump the frontend-dependencies group across 1 directory with 77 updates
Bumps the frontend-dependencies group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.575.0` | `0.576.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.0` | `25.3.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `16.2.7` | `16.3.1` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.996.0` | `3.1000.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.996.0` | `3.1000.0` |
| [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) | `6.0.3` | `7.2.0` |



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

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

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

Updates `@aws-sdk/client-s3` from 3.996.0 to 3.1000.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.1000.0/clients/client-s3)

Updates `@aws-sdk/s3-request-presigner` from 3.996.0 to 3.1000.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.1000.0/packages/s3-request-presigner)

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

Updates `@aws-sdk/core` from 3.973.12 to 3.973.15
- [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.0 to 3.972.3
- [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.10 to 3.972.13
- [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.12 to 3.972.15
- [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.10 to 3.972.13
- [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.10 to 3.972.13
- [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.11 to 3.972.14
- [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.10 to 3.972.13
- [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.10 to 3.972.13
- [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.10 to 3.972.13
- [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.3 to 3.972.6
- [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.3 to 3.972.6
- [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.972.10 to 3.973.1
- [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.3 to 3.972.6
- [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.3 to 3.972.6
- [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.3 to 3.972.6
- [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.3 to 3.972.6
- [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.12 to 3.972.15
- [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.3 to 3.972.6
- [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.12 to 3.972.15
- [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.0 to 3.996.3
- [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.3 to 3.972.6
- [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.0 to 3.996.3
- [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.996.0 to 3.999.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.999.0/packages/token-providers)

Updates `@aws-sdk/types` 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/types/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/types)

Updates `@aws-sdk/util-endpoints` from 3.996.0 to 3.996.3
- [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.3 to 3.972.6
- [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-user-agent-browser` from 3.972.3 to 3.972.6
- [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.972.11 to 3.973.0
- [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/v3.973.0/packages-internal/util-user-agent-node)

Updates `@aws-sdk/xml-builder` from 3.972.5 to 3.972.8
- [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 `@smithy/abort-controller` from 4.2.9 to 4.2.10
- [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.10/packages/abort-controller)

Updates `@smithy/config-resolver` from 4.4.7 to 4.4.9
- [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.9/packages/config-resolver)

Updates `@smithy/core` from 3.23.4 to 3.23.6
- [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.6/packages/core)

Updates `@smithy/credential-provider-imds` from 4.2.9 to 4.2.10
- [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.10/packages/credential-provider-imds)

Updates `@smithy/eventstream-codec` from 4.2.9 to 4.2.10
- [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.10/packages/eventstream-codec)

Updates `@smithy/eventstream-serde-browser` from 4.2.9 to 4.2.10
- [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.10/packages/eventstream-serde-browser)

Updates `@smithy/eventstream-serde-config-resolver` from 4.3.9 to 4.3.10
- [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.10/packages/eventstream-serde-config-resolver)

Updates `@smithy/eventstream-serde-node` from 4.2.9 to 4.2.10
- [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.10/packages/eventstream-serde-node)

Updates `@smithy/eventstream-serde-universal` from 4.2.9 to 4.2.10
- [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.10/packages/eventstream-serde-universal)

Updates `@smithy/fetch-http-handler` 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/fetch-http-handler/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/fetch-http-handler@5.3.11/packages/fetch-http-handler)

Updates `@smithy/hash-blob-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/hash-blob-browser/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/hash-blob-browser@4.2.11/packages/hash-blob-browser)

Updates `@smithy/hash-node` from 4.2.9 to 4.2.10
- [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.10/packages/hash-node)

Updates `@smithy/hash-stream-node` from 4.2.9 to 4.2.10
- [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.10/packages/hash-stream-node)

Updates `@smithy/invalid-dependency` from 4.2.9 to 4.2.10
- [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.10/packages/invalid-dependency)

Updates `@smithy/md5-js` from 4.2.9 to 4.2.10
- [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.10/packages/md5-js)

Updates `@smithy/middleware-content-length` from 4.2.9 to 4.2.10
- [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.10/packages/middleware-content-length)

Updates `@smithy/middleware-endpoint` from 4.4.18 to 4.4.20
- [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.20/packages/middleware-endpoint)

Updates `@smithy/middleware-retry` from 4.4.35 to 4.4.37
- [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.37/packages/middleware-retry)

Updates `@smithy/middleware-serde` 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-serde/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/middleware-serde@4.2.11/packages/middleware-serde)

Updates `@smithy/middleware-stack` from 4.2.9 to 4.2.10
- [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.10/packages/middleware-stack)

Updates `@smithy/node-config-provider` from 4.3.9 to 4.3.10
- [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.10/packages/node-config-provider)

Updates `@smithy/node-http-handler` from 4.4.11 to 4.4.12
- [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.12/packages/node-http-handler)

Updates `@smithy/property-provider` from 4.2.9 to 4.2.10
- [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.10/packages/property-provider)

Updates `@smithy/protocol-http` from 5.3.9 to 5.3.10
- [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.10/packages/protocol-http)

Updates `@smithy/querystring-builder` from 4.2.9 to 4.2.10
- [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.10/packages/querystring-builder)

Updates `@smithy/querystring-parser` from 4.2.9 to 4.2.10
- [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.10/packages/querystring-parser)

Updates `@smithy/service-error-classification` from 4.2.9 to 4.2.10
- [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.10/packages/service-error-classification)

Updates `@smithy/shared-ini-file-loader` from 4.4.4 to 4.4.5
- [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.5/packages/shared-ini-file-loader)

Updates `@smithy/signature-v4` from 5.3.9 to 5.3.10
- [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.10/packages/signature-v4)

Updates `@smithy/smithy-client` from 4.11.7 to 4.12.0
- [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.0/packages/smithy-client)

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

Updates `@smithy/url-parser` from 4.2.9 to 4.2.10
- [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.10/packages/url-parser)

Updates `@smithy/util-defaults-mode-browser` from 4.3.34 to 4.3.36
- [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.36/packages/util-defaults-mode-node)

Updates `@smithy/util-defaults-mode-node` from 4.2.37 to 4.2.39
- [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.39/packages/util-defaults-mode-node)

Updates `@smithy/util-endpoints` from 3.2.9 to 3.3.1
- [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.1/packages/util-endpoints)

Updates `@smithy/util-middleware` from 4.2.9 to 4.2.10
- [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.10/packages/util-middleware)

Updates `@smithy/util-retry` from 4.2.9 to 4.2.10
- [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.10/packages/util-retry)

Updates `@smithy/util-stream` from 4.5.14 to 4.5.15
- [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.15/packages/util-stream)

Updates `@smithy/util-waiter` from 4.2.9 to 4.2.10
- [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.10/packages/util-waiter)

Updates `cli-truncate` from 5.1.1 to 5.2.0
- [Release notes](https://github.com/sindresorhus/cli-truncate/releases)
- [Commits](https://github.com/sindresorhus/cli-truncate/compare/v5.1.1...v5.2.0)

Updates `strnum` from 2.1.2 to 2.2.0
- [Changelog](https://github.com/NaturalIntelligence/strnum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/strnum/commits)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.576.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: lint-staged
  dependency-version: 16.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1000.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.1000.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@types/supertest"
  dependency-version: 7.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/core"
  dependency-version: 3.973.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/crc64-nvme"
  dependency-version: 3.972.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-env"
  dependency-version: 3.972.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-http"
  dependency-version: 3.972.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-ini"
  dependency-version: 3.972.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-login"
  dependency-version: 3.972.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-node"
  dependency-version: 3.972.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-process"
  dependency-version: 3.972.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-sso"
  dependency-version: 3.972.13
  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.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-bucket-endpoint"
  dependency-version: 3.972.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-expect-continue"
  dependency-version: 3.972.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-flexible-checksums"
  dependency-version: 3.973.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-host-header"
  dependency-version: 3.972.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-location-constraint"
  dependency-version: 3.972.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-logger"
  dependency-version: 3.972.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-recursion-detection"
  dependency-version: 3.972.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-sdk-s3"
  dependency-version: 3.972.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-ssec"
  dependency-version: 3.972.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-user-agent"
  dependency-version: 3.972.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/nested-clients"
  dependency-version: 3.996.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/region-config-resolver"
  dependency-version: 3.972.6
  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.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/token-providers"
  dependency-version: 3.999.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/types"
  dependency-version: 3.973.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-endpoints"
  dependency-version: 3.996.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-format-url"
  dependency-version: 3.972.6
  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.6
  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.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/abort-controller"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/config-resolver"
  dependency-version: 4.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/core"
  dependency-version: 3.23.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/credential-provider-imds"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/eventstream-codec"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/eventstream-serde-browser"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/eventstream-serde-config-resolver"
  dependency-version: 4.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/eventstream-serde-node"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/eventstream-serde-universal"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/fetch-http-handler"
  dependency-version: 5.3.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/hash-blob-browser"
  dependency-version: 4.2.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/hash-node"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/hash-stream-node"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/invalid-dependency"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/md5-js"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-content-length"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-endpoint"
  dependency-version: 4.4.20
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-retry"
  dependency-version: 4.4.37
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-serde"
  dependency-version: 4.2.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-stack"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/node-config-provider"
  dependency-version: 4.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/node-http-handler"
  dependency-version: 4.4.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/property-provider"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/protocol-http"
  dependency-version: 5.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/querystring-builder"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/querystring-parser"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/service-error-classification"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/shared-ini-file-loader"
  dependency-version: 4.4.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/signature-v4"
  dependency-version: 5.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/smithy-client"
  dependency-version: 4.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/types"
  dependency-version: 4.13.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/url-parser"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-defaults-mode-browser"
  dependency-version: 4.3.36
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-defaults-mode-node"
  dependency-version: 4.2.39
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-endpoints"
  dependency-version: 3.3.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-middleware"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-retry"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-stream"
  dependency-version: 4.5.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-waiter"
  dependency-version: 4.2.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: cli-truncate
  dependency-version: 5.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: strnum
  dependency-version: 2.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 08:24:10 +00:00
zhom f6041192e9 Merge pull request #219 from zhom/dependabot/github_actions/github-actions-a9967a8187
ci(deps): bump the github-actions group with 4 updates
2026-03-02 11:47:40 +04:00
dependabot[bot] 4ef50672b4 ci(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [actions/ai-inference](https://github.com/actions/ai-inference), [anomalyco/opencode](https://github.com/anomalyco/opencode), [actions/setup-go](https://github.com/actions/setup-go) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `actions/ai-inference` from 2.0.6 to 2.0.7
- [Release notes](https://github.com/actions/ai-inference/releases)
- [Commits](https://github.com/actions/ai-inference/compare/a380166897b5408b8fb7dddd148142794cb5624a...e09e65981758de8b2fdab13c2bfb7c7d5493b0b6)

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

Updates `actions/setup-go` from 5.6.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/40f1582b2485089dde7abd97c1529aa768e1baff...4b73464bb391d4059bd26b0524d20df3927bd417)

Updates `crate-ci/typos` from 1.43.5 to 1.44.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/57b11c6b7e54c402ccd9cda953f1072ec4f78e33...631208b7aac2daa8b707f55e7331f9112b0e062d)

---
updated-dependencies:
- dependency-name: actions/ai-inference
  dependency-version: 2.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/setup-go
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 07:46:38 +00:00
zhom 3140ad99ae docs: copy 2026-03-02 11:37:18 +04:00
zhom 97b1225d40 refactor: better custom name 2026-03-02 11:29:17 +04:00
zhom 8a96d18e46 feat: extension management 2026-03-02 07:26:42 +04:00
zhom a723c8b30b fix: properly track download progress 2026-03-02 06:02:12 +04:00
zhom 4a56575dbd feat: profile settings refresh 2026-03-02 05:53:54 +04:00
zhom 3331699540 fix: allow usage of the API 2026-03-02 05:19:11 +04:00
zhom 1f28983a4e refactor: cleanup 2026-03-02 05:18:49 +04:00
zhom 362f3e423b chore: force pull homebrew 2026-03-02 05:18:28 +04:00
zhom 704bcb2b28 chore: linting 2026-02-24 15:02:31 +04:00
zhom 08559eef13 chore: version bump 2026-02-24 09:31:31 +04:00
zhom 0ff0570321 chore: linting 2026-02-24 09:30:29 +04:00
zhom 7eb56a2296 chore: linting 2026-02-24 08:41:48 +04:00
zhom e2fa6f2c5f chore: linting 2026-02-24 06:31:24 +04:00
zhom 8b83ece7be chore: don't fail fast 2026-02-24 06:31:18 +04:00
zhom 4fed80cf3c chore: update dependencies 2026-02-24 06:21:06 +04:00
zhom c1fb1e3c4b chore linting 2026-02-24 06:09:30 +04:00
zhom 7e367325be chore: spellcheck 2026-02-24 05:54:44 +04:00
zhom e6cb4e6082 feat: e2e encrypted sync 2026-02-24 05:51:48 +04:00
zhom 21d80fde56 chore: linting 2026-02-22 10:50:21 +04:00
zhom 3732d3a6e1 feat: ephemeral profiles 2026-02-22 10:17:25 +04:00
zhom 2e193987df chore: linting 2026-02-22 03:35:29 +04:00
zhom ddc2657165 refactor: better daemon management 2026-02-22 03:03:41 +04:00
zhom 98798b83df chore: ai inference refresh 2026-02-22 03:03:23 +04:00
zhom ed82f74932 chore: fix brew version bump 2026-02-22 00:46:57 +04:00
zhom cc5379f957 feat: rpm and deb repos 2026-02-22 00:34:32 +04:00
zhom 8b9ad44ebc feat: add more import/export formats for cookies 2026-02-21 22:13:02 +04:00
zhom 206be3ff12 refactor: allow custom location 2026-02-21 16:32:46 +04:00
zhom 1afc2ca5ff refactor: handle space in the user name 2026-02-21 16:21:03 +04:00
zhom c61b3d3188 feat: netscape cookie import 2026-02-21 15:50:23 +04:00
zhom 97da1ca288 refactor: allow sync for non-subscribers 2026-02-21 14:46:31 +04:00
zhom 6484656de0 chore: update pnpm 2026-02-21 14:28:56 +04:00
zhom 961e3f2185 Merge pull request #213 from zhom/dependabot/npm_and_yarn/frontend-dependencies-0e131dca9a
deps(deps): bump the frontend-dependencies group with 43 updates
2026-02-21 13:45:59 +04:00
zhom f515a4f327 Merge pull request #212 from zhom/dependabot/github_actions/github-actions-3d84b0132c
ci(deps): bump the github-actions group with 3 updates
2026-02-21 13:45:44 +04:00
dependabot[bot] 4ba2c5ec24 deps(deps): bump the frontend-dependencies group with 43 updates
Bumps the frontend-dependencies group with 43 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `25.8.11` | `25.8.13` |
| [motion](https://github.com/motiondivision/motion) | `12.34.2` | `12.34.3` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.994.0` | `3.995.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.994.0` | `3.995.0` |
| [@aws-sdk/signature-v4-multi-region](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-multi-region) | `3.994.0` | `3.995.0` |
| [@aws-sdk/util-user-agent-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-user-agent-node) | `3.972.9` | `3.972.10` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.3` | `2.4.4` |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-loong64-gnu](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-loong64-musl](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-ppc64-gnu](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-ppc64-musl](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-openbsd-x64](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-openharmony-arm64](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-win32-x64-gnu](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |
| [framer-motion](https://github.com/motiondivision/motion) | `12.34.2` | `12.34.3` |
| [motion-dom](https://github.com/motiondivision/motion) | `12.34.2` | `12.34.3` |
| [rollup](https://github.com/rollup/rollup) | `4.57.1` | `4.58.0` |


Updates `i18next` from 25.8.11 to 25.8.13
- [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.11...v25.8.13)

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

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

Updates `@aws-sdk/client-s3` from 3.994.0 to 3.995.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.995.0/clients/client-s3)

Updates `@aws-sdk/s3-request-presigner` from 3.994.0 to 3.995.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.995.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-multi-region` from 3.994.0 to 3.995.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-multi-region/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.995.0/packages/signature-v4-multi-region)

Updates `@aws-sdk/util-user-agent-node` from 3.972.9 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/util-user-agent-node/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/util-user-agent-node)

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

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

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

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

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

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

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

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

Updates `@rollup/rollup-android-arm-eabi` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-android-arm64` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-darwin-arm64` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-darwin-x64` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-freebsd-arm64` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-freebsd-x64` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-arm64-musl` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-loong64-gnu` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-loong64-musl` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-ppc64-gnu` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-ppc64-musl` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-x64-gnu` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-linux-x64-musl` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-openbsd-x64` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-openharmony-arm64` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-win32-x64-gnu` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

Updates `@rollup/rollup-win32-x64-msvc` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

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

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

Updates `rollup` from 4.57.1 to 4.58.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.58.0)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: motion
  dependency-version: 12.34.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.995.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.995.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/signature-v4-multi-region"
  dependency-version: 3.995.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-user-agent-node"
  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.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.4.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loong64-gnu"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loong64-musl"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-gnu"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-musl"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-openbsd-x64"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-openharmony-arm64"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-gnu"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: framer-motion
  dependency-version: 12.34.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: motion-dom
  dependency-version: 12.34.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.58.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-21 09:36:31 +00:00
dependabot[bot] f378f0fbde ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [anomalyco/opencode](https://github.com/anomalyco/opencode), [crate-ci/typos](https://github.com/crate-ci/typos) and [actions/stale](https://github.com/actions/stale).


Updates `anomalyco/opencode` from 1.2.4 to 1.2.10
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/d1482e148399bfaf808674549199f5f4aa69a22d...296250f1b7e1ec992a3a33bee999f5e09a1697d0)

Updates `crate-ci/typos` from 1.43.4 to 1.43.5
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/78bc6fb2c0d734235d57a2d6b9de923cc325ebdd...57b11c6b7e54c402ccd9cda953f1072ec4f78e33)

Updates `actions/stale` from 10.1.1 to 10.2.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/997185467fa4f803885201cee163a9f38240193d...b5d41d4e1d5dceea10e7104786b73624c18a190f)

---
updated-dependencies:
- dependency-name: anomalyco/opencode
  dependency-version: 1.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.43.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-21 09:04:17 +00:00
zhom c816fee184 chore: version bump 2026-02-20 09:04:37 +04:00
zhom 4872dcc8ad refactor: sync 2026-02-20 07:22:42 +04:00
zhom 8bc1ea500b chore: linting 2026-02-20 05:00:13 +04:00
zhom 7ed19f3a8f chore: pnpm update 2026-02-20 04:55:40 +04:00
zhom e5663515a7 refactor: cleanup and decouple 2026-02-20 04:44:35 +04:00
zhom 0f579cb97d fix: fetch auto-update on windows 2026-02-18 15:32:10 +04:00
zhom de896f895c refactor: check subscription 2026-02-18 13:23:20 +04:00
zhom 3d57a622b1 chore: version bump 2026-02-18 09:41:20 +04:00
zhom 5dfe7cb216 fix: download zip instead of exe 2026-02-18 09:40:45 +04:00
zhom dea0181009 chore: version bump 2026-02-17 23:15:36 +04:00
zhom 4983f622d0 fix: properly signed the app 2026-02-17 23:15:06 +04:00
zhom 6654ab9fdc refactor: simplify auto-update login 2026-02-17 22:54:45 +04:00
zhom d490ad3612 chore: version bump 2026-02-17 17:31:43 +04:00
zhom e31de5ac99 fix: proper state geotargeting 2026-02-17 17:31:15 +04:00
zhom 7cd3e922f5 chore: remove brew comment 2026-02-17 16:37:07 +04:00
zhom 547bd89de9 fix: find wayfern binary 2026-02-17 16:36:57 +04:00
zhom edabfd0831 chore: download assets earlier 2026-02-17 00:00:58 +04:00
zhom 127912c68c chore: use correct dir for temp repo 2026-02-16 22:18:41 +04:00
zhom af2aa36ac6 feat: block launching profiles for incompatible systems 2026-02-16 22:18:11 +04:00
zhom d52493b7e4 docs: add email links 2026-02-16 21:08:28 +04:00
zhom dfc94c10ff chore: add latest release for nightly 2026-02-16 20:45:38 +04:00
zhom a008e11504 refactor: properly handle admin account 2026-02-16 19:58:23 +04:00
zhom 6f28ed3a47 chore: sign ad-hoc only if no env variables are set 2026-02-16 19:57:41 +04:00
zhom c30a44a13d docs: update preview 2026-02-16 19:11:45 +04:00
zhom b600a61da8 chore: version bump 2026-02-16 16:30:22 +04:00
zhom 9d31d68f14 chore: fix permissions 2026-02-16 16:29:55 +04:00
zhom 12837b740d chore: version bump 2026-02-16 15:50:07 +04:00
zhom 964cd03681 feat: enable production windows builds 2026-02-16 15:49:36 +04:00
zhom e8e98a36ae fix: don't show self-hosted login after logging out 2026-02-16 15:47:25 +04:00
zhom 2acbc6c147 chore: version bump 2026-02-16 15:00:23 +04:00
zhom 6eb6148a9a style: dialog scrooling 2026-02-16 14:55:43 +04:00
zhom d6b05e04a6 fix: use correct prefix for counting 2026-02-16 14:46:17 +04:00
zhom 59706e62c1 refactor: better error handling 2026-02-16 13:50:18 +04:00
zhom bb8356eeef feat: better proxy management 2026-02-16 10:03:27 +04:00
zhom 777be9b9dc build: do not ignore configs 2026-02-16 00:16:49 +04:00
zhom 354e6f4f6b chore: cleanup 2026-02-16 00:08:12 +04:00
184 changed files with 37374 additions and 9871 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ Hi there! To expedite issue processing please search open and closed issues befo
## Your Environment
<!-- Please provide as much information as you feel comfortable to help us understand the issue better -->
<!-- Please provide as much information as you feel comfortable to help the maintainers understand the issue better -->
## Exception or Error or Screenshot
+33
View File
@@ -0,0 +1,33 @@
messages:
- role: system
content: |-
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
Guidelines:
- Use clear, user-friendly language
- Group related commits logically
- Omit minor commits like formatting, typos unless significant
- Focus on user-facing changes
- Use emojis sparingly and consistently
- Keep descriptions concise but informative
- If commits are unclear, infer the purpose from the context
- Only include sections that have relevant changes
- role: user
content: |-
Generate release notes for version {{version}} based on these commits:
{{commits}}
Use this format:
## What's New in {{version}}
[Brief 1-2 sentence overview]
### New Features
### Bug Fixes
### Improvements
### Documentation
### Dependencies
### Developer Experience
model: openai/gpt-4.1
+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 -300
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,90 +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: Get issue templates
id: get-templates
run: |
if [ -f ".github/ISSUE_TEMPLATE/01-bug-report.md" ]; then
echo "bug-template-exists=true" >> $GITHUB_OUTPUT
fi
if [ -f ".github/ISSUE_TEMPLATE/02-feature-request.md" ]; then
echo "feature-template-exists=true" >> $GITHUB_OUTPUT
fi
- name: Create issue analysis prompt
id: create-prompt
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
run: |
cat > issue_analysis.txt << EOF
## Issue Content to Analyze:
**Title:** $ISSUE_TITLE
**Body:**
$ISSUE_BODY
**Labels:** $ISSUE_LABELS
EOF
- name: Validate issue with AI
id: validate
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
with:
prompt-file: issue_analysis.txt
system-prompt: |
You are an issue validation assistant for Donut Browser, an anti-detect browser.
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
**For Bug Reports, the issue should include:**
1. Clear description of the problem
2. Steps to reproduce the issue (numbered list preferred)
3. Expected vs actual behavior
4. Environment information (OS, browser version, etc.)
5. Error messages, stack traces, or screenshots if applicable
**For Feature Requests, the issue should include:**
1. Clear description of the requested feature
2. Use case or problem it solves
3. Proposed solution or how it should work
4. Priority level or importance
**General Requirements for all issues:**
1. Descriptive title
2. Sufficient detail to understand and act upon
3. Professional tone and clear communication
Respond ONLY with valid JSON (no markdown fences). Keep responses concise.
JSON structure:
{
"is_valid": true|false,
"issue_type": "bug_report"|"feature_request"|"other",
"missing_info": ["item1", "item2"],
"suggestions": ["suggestion1", "suggestion2"],
"overall_assessment": "One sentence assessment"
}
IMPORTANT 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
- Output ONLY the JSON object, nothing else
model: gpt-5-mini
- name: Check if first-time contributor
id: check-first-time
env:
@@ -118,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 us 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 our 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 us know you have made the updates.\n\n"
printf -- "---\n*This validation was performed automatically to ensure we have all the information needed to help you effectively.*\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@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
- 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_analysis.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
@@ -237,141 +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: Create PR analysis prompt
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
{
printf "## Pull Request to Review:\n\n"
printf "**Title:** %s\n\n" "$PR_TITLE"
printf "**Description:**\n%s\n\n" "$PR_BODY"
printf "**Diff:**\n"
cat pr_diff_truncated.txt
} > pr_analysis.txt
- name: Analyze PR with AI
id: analyze
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
with:
prompt-file: pr_analysis.txt
system-prompt: |
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
Respond ONLY with valid JSON (no markdown fences).
JSON structure:
{
"summary": "Brief 1-2 sentence summary of what this PR does",
"quality_score": "good"|"needs_work"|"critical_issues",
"feedback": ["feedback point 1", "feedback point 2"],
"suggestions": ["suggestion 1", "suggestion 2"],
"security_notes": ["security note if any"] or []
}
IMPORTANT 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
- Output ONLY the JSON object, nothing else
model: gpt-5-mini
- 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@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
- 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_analysis.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: |
@@ -386,8 +129,9 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Run opencode
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
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
+2 -1
View File
@@ -29,6 +29,7 @@ permissions:
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-22.04, windows-latest]
@@ -43,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
+7 -40
View File
@@ -82,47 +82,14 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
prompt-file: commits.txt
system-prompt: |
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser.
Analyze the provided commit messages and generate well-structured release notes following this format:
## What's New in ${{ steps.get-previous-tag.outputs.current-tag }}
[Brief 1-2 sentence overview of the release]
### ✨ New Features
[List new features with brief descriptions]
### 🐛 Bug Fixes
[List bug fixes]
### 🔧 Improvements
[List improvements and enhancements]
### 📚 Documentation
[List documentation updates if any]
### 🔄 Dependencies
[List dependency updates if any]
### 🛠️ Developer Experience
[List development-related changes if any]
Guidelines:
- Use clear, user-friendly language
- Group related commits logically
- Omit minor commits like formatting, typos unless significant
- Focus on user-facing changes
- Use emojis sparingly and consistently
- Keep descriptions concise but informative
- If commits are unclear, infer the purpose from the context
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
model: gpt-5-mini
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
version: ${{ steps.get-previous-tag.outputs.current-tag }}
file_input: |
commits: ./commits.txt
max-tokens: 4096
- name: Update release with generated notes
if: steps.get-release.outputs.is-prerelease == 'false'
+103 -2
View File
@@ -5,6 +5,12 @@ on:
tags:
- "v*"
permissions:
contents: write
security-events: write
packages: read
actions: read
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -85,13 +91,18 @@ jobs:
arch: "aarch64"
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
- platform: "windows-latest"
args: "--target x86_64-pc-windows-msvc --verbose"
arch: "x86_64"
target: "x86_64-pc-windows-msvc"
pkg_target: "latest-win-x64"
runs-on: ${{ matrix.platform }}
steps:
- 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
@@ -114,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
@@ -219,3 +230,93 @@ jobs:
# with:
# branch: main
# commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
publish-repos:
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Download Linux packages from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p /tmp/packages
gh release download "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--pattern "*.deb" \
--dir /tmp/packages
gh release download "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--pattern "*.rpm" \
--dir /tmp/packages
echo "Downloaded packages:"
ls -la /tmp/packages/
- name: Setup Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 #v6.3.0
with:
go-version: "1.23"
cache: false
- name: Install repogen
run: |
go install github.com/ralt/repogen/cmd/repogen@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Sync existing repo metadata from R2
env:
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 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: |
repogen generate \
--input-dir /tmp/packages \
--output-dir /tmp/repo \
--incremental \
--arch amd64,arm64 \
--origin "Donut Browser" \
--label "Donut Browser" \
--codename stable \
--components main \
--verbose
- name: Upload repository to R2
env:
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 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:
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}" || echo " (listing not available)"
echo "RPM repo:"
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
+60 -2
View File
@@ -5,6 +5,12 @@ on:
branches:
- main
permissions:
contents: write
security-events: write
packages: read
actions: read
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -95,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
@@ -118,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
@@ -228,3 +234,55 @@ jobs:
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f $RUNNER_TEMP/build_certificate.p12 || true
update-nightly-release:
needs: [rolling-release]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Generate nightly tag
id: tag
run: |
TIMESTAMP=$(date -u +"%Y-%m-%d")
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
- name: Update rolling nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NIGHTLY_TAG="${{ steps.tag.outputs.nightly_tag }}"
ASSETS_DIR="/tmp/nightly-assets"
# Download all assets from the per-commit nightly release
mkdir -p "$ASSETS_DIR"
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
# Rename versioned filenames to stable nightly names
cd "$ASSETS_DIR"
for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
for f in Donut_*_aarch64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.AppImage; done
for f in Donut_*_amd64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.AppImage; done
for f in Donut_*_amd64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.deb; done
for f in Donut_*_arm64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_arm64.deb; done
for f in Donut-*.x86_64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_x86_64.rpm; done
for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
cd "$GITHUB_WORKSPACE"
# Delete existing rolling nightly release and tag
gh release delete nightly --yes 2>/dev/null || true
git push --delete origin nightly 2>/dev/null || true
# Create new rolling nightly release with all assets
gh release create nightly \
"$ASSETS_DIR"/Donut_nightly_* \
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
--title "Donut Browser Nightly" \
--notes "Automatically updated nightly build from the latest main branch.\n\nCommit: ${GITHUB_SHA}" \
--prerelease
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Spell Check Repo
uses: crate-ci/typos@78bc6fb2c0d734235d57a2d6b9de923cc325ebdd #v1.43.4
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
+4 -3
View File
@@ -24,6 +24,7 @@ jobs:
rust-sync-e2e:
name: Rust Sync E2E Tests
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-22.04]
@@ -34,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
@@ -50,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"
@@ -93,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
+28
View File
@@ -13,6 +13,7 @@
"autoconfig",
"autologin",
"biomejs",
"boringtun",
"breezedark",
"browserforge",
"busctl",
@@ -31,6 +32,7 @@
"cmdk",
"codegen",
"codesign",
"codesigning",
"commitish",
"Crashpad",
"CTYPE",
@@ -41,9 +43,12 @@
"DBAPI",
"dconf",
"debuginfo",
"desynced",
"devedition",
"direnv",
"distro",
"dists",
"DMABUF",
"doctest",
"doesn",
"domcontentloaded",
@@ -65,6 +70,7 @@
"gettimezone",
"gifs",
"globset",
"GOPATH",
"gsettings",
"healthreport",
"hiddenimports",
@@ -77,7 +83,9 @@
"idletime",
"idna",
"infobars",
"inkey",
"Inno",
"isps",
"kdeglobals",
"keras",
"KHTML",
@@ -92,6 +100,7 @@
"libayatana",
"libc",
"libcairo",
"libfuse",
"libgdk",
"libglib",
"libpango",
@@ -125,16 +134,20 @@
"ntlm",
"numpy",
"objc",
"oneshot",
"opencode",
"orhun",
"orjson",
"osascript",
"oscpu",
"outpath",
"OVPN",
"passout",
"patchelf",
"pathex",
"pathlib",
"peerconnection",
"PHANDLER",
"pids",
"pixbuf",
"pkexec",
@@ -153,10 +166,17 @@
"pytest",
"pyyaml",
"quic",
"ralt",
"ramdisk",
"repodata",
"repogen",
"reportingpolicy",
"reqwest",
"resvg",
"ridedott",
"rlib",
"rsplit",
"rusqlite",
"rustc",
"rwxr",
"SARIF",
@@ -172,9 +192,11 @@
"shadcn",
"showcursor",
"shutil",
"sighandler",
"signon",
"signum",
"sklearn",
"smoltcp",
"SMTO",
"sonner",
"splitn",
@@ -183,6 +205,7 @@
"stefanzweifel",
"subdirs",
"subkey",
"subsec",
"SUPPRESSMSGBOXES",
"swatinem",
"sysinfo",
@@ -194,14 +217,18 @@
"TERX",
"testpass",
"testuser",
"thiserror",
"timedatectl",
"titlebar",
"tkinter",
"tmpfs",
"tqdm",
"trackingprotection",
"trailhead",
"tungstenite",
"turbopack",
"turtledemo",
"typer",
"udeps",
"unlisten",
"unminimize",
@@ -212,6 +239,7 @@
"venv",
"vercel",
"VERYSILENT",
"vpns",
"wayfern",
"webgl",
"webrtc",
+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.
+4 -4
View File
@@ -1,10 +1,10 @@
# Code of Conduct
All participants of the Donut Browser project (referred to as "the project") are expected to abide by our Code of Conduct, both online and during in-person events that are hosted and/or associated with the project.
All participants of the Donut Browser project (referred to as "the project") are expected to abide by this Code of Conduct, both online and during in-person events that are hosted and/or associated with the project.
## The Pledge
In the interest of fostering an open and welcoming environment, we pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
In the interest of fostering an open and welcoming environment, the maintainers pledge to make participation in the project and the community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## The Standards
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
## Enforcement
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
Violations of the Code of Conduct may be reported to [contact@donutbrowser.com](mailto:contact@donutbrowser.com). All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
The maintainers hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that are deemed inappropriate, threatening, offensive, or harmful.
+7 -7
View File
@@ -10,11 +10,11 @@ Do keep in mind before you start working on an issue / posting a PR:
- Search existing PRs related to that issue which might close them
- Confirm if other contributors are working on the same issue
- Check if the feature aligns with our roadmap and project goals
- Check if the feature aligns with the project's roadmap and goals
## Contributor License Agreement
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to our [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to the [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
- Your contributions can be used in the open source version of Donut Browser (licensed under AGPL-3.0)
- Donut Browser can offer commercial licenses for the software, including your contributions
@@ -27,7 +27,7 @@ When you submit your first pull request, you acknowledge that you agree to the t
- PRs with tests are highly appreciated
- Avoid adding third party libraries, whenever possible
- Unless you are helping out by updating dependencies, you should not be uploading your lock files or updating any dependencies in your PR
- If you are unsure where to start, open a discussion and we will point you to a good first issue
- If you are unsure where to start, open a discussion to get pointed to a good first issue
## Development Setup
@@ -80,7 +80,7 @@ This will start the app for local development with live reloading.
## Code Style & Quality
We use several tools to maintain code quality:
The project uses several tools to maintain code quality:
- **Biome** for JavaScript/TypeScript linting and formatting
- **Clippy** for Rust linting
@@ -88,7 +88,7 @@ We use several tools to maintain code quality:
### Before Committing
Run these commands to ensure your code meets our standards:
Run these commands to ensure your code meets the project's standards:
```bash
# Format and lint frontend code
@@ -151,7 +151,7 @@ Refs #00000
### PR Checklist
- [ ] Code follows our style guidelines
- [ ] Code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
@@ -187,7 +187,7 @@ Please note that this project is released with a [Contributor Code of Conduct](C
## Recognition
All contributors will be recognized! We use the all-contributors specification to acknowledge everyone who contributes to the project.
All contributors will be recognized! The project uses the all-contributors specification to acknowledge everyone who contributes.
---
+16 -7
View File
@@ -25,11 +25,7 @@
</a>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png" />
<img alt="Preview" src="assets/preview.png" />
</picture>
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
## Features
@@ -46,6 +42,19 @@
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
<details>
<summary>Troubleshooting AppImage on Linux</summary>
If the AppImage segfaults on launch, install **libfuse2** (`sudo apt install libfuse2` / `yay -S libfuse2` / `sudo dnf install fuse-libs`), or bypass FUSE entirely:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./Donut.Browser_x.x.x_amd64.AppImage
```
If that gives an EGL display error, try adding `WEBKIT_DISABLE_DMABUF_RENDERER=1` or `GDK_BACKEND=x11` to the command above. If issues persist, the **.deb** / **.rpm** packages are a more reliable alternative.
</details>
<!-- ## Supported Platforms
-**macOS** (Apple Silicon)
@@ -68,7 +77,7 @@ Donut Browser supports syncing profiles, proxies, and groups across devices via
## Community
Have questions or want to contribute? We'd love to hear from you!
Have questions or want to contribute? The team would love to hear from you!
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
@@ -117,7 +126,7 @@ Have questions or want to contribute? We'd love to hear from you!
## Contact
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com) and the team will get back to you as fast as possible.
## License
+2 -2
View File
@@ -8,7 +8,7 @@ We take the security of Donut Browser seriously. If you believe you have found a
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
Instead, please send an email to **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)** with the subject line "Security Vulnerability Report".
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
@@ -32,7 +32,7 @@ This information will help us triage your report more quickly.
## Contact
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
For urgent security matters, please contact us at **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)**.
For general questions about this security policy, you can also reach out through:
+4
View File
@@ -6,3 +6,7 @@ extend-exclude = [
"src-tauri/build.rs",
"src-tauri/tests/fixtures/test.ovpn",
]
[default.extend-words]
DBE = "DBE"
nd = "nd"
Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

-4
View File
@@ -7,8 +7,4 @@ coverage
.tmp
.git
*.log
src
test
tsconfig.json
tsconfig.build.json
nest-cli.json
+11
View File
@@ -0,0 +1,11 @@
[phases.setup]
nixPkgs = ["nodejs_22"]
[phases.install]
cmds = ["npm install --include=dev"]
[phases.build]
cmds = ["npm run build", "npm prune --omit=dev"]
[start]
cmd = "npm run start:prod"
+9 -9
View File
@@ -18,12 +18,12 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.990.0",
"@aws-sdk/s3-request-presigner": "^3.990.0",
"@nestjs/common": "^11.1.13",
"@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.13",
"@nestjs/platform-express": "^11.1.13",
"@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.13",
"@nestjs/testing": "^11.1.16",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.3",
"@types/supertest": "^6.0.3",
"jest": "^30.2.0",
"@types/node": "^25.5.0",
"@types/supertest": "^7.2.0",
"jest": "^30.3.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
+6 -2
View File
@@ -43,6 +43,7 @@ export class AuthGuard implements CanActivate {
prefix: "",
teamPrefix: null,
profileLimit: 0,
teamProfileLimit: 0,
} satisfies UserContext;
return true;
}
@@ -59,10 +60,13 @@ export class AuthGuard implements CanActivate {
prefix: decoded.prefix || `users/${decoded.sub}/`,
teamPrefix: decoded.teamPrefix || null,
profileLimit: decoded.profileLimit || 0,
teamProfileLimit: decoded.teamProfileLimit || 0,
} satisfies UserContext;
return true;
} catch {
// JWT verification failed — fall through to error
} catch (err) {
this.logger.warn(
`JWT verification failed: ${err instanceof Error ? err.message : err}`,
);
}
}
@@ -3,4 +3,5 @@ export interface UserContext {
prefix: string; // '' for self-hosted, 'users/{id}/' for cloud
teamPrefix: string | null; // 'teams/{id}/' or null
profileLimit: number; // 0 for unlimited (self-hosted)
teamProfileLimit: number; // 0 for unlimited or non-team users
}
+7 -7
View File
@@ -1,13 +1,10 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module.js";
function validateEnv() {
const required = ["SYNC_TOKEN"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error(
`Missing required environment variables: ${missing.join(", ")}`,
);
if (!process.env.SYNC_TOKEN && !process.env.SYNC_JWT_PUBLIC_KEY) {
console.error("Either SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY must be set");
process.exit(1);
}
}
@@ -15,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: "*",
@@ -0,0 +1,38 @@
import {
Body,
Controller,
Headers,
HttpCode,
Post,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SyncService } from "./sync.service.js";
@Controller("v1/internal")
export class InternalController {
private readonly internalKey: string | undefined;
constructor(
private readonly syncService: SyncService,
private readonly configService: ConfigService,
) {
this.internalKey = this.configService.get<string>("INTERNAL_KEY");
}
@Post("cleanup-excess-profiles")
@HttpCode(200)
async cleanupExcessProfiles(
@Headers("x-internal-key") key: string,
@Body() body: { userId: string; maxProfiles: number },
) {
if (!this.internalKey || key !== this.internalKey) {
throw new UnauthorizedException("Invalid internal key");
}
return this.syncService.cleanupExcessProfiles(
body.userId,
body.maxProfiles,
);
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { InternalController } from "./internal.controller.js";
import { SyncController } from "./sync.controller.js";
import { SyncService } from "./sync.service.js";
@Module({
controllers: [SyncController],
controllers: [SyncController, InternalController],
providers: [SyncService, AuthGuard],
exports: [SyncService],
})
+209 -16
View File
@@ -145,6 +145,7 @@ export class SyncService implements OnModuleInit {
*/
private scopeKey(ctx: UserContext, key: string): string {
if (ctx.mode === "self-hosted") return key;
if (ctx.teamPrefix && key.startsWith(ctx.teamPrefix)) return key;
return `${ctx.prefix}${key}`;
}
@@ -309,10 +310,12 @@ export class SyncService implements OnModuleInit {
);
const userPrefix = ctx?.prefix || "";
const teamPrefix = ctx?.teamPrefix || "";
const objects = (response.Contents || []).map((obj) => {
// Strip user prefix from returned keys so client sees relative keys
let key = obj.Key || "";
if (userPrefix && key.startsWith(userPrefix)) {
if (teamPrefix && key.startsWith(teamPrefix)) {
key = key.substring(teamPrefix.length);
} else if (userPrefix && key.startsWith(userPrefix)) {
key = key.substring(userPrefix.length);
}
return {
@@ -481,11 +484,15 @@ export class SyncService implements OnModuleInit {
): Observable<SubscribeEventDto> {
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
// Scope prefixes for cloud users; self-hosted gets root prefixes
const prefixes =
ctx.mode === "self-hosted"
? basePrefixes
: basePrefixes.map((p) => `${ctx.prefix}${p}`);
let prefixes: string[];
if (ctx.mode === "self-hosted") {
prefixes = basePrefixes;
} else {
prefixes = basePrefixes.map((p) => `${ctx.prefix}${p}`);
if (ctx.teamPrefix) {
prefixes.push(...basePrefixes.map((p) => `${ctx.teamPrefix}${p}`));
}
}
// Per-connection state (not shared across subscribers)
let lastKnownState = new Map<string, string>();
@@ -547,6 +554,135 @@ export class SyncService implements OnModuleInit {
this.changeSubject.next(event);
}
async cleanupExcessProfiles(
userId: string,
maxProfiles: number,
): Promise<{ deletedProfiles: string[]; remaining: number }> {
const userPrefix = `users/${userId}/`;
const profilePrefix = `${userPrefix}profiles/`;
// List all profile directories
const profiles: { id: string; lastModified: Date }[] = [];
let continuationToken: string | undefined;
do {
const result = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: profilePrefix,
Delimiter: "/",
MaxKeys: 1000,
ContinuationToken: continuationToken,
}),
);
if (result.CommonPrefixes) {
for (const cp of result.CommonPrefixes) {
if (!cp.Prefix) continue;
const profileId = cp.Prefix.replace(profilePrefix, "").replace(
/\/$/,
"",
);
// Get creation time from first object in the profile directory
const objects = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: cp.Prefix,
MaxKeys: 1,
}),
);
const firstObj = objects.Contents?.[0];
profiles.push({
id: profileId,
lastModified: firstObj?.LastModified || new Date(0),
});
}
}
continuationToken = result.NextContinuationToken;
} while (continuationToken);
if (profiles.length <= maxProfiles) {
return { deletedProfiles: [], remaining: profiles.length };
}
// Sort newest first — delete newest excess profiles
profiles.sort(
(a, b) => b.lastModified.getTime() - a.lastModified.getTime(),
);
const excessCount = profiles.length - maxProfiles;
const toDelete = profiles.slice(0, excessCount);
const deletedProfiles: string[] = [];
for (const profile of toDelete) {
const prefix = `${profilePrefix}${profile.id}/`;
// Delete all objects under this profile
let delToken: string | undefined;
do {
const listResult = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: prefix,
MaxKeys: 1000,
ContinuationToken: delToken,
}),
);
const objects = listResult.Contents || [];
if (objects.length > 0) {
const deleteObjects = objects
.filter((obj): obj is typeof obj & { Key: string } => !!obj.Key)
.map((obj) => ({ Key: obj.Key }));
if (deleteObjects.length > 0) {
await this.s3Client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: { Objects: deleteObjects, Quiet: true },
}),
);
}
}
delToken = listResult.NextContinuationToken;
} while (delToken);
// Create tombstone
const tombstoneKey = `${userPrefix}tombstones/profiles/${profile.id}`;
const tombstoneData = JSON.stringify({
prefix: `profiles/${profile.id}/`,
deleted_at: new Date().toISOString(),
reason: "excess_profile_cleanup",
});
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: tombstoneKey,
Body: tombstoneData,
ContentType: "application/json",
}),
);
deletedProfiles.push(profile.id);
this.logger.log(
`Cleaned up excess profile ${profile.id} for user ${userId}`,
);
}
// Report updated profile usage to backend
const remaining = profiles.length - deletedProfiles.length;
await this.reportProfileUsage(userId, remaining).catch((err) =>
this.logger.warn(`Failed to report usage after cleanup: ${err.message}`),
);
return { deletedProfiles, remaining };
}
/**
* Check if the user has reached their profile limit.
* Counts objects in the profiles/ prefix.
@@ -554,16 +690,33 @@ export class SyncService implements OnModuleInit {
private async checkProfileLimit(ctx: UserContext): Promise<void> {
if (ctx.profileLimit <= 0) return; // 0 = unlimited
const profilePrefix = `${ctx.prefix}profiles/`;
const result = await this.s3Client.send(
let count = 0;
const userResult = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: profilePrefix,
MaxKeys: ctx.profileLimit + 1,
Prefix: `${ctx.prefix}profiles/`,
Delimiter: "/",
}),
);
count += userResult.CommonPrefixes?.length || 0;
if (ctx.teamPrefix && ctx.teamProfileLimit && ctx.teamProfileLimit > 0) {
const teamResult = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: `${ctx.teamPrefix}profiles/`,
Delimiter: "/",
}),
);
const teamCount = teamResult.CommonPrefixes?.length || 0;
if (teamCount >= ctx.teamProfileLimit) {
throw new ForbiddenException(
`Team profile limit reached (${ctx.teamProfileLimit}). Ask the team owner to upgrade.`,
);
}
}
const count = result.Contents?.length || 0;
if (count >= ctx.profileLimit) {
throw new ForbiddenException(
`Profile limit reached (${ctx.profileLimit}). Upgrade your plan for more profiles.`,
@@ -572,7 +725,7 @@ export class SyncService implements OnModuleInit {
}
/**
* Count the number of profile objects for a user.
* Count the number of distinct profile directories for a user.
*/
private async countProfiles(ctx: UserContext): Promise<number> {
const profilePrefix = `${ctx.prefix}profiles/`;
@@ -584,11 +737,12 @@ export class SyncService implements OnModuleInit {
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: profilePrefix,
Delimiter: "/",
MaxKeys: 1000,
ContinuationToken: continuationToken,
}),
);
count += result.Contents?.length || 0;
count += result.CommonPrefixes?.length || 0;
continuationToken = result.NextContinuationToken;
} while (continuationToken);
@@ -603,6 +757,35 @@ export class SyncService implements OnModuleInit {
return match ? match[1] : null;
}
private async countTeamProfiles(ctx: UserContext): Promise<number> {
if (!ctx.teamPrefix) return 0;
const profilePrefix = `${ctx.teamPrefix}profiles/`;
let count = 0;
let continuationToken: string | undefined;
do {
const result = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: profilePrefix,
Delimiter: "/",
MaxKeys: 1000,
ContinuationToken: continuationToken,
}),
);
count += result.CommonPrefixes?.length || 0;
continuationToken = result.NextContinuationToken;
} while (continuationToken);
return count;
}
private extractTeamId(ctx: UserContext): string | null {
if (!ctx.teamPrefix) return null;
const match = ctx.teamPrefix.match(/^teams\/([^/]+)\/$/);
return match ? match[1] : null;
}
/**
* Fire-and-forget: count profiles and report to backend.
*/
@@ -613,7 +796,17 @@ export class SyncService implements OnModuleInit {
if (!userId) return;
this.countProfiles(ctx)
.then((count) => this.reportProfileUsage(userId, count))
.then(async (count) => {
await this.reportProfileUsage(userId, count);
if (ctx.teamPrefix) {
const teamCount = await this.countTeamProfiles(ctx);
const teamId = this.extractTeamId(ctx);
if (teamId) {
await this.reportProfileUsage(teamId, teamCount);
}
}
})
.catch((err) =>
this.logger.warn(`Failed to report profile usage: ${err.message}`),
);
@@ -628,7 +821,7 @@ export class SyncService implements OnModuleInit {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-internal-key": this.backendInternalKey!,
"x-internal-key": this.backendInternalKey ?? "undefined",
},
body: JSON.stringify({ userId, count }),
});
+25 -21
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.13.9",
"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",
@@ -44,7 +45,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "~2.9.0",
"@tauri-apps/api": "~2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "~2.4.5",
@@ -56,46 +57,49 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^25.8.7",
"lucide-react": "^0.564.0",
"motion": "^12.34.0",
"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.4.0",
"tailwind-merge": "^3.5.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.15",
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/cli": "~2.9.0",
"@biomejs/biome": "2.4.7",
"@tailwindcss/postcss": "^4.2.1",
"@tauri-apps/cli": "~2.10.1",
"@types/color": "^4.2.0",
"@types/node": "^25.2.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.2.7",
"tailwindcss": "^4.1.18",
"lint-staged": "^16.3.4",
"tailwindcss": "^4.2.1",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.1",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
],
"src-tauri/**/*.rs": [
"cd src-tauri && cargo fmt --all",
"cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all",
"cd src-tauri && cargo test"
"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"
]
}
}
+1774 -1874
View File
File diff suppressed because it is too large Load Diff
+1109 -563
View File
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.13.9"
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"
@@ -40,6 +40,7 @@ tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
@@ -56,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"
@@ -71,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"
@@ -90,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,20 +106,17 @@ quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support
boringtun = "0.7"
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"
muda = "0.17"
tao = "0.34"
single-instance = "0.3"
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
sys-locale = "0.3"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal", "process"] }
@@ -123,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",
+2
View File
@@ -14,6 +14,8 @@
<string>Donut</string>
<key>CFBundleIdentifier</key>
<string>com.donutbrowser</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleURLName</key>
<string>com.donutbrowser</string>
<key>CFBundleExecutable</key>
+2
View File
@@ -34,6 +34,8 @@
"deep-link:allow-get-current",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:allow-write-text-file",
"macos-permissions:default",
"macos-permissions:allow-request-microphone-permission",
"macos-permissions:allow-request-camera-permission",
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

+60 -24
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -13,6 +12,7 @@ pub struct VersionComponent {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub build: u32,
pub pre_release: Option<PreRelease>,
}
@@ -48,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
@@ -67,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
@@ -77,6 +79,7 @@ impl VersionComponent {
major,
minor,
patch,
build,
pre_release,
}
}
@@ -174,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
@@ -182,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) {
@@ -334,7 +347,7 @@ pub struct BrowserRelease {
pub is_prerelease: bool,
}
/// Wayfern version info from https://download.wayfern.com/version.json
/// Wayfern version info from https://donutbrowser.com/wayfern.json
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WayfernVersionInfo {
pub version: String,
@@ -464,13 +477,7 @@ impl ApiClient {
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let app_name = if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
};
let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache");
let cache_dir = crate::app_dirs::cache_dir().join("version_cache");
fs::create_dir_all(&cache_dir)?;
Ok(cache_dir)
}
@@ -1115,7 +1122,7 @@ impl ApiClient {
Ok(())
}
/// Fetch Wayfern version info from https://download.wayfern.com/version.json
/// Fetch Wayfern version info from https://donutbrowser.com/wayfern.json
pub async fn fetch_wayfern_version_with_caching(
&self,
no_caching: bool,
@@ -1128,21 +1135,50 @@ impl ApiClient {
}
}
log::info!("Fetching Wayfern version from https://download.wayfern.com/version.json");
let url = "https://download.wayfern.com/version.json";
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)
+263 -49
View File
@@ -39,6 +39,7 @@ pub struct ApiProfile {
pub group_id: Option<String>,
pub tags: Vec<String>,
pub is_running: bool,
pub proxy_bypass_rules: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -78,6 +79,8 @@ pub struct UpdateProfileRequest {
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>,
}
#[derive(Clone)]
@@ -108,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)]
@@ -122,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)]
@@ -295,30 +304,24 @@ impl ApiServer {
// Create router with OpenAPI documentation
let (v1_routes, _) = OpenApiRouter::new()
.routes(routes!(
get_profiles,
create_profile,
get_profile,
update_profile,
delete_profile,
run_profile,
open_url_in_profile,
kill_profile,
get_groups,
create_group,
get_group,
update_group,
delete_group,
get_tags,
get_proxies,
create_proxy,
get_proxy,
update_proxy,
delete_proxy,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
))
.routes(routes!(get_profiles, create_profile))
.routes(routes!(get_profile, update_profile, delete_profile))
.routes(routes!(run_profile))
.routes(routes!(open_url_in_profile))
.routes(routes!(kill_profile))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_tags))
.routes(routes!(get_proxies, create_proxy))
.routes(routes!(get_proxy, update_proxy, delete_proxy))
.routes(routes!(get_extensions))
.routes(routes!(delete_extension_api))
.routes(routes!(get_extension_groups))
.routes(routes!(delete_extension_group_api))
.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();
@@ -337,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())
@@ -493,6 +496,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
})
.collect();
@@ -547,6 +551,7 @@ async fn get_profile(
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
},
}))
} else {
@@ -601,9 +606,11 @@ async fn create_profile(
&request.version,
request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(),
None, // vpn_id
camoufox_config,
wayfern_config,
request.group_id.clone(),
false,
)
.await
{
@@ -643,6 +650,7 @@ async fn create_profile(
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
proxy_bypass_rules: profile.proxy_bypass_rules,
},
}))
}
@@ -746,6 +754,29 @@ async fn update_profile(
}
}
if let Some(extension_group_id) = request.extension_group_id {
let ext_group = if extension_group_id.is_empty() {
None
} else {
Some(extension_group_id)
};
if profile_manager
.update_profile_extension_group(&id, ext_group)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(proxy_bypass_rules) = request.proxy_bypass_rules {
if profile_manager
.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
// Return updated profile
get_profile(Path(id), State(state)).await
}
@@ -1003,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(),
@@ -1036,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)
@@ -1061,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),
@@ -1099,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),
}
}
@@ -1151,6 +1200,94 @@ async fn delete_proxy(
}
}
// Extension API endpoints
#[utoipa::path(
get,
path = "/v1/extensions",
responses(
(status = 200, description = "List of extensions"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extensions(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::Extension>>, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.list_extensions()
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/v1/extension-groups",
responses(
(status = 200, description = "List of extension groups"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.list_groups()
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/v1/extensions/{id}",
params(("id" = String, Path, description = "Extension ID")),
responses(
(status = 204, description = "Extension deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension not found"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn delete_extension_api(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.delete_extension(&state.app_handle, &id)
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::NOT_FOUND)
}
#[utoipa::path(
delete,
path = "/v1/extension-groups/{id}",
params(("id" = String, Path, description = "Extension Group ID")),
responses(
(status = 204, description = "Extension group deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension group not found"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn delete_extension_group_api(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.delete_group(&state.app_handle, &id)
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::NOT_FOUND)
}
// API Handler - Run Profile with Remote Debugging
#[utoipa::path(
post,
@@ -1161,6 +1298,7 @@ async fn delete_proxy(
request_body = RunProfileRequest,
responses(
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
(status = 400, description = "Cannot launch cross-OS profile"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
@@ -1175,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;
@@ -1188,6 +1333,15 @@ async fn run_profile(
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
if profile.is_cross_os() {
return Err(StatusCode::BAD_REQUEST);
}
// Team lock check
crate::team_lock::acquire_team_lock_if_needed(profile)
.await
.map_err(|_| StatusCode::CONFLICT)?;
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
@@ -1234,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
@@ -1282,6 +1443,8 @@ async fn kill_profile(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
crate::team_lock::release_team_lock_if_needed(profile).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -1377,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 }))
}
+105 -28
View File
@@ -506,7 +506,8 @@ impl AppAutoUpdater {
&& (asset.name.contains(&format!("_{arch}.dmg"))
|| asset.name.contains(&format!("-{arch}.dmg"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
|| asset.name.contains(&format!("-{arch}-"))
|| asset.name.contains(&format!("_{arch}-")))
{
log::info!("Found exact architecture match: {}", asset.name);
return Some(asset.browser_download_url.clone());
@@ -564,7 +565,8 @@ impl AppAutoUpdater {
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|| asset.name.contains(&format!("-{arch}.{ext}"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
|| asset.name.contains(&format!("-{arch}-"))
|| asset.name.contains(&format!("_{arch}-")))
{
log::info!("Found Windows {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
@@ -627,7 +629,8 @@ impl AppAutoUpdater {
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|| asset.name.contains(&format!("-{arch}.{ext}"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
|| asset.name.contains(&format!("-{arch}-"))
|| asset.name.contains(&format!("_{arch}-")))
{
log::info!("Found Linux {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
@@ -741,13 +744,36 @@ impl AppAutoUpdater {
log::info!("Extracting update...");
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
log::info!("Installing update (overwriting binary)...");
self.install_update(&extracted_app_path).await?;
// On Windows, MSI/EXE installers close the running app, so running them now
// would kill the process before the "Update ready" toast can appear. Instead,
// defer execution to restart_application() when the user clicks "Restart Now".
#[cfg(target_os = "windows")]
{
let ext = extracted_app_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext == "msi" || ext == "exe" {
log::info!("Deferring Windows installer execution until user-initiated restart");
*PENDING_INSTALLER_PATH.lock().unwrap() = Some(extracted_app_path);
} else {
log::info!("Installing update (overwriting binary)...");
self.install_update(&extracted_app_path).await?;
log::info!("Cleaning up temporary files...");
let _ = fs::remove_dir_all(&temp_dir);
}
}
log::info!("Cleaning up temporary files...");
let _ = fs::remove_dir_all(&temp_dir);
#[cfg(not(target_os = "windows"))]
{
log::info!("Installing update (overwriting binary)...");
self.install_update(&extracted_app_path).await?;
log::info!("Cleaning up temporary files...");
let _ = fs::remove_dir_all(&temp_dir);
}
log::info!("Update installed successfully, emitting app-update-ready event");
log::info!("Update ready, emitting app-update-ready event");
let _ = events::emit("app-update-ready", update_info.new_version.clone());
@@ -1418,14 +1444,63 @@ rm "{}"
{
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
// Create a temporary restart batch script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.bat");
let update_temp_dir = temp_dir.join("donut_app_update");
// Create the restart script content
let script_content = format!(
r#"@echo off
let script_content = if let Some(installer_path) = pending {
let ext = installer_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let install_cmd = match ext.as_str() {
"msi" => format!(
"msiexec /i \"{}\" /quiet /norestart REBOOT=ReallySuppress",
installer_path.to_str().unwrap()
),
"exe" => format!("\"{}\" /S", installer_path.to_str().unwrap()),
_ => String::new(),
};
format!(
r#"@echo off
rem Wait for the current process to exit
:wait_loop
tasklist /fi "PID eq {pid}" >nul 2>&1
if %errorlevel% equ 0 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
rem Wait a bit more to ensure clean exit
timeout /t 2 /nobreak >nul
rem Run the installer
{install_cmd}
rem Wait for installation to complete
timeout /t 3 /nobreak >nul
rem Start the new application
start "" "{app_path}"
rem Clean up installer temp files
rmdir /s /q "{update_temp}"
rem Clean up this script
del "%~f0"
"#,
pid = current_pid,
install_cmd = install_cmd,
app_path = app_path.to_str().unwrap(),
update_temp = update_temp_dir.to_str().unwrap(),
)
} else {
format!(
r#"@echo off
rem Wait for the current process to exit
:wait_loop
tasklist /fi "PID eq {}" >nul 2>&1
@@ -1443,24 +1518,20 @@ start "" "{}"
rem Clean up this script
del "%~f0"
"#,
current_pid,
app_path.to_str().unwrap()
);
current_pid,
app_path.to_str().unwrap()
)
};
// Write the script to file
fs::write(&script_path, script_content)?;
// Execute the restart script in the background
let mut cmd = Command::new("cmd");
cmd.args(["/C", script_path.to_str().unwrap()]);
// Start the process detached
let _child = cmd.spawn()?;
// Give the script a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Exit the current process
std::process::exit(0);
}
@@ -1531,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()
@@ -1698,15 +1779,10 @@ mod tests {
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
// Windows assets
// Windows assets (NSIS naming: _ARCH-setup.exe)
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
browser_download_url: "https://example.com/x64.msi".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.exe".to_string(),
browser_download_url: "https://example.com/x64.exe".to_string(),
name: "Donut_0.1.0_x64-setup.exe".to_string(),
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
size: 12345,
},
// Linux assets
@@ -1928,4 +2004,5 @@ mod tests {
// Global singleton instance
lazy_static::lazy_static! {
static ref APP_AUTO_UPDATER: AppAutoUpdater = AppAutoUpdater::new();
static ref PENDING_INSTALLER_PATH: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
}
+200
View File
@@ -0,0 +1,200 @@
use directories::BaseDirs;
use std::path::PathBuf;
use std::sync::OnceLock;
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
fn base_dirs() -> &'static BaseDirs {
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
}
pub fn app_name() -> &'static str {
if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
}
}
pub fn data_dir() -> PathBuf {
#[cfg(test)]
{
if let Some(dir) = TEST_DATA_DIR.with(|cell| cell.borrow().clone()) {
return dir;
}
}
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
return PathBuf::from(dir);
}
base_dirs().data_local_dir().join(app_name())
}
pub fn cache_dir() -> PathBuf {
#[cfg(test)]
{
if let Some(dir) = TEST_CACHE_DIR.with(|cell| cell.borrow().clone()) {
return dir;
}
}
if let Ok(dir) = std::env::var("DONUTBROWSER_CACHE_DIR") {
return PathBuf::from(dir);
}
base_dirs().cache_dir().join(app_name())
}
pub fn profiles_dir() -> PathBuf {
data_dir().join("profiles")
}
pub fn binaries_dir() -> PathBuf {
data_dir().join("binaries")
}
pub fn data_subdir() -> PathBuf {
data_dir().join("data")
}
pub fn settings_dir() -> PathBuf {
data_dir().join("settings")
}
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")
}
pub fn extensions_dir() -> PathBuf {
data_dir().join("extensions")
}
#[cfg(test)]
thread_local! {
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
static TEST_CACHE_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub struct TestDirGuard {
kind: TestDirKind,
}
#[cfg(test)]
enum TestDirKind {
Data,
Cache,
}
#[cfg(test)]
impl Drop for TestDirGuard {
fn drop(&mut self) {
match self.kind {
TestDirKind::Data => TEST_DATA_DIR.with(|cell| *cell.borrow_mut() = None),
TestDirKind::Cache => TEST_CACHE_DIR.with(|cell| *cell.borrow_mut() = None),
}
}
}
#[cfg(test)]
pub fn set_test_data_dir(dir: PathBuf) -> TestDirGuard {
TEST_DATA_DIR.with(|cell| *cell.borrow_mut() = Some(dir));
TestDirGuard {
kind: TestDirKind::Data,
}
}
#[cfg(test)]
pub fn set_test_cache_dir(dir: PathBuf) -> TestDirGuard {
TEST_CACHE_DIR.with(|cell| *cell.borrow_mut() = Some(dir));
TestDirGuard {
kind: TestDirKind::Cache,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_name() {
let name = app_name();
assert!(
name == "DonutBrowser" || name == "DonutBrowserDev",
"app_name should be DonutBrowser or DonutBrowserDev, got: {name}"
);
}
#[test]
fn test_data_dir_returns_path() {
let dir = data_dir();
assert!(
dir.to_string_lossy().contains(app_name()),
"data_dir should contain app_name"
);
}
#[test]
fn test_cache_dir_returns_path() {
let dir = cache_dir();
assert!(
dir.to_string_lossy().contains(app_name()),
"cache_dir should contain app_name"
);
}
#[test]
fn test_subdirectory_helpers() {
assert!(profiles_dir().ends_with("profiles"));
assert!(binaries_dir().ends_with("binaries"));
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"));
}
#[test]
fn test_set_test_data_dir() {
let tmp = PathBuf::from("/tmp/test-donut-data");
let _guard = set_test_data_dir(tmp.clone());
assert_eq!(data_dir(), tmp);
assert_eq!(profiles_dir(), tmp.join("profiles"));
assert_eq!(binaries_dir(), tmp.join("binaries"));
}
#[test]
fn test_set_test_cache_dir() {
let tmp = PathBuf::from("/tmp/test-donut-cache");
let _guard = set_test_cache_dir(tmp.clone());
assert_eq!(cache_dir(), tmp);
}
#[test]
fn test_guard_cleanup() {
let original_data = data_dir();
let original_cache = cache_dir();
{
let _guard = set_test_data_dir(PathBuf::from("/tmp/test-cleanup-data"));
assert_eq!(data_dir(), PathBuf::from("/tmp/test-cleanup-data"));
}
assert_eq!(data_dir(), original_data);
{
let _guard = set_test_cache_dir(PathBuf::from("/tmp/test-cleanup-cache"));
assert_eq!(cache_dir(), PathBuf::from("/tmp/test-cleanup-cache"));
}
assert_eq!(cache_dir(), original_cache);
}
}
+267 -96
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};
@@ -61,6 +60,10 @@ impl AutoUpdater {
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
for profile in profiles {
if profile.is_cross_os() {
continue;
}
// Only check supported browsers
if !self
.browser_version_manager
@@ -77,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());
@@ -102,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);
}
}
}
@@ -132,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}");
}
}
}
@@ -217,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
@@ -313,9 +310,42 @@ impl AutoUpdater {
// Find all profiles for this browser that should be updated
for profile in profiles {
if profile.browser == browser {
if profile.is_cross_os() {
continue;
}
// 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)
@@ -362,15 +392,6 @@ impl AutoUpdater {
Ok(updated_profiles)
}
/// Check if browser is disabled due to ongoing update
pub fn is_browser_disabled(
&self,
browser: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let state = self.load_auto_update_state()?;
Ok(state.disabled_browsers.contains(browser))
}
/// Dismiss update notification
pub fn dismiss_update_notification(
&self,
@@ -457,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
@@ -511,6 +674,7 @@ mod tests {
version: version.to_string(),
process_id: None,
proxy_id: None,
vpn_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
@@ -518,8 +682,15 @@ mod tests {
group_id: None,
tags: Vec::new(),
note: None,
sync_enabled: false,
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,
}
}
+113 -28
View File
@@ -16,12 +16,32 @@ use serde::{Deserialize, Serialize};
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
use tray_icon::{MouseButton, TrayIcon, TrayIconEvent};
use tray_icon::TrayIcon;
#[cfg(not(target_os = "macos"))]
use tray_icon::{MouseButton, TrayIconEvent};
use donutbrowser_lib::daemon::{autostart, services, tray};
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
enum ServiceStatus {
Ready {
api_port: Option<u16>,
@@ -173,6 +193,41 @@ fn run_daemon() {
// Store tray icon in Option - created after event loop starts
let mut tray_icon: Option<TrayIcon> = None;
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
#[cfg(unix)]
unsafe {
extern "C" fn signal_handler(_sig: libc::c_int) {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
}
libc::signal(
libc::SIGTERM,
signal_handler as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGINT,
signal_handler as *const () as libc::sighandler_t,
);
}
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
1 // TRUE
}
unsafe {
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
}
}
// Run the event loop
event_loop.run(move |event, _, control_flow| {
// Use WaitUntil to check for menu events periodically while staying low on CPU
@@ -222,15 +277,15 @@ fn run_daemon() {
// Process menu events
while let Ok(event) = menu_channel.try_recv() {
if event.id == tray_menu.open_item.id() {
tray::open_gui();
} else if event.id == tray_menu.quit_item.id() {
if event.id == tray_menu.quit_item.id() {
log::info!("[daemon] Quit requested");
SHOULD_QUIT.store(true, Ordering::SeqCst);
}
}
// Handle tray icon click (left-click opens the app)
// On macOS, left-click already shows the menu, so don't also launch the GUI.
#[cfg(not(target_os = "macos"))]
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
if let TrayIconEvent::Click {
button: MouseButton::Left,
@@ -243,12 +298,37 @@ fn run_daemon() {
// Use swap to only run cleanup once
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
// Cleanup
// Remove tray icon from status bar immediately so the UI feels responsive
tray_icon = None;
tray::quit_gui();
let mut state = read_state();
state.daemon_pid = None;
let _ = write_state(&state);
log::info!("[daemon] Exiting");
*control_flow = ControlFlow::Exit;
// Use process::exit for immediate termination instead of ControlFlow::Exit.
// ControlFlow::Exit can delay because tao's macOS event loop defers exit,
// and dropping the tokio runtime blocks until all spawned tasks finish.
process::exit(0);
}
}
Event::Reopen { .. } => {
tray::open_gui();
// Re-hide daemon from Dock. macOS activates the daemon (making it
// visible) when the user clicks the Dock icon, overriding the
// Accessory policy set at init.
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
}
_ => {}
@@ -266,6 +346,32 @@ fn stop_daemon() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let state_path = get_state_path();
if let Ok(content) = fs::read_to_string(&state_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
let _ = Command::new("taskkill")
.args(["/PID", &gui_pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
}
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
#[cfg(unix)]
{
unsafe {
@@ -273,15 +379,6 @@ fn stop_daemon() {
}
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
#[cfg(windows)]
{
use std::process::Command;
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output();
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
} else {
eprintln!("Daemon is not running");
}
@@ -295,15 +392,7 @@ fn show_status() {
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
#[cfg(windows)]
let is_running = {
use std::process::Command;
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid)])
.output();
output
.map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
.unwrap_or(false)
};
let is_running = win_process_exists(pid);
#[cfg(not(any(unix, windows)))]
let is_running = false;
@@ -357,10 +446,6 @@ fn main() {
match args[1].as_str() {
"start" => {
// "start" is now an alias for "run"
// On macOS, the daemon should be started via launchctl (see daemon_spawn.rs)
// This command is kept for backward compatibility
eprintln!("Starting daemon...");
run_daemon();
}
"stop" => {
+129 -1
View File
@@ -147,6 +147,11 @@ async fn main() {
Arg::new("profile-id")
.long("profile-id")
.help("ID of the profile this proxy is associated with"),
)
.arg(
Arg::new("bypass-rules")
.long("bypass-rules")
.help("JSON array of bypass rules (hostnames, IPs, or regex patterns)"),
),
)
.subcommand(
@@ -172,6 +177,24 @@ async fn main() {
)
.arg(Arg::new("action").required(true).help("Action (start)")),
)
.subcommand(
Command::new("vpn-worker")
.about("Run a VPN worker process (internal use)")
.arg(
Arg::new("id")
.long("id")
.required(true)
.help("VPN worker configuration ID"),
)
.arg(
Arg::new("port")
.long("port")
.value_parser(clap::value_parser!(u16))
.required(true)
.help("Local SOCKS5 port"),
)
.arg(Arg::new("action").required(true).help("Action (start)")),
)
.get_matches();
if let Some(proxy_matches) = matches.subcommand_matches("proxy") {
@@ -199,8 +222,12 @@ async fn main() {
let port = start_matches.get_one::<u16>("port").copied();
let profile_id = start_matches.get_one::<String>("profile-id").cloned();
let bypass_rules: Vec<String> = start_matches
.get_one::<String>("bypass-rules")
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
match start_proxy_process_with_profile(upstream_url, port, profile_id).await {
match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await {
Ok(config) => {
// Output the configuration as JSON for the Rust side to parse
// Use println! here because this needs to go to stdout for parsing
@@ -333,6 +360,107 @@ async fn main() {
log::error!("Invalid action for proxy-worker. Use 'start'");
process::exit(1);
}
} else if let Some(vpn_matches) = matches.subcommand_matches("vpn-worker") {
let id = vpn_matches.get_one::<String>("id").expect("id is required");
let action = vpn_matches
.get_one::<String>("action")
.expect("action is required");
let port = *vpn_matches
.get_one::<u16>("port")
.expect("port is required");
if action == "start" {
set_high_priority();
log::info!("VPN worker starting, config id: {}", id);
log::info!("Process PID: {}", std::process::id());
// Retry config loading to handle file system race condition
let config = {
let mut attempts = 0;
loop {
if let Some(config) = donutbrowser_lib::vpn_worker_storage::get_vpn_worker_config(id) {
log::info!(
"Found VPN worker config: id={}, vpn_type={}, vpn_id={}",
config.id,
config.vpn_type,
config.vpn_id
);
break config;
}
attempts += 1;
if attempts >= 10 {
log::error!(
"VPN worker configuration {} not found after {} attempts",
id,
attempts
);
process::exit(1);
}
log::info!(
"VPN worker config {} not found yet, retrying ({}/10)...",
id,
attempts
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
};
// Read the decrypted VPN config from the temp file
let vpn_config_data = match std::fs::read_to_string(&config.config_file_path) {
Ok(data) => data,
Err(e) => {
log::error!(
"Failed to read VPN config file {}: {}",
config.config_file_path,
e
);
process::exit(1);
}
};
match config.vpn_type.as_str() {
"wireguard" => {
let wg_config = match donutbrowser_lib::vpn::parse_wireguard_config(&vpn_config_data) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to parse WireGuard config: {}", e);
process::exit(1);
}
};
let server =
donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port);
if let Err(e) = server.run(id.clone()).await {
log::error!("VPN worker failed: {}", e);
process::exit(1);
}
}
"openvpn" => {
let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to parse OpenVPN config: {}", e);
process::exit(1);
}
};
let server =
donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port);
if let Err(e) = server.run(id.clone()).await {
log::error!("VPN worker failed: {}", e);
process::exit(1);
}
}
other => {
log::error!("Unknown VPN type: {}", other);
process::exit(1);
}
}
} else {
log::error!("Invalid action for vpn-worker. Use 'start'");
process::exit(1);
}
} else {
log::error!("No command specified");
process::exit(1);
+95 -556
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"),
@@ -612,24 +456,20 @@ mod windows {
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
if path.extension().is_some_and(|ext| ext == "exe") && is_pe_executable(&path) {
let name = path
.file_stem()
.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"),
@@ -716,17 +534,13 @@ mod windows {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
if path.extension().is_some_and(|ext| ext == "exe") && is_pe_executable(&path) {
let name = path
.file_stem()
.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,16 +766,28 @@ 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()),
}
}
}
/// Check if a file is a valid PE executable by reading its magic bytes (MZ).
/// Returns false for archive files (.zip starts with PK, etc.) that were
/// incorrectly named with a .exe extension.
#[cfg(target_os = "windows")]
fn is_pe_executable(path: &Path) -> bool {
use std::io::Read;
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut magic = [0u8; 2];
if file.read_exact(&mut magic).is_err() {
return false;
}
magic == [0x4D, 0x5A] // MZ
}
// Factory function to create browser instances (kept for backward compatibility)
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
BrowserFactory::instance().create_browser(browser_type)
@@ -1256,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
@@ -1304,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(
@@ -1332,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"
@@ -1373,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
@@ -1407,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"
@@ -1422,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"
);
}
@@ -1475,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()
@@ -1507,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"
);
}
@@ -1577,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]
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -685,7 +685,7 @@ impl BrowserVersionManager {
"macos-arm64" | "macos-x64" => (format!("wayfern-{version}-{platform_key}.dmg"), true),
"linux-x64" | "linux-arm64" => (format!("wayfern-{version}-{platform_key}.tar.xz"), true),
"windows-x64" | "windows-arm64" => {
(format!("wayfern-{version}-{platform_key}.exe"), false)
(format!("wayfern-{version}-{platform_key}.zip"), true)
}
_ => {
return Err(
+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;
+37 -15
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;
@@ -267,6 +267,7 @@ impl Default for LocaleSelector {
}
/// Normalize a locale string to standard format.
/// Handles formats like "en-US", "zh-Hant-US", "zh-Hans-CN".
fn normalize_locale(locale: &str) -> Locale {
let parts: Vec<&str> = locale.split('-').collect();
@@ -275,23 +276,33 @@ fn normalize_locale(locale: &str) -> Locale {
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "en".to_string());
let region = parts.get(1).map(|s| s.to_uppercase());
// A 4-letter part is a script subtag (e.g. "Hant", "Hans", "Cyrl").
// A 2-letter or 3-digit part is a region subtag (e.g. "US", "CN").
let mut explicit_script: Option<String> = None;
let mut region: Option<String> = None;
// Determine script based on language if needed
let script = match language.as_str() {
"zh" => {
// Chinese - Traditional for TW/HK, Simplified otherwise
if region.as_deref() == Some("TW") || region.as_deref() == Some("HK") {
Some("Hant".to_string())
} else {
Some("Hans".to_string())
for part in parts.iter().skip(1) {
if part.len() == 4 && part.chars().all(|c| c.is_ascii_alphabetic()) {
explicit_script = Some(part[..1].to_uppercase() + &part[1..].to_lowercase());
} else {
region = Some(part.to_uppercase());
}
}
let script = if explicit_script.is_some() {
explicit_script
} else {
match language.as_str() {
"zh" => {
if region.as_deref() == Some("TW") || region.as_deref() == Some("HK") {
Some("Hant".to_string())
} else {
Some("Hans".to_string())
}
}
"sr" => Some("Cyrl".to_string()),
_ => None,
}
"sr" => {
// Serbian - can be Cyrillic or Latin
Some("Cyrl".to_string())
}
_ => None,
};
Locale {
@@ -442,5 +453,16 @@ mod tests {
let zh_cn = normalize_locale("zh-CN");
assert_eq!(zh_cn.script, Some("Hans".to_string()));
// 3-part locale: language-script-region
let zh_hant_us = normalize_locale("zh-Hant-US");
assert_eq!(zh_hant_us.language, "zh");
assert_eq!(zh_hant_us.region, Some("US".to_string()));
assert_eq!(zh_hant_us.script, Some("Hant".to_string()));
let zh_hans_us = normalize_locale("zh-Hans-US");
assert_eq!(zh_hans_us.language, "zh");
assert_eq!(zh_hans_us.region, Some("US".to_string()));
assert_eq!(zh_hans_us.script, Some("Hans".to_string()));
}
}
+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;
+116 -22
View File
@@ -1,7 +1,6 @@
use crate::browser_runner::BrowserRunner;
use crate::camoufox::{CamoufoxConfigBuilder, GeoIPOption, ScreenConstraints};
use crate::profile::BrowserProfile;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
@@ -57,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)]
@@ -66,6 +66,7 @@ struct CamoufoxInstance {
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
}
struct CamoufoxManagerInner {
@@ -74,7 +75,6 @@ struct CamoufoxManagerInner {
pub struct CamoufoxManager {
inner: Arc<AsyncMutex<CamoufoxManagerInner>>,
base_dirs: BaseDirs,
}
impl CamoufoxManager {
@@ -83,7 +83,6 @@ impl CamoufoxManager {
inner: Arc::new(AsyncMutex::new(CamoufoxManagerInner {
instances: HashMap::new(),
})),
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
}
}
@@ -91,15 +90,35 @@ 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 {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("profiles");
path
crate::app_dirs::profiles_dir()
}
/// Generate Camoufox fingerprint configuration during profile creation
@@ -111,7 +130,15 @@ impl CamoufoxManager {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Get executable path
let executable_path = if let Some(path) = &config.executable_path {
PathBuf::from(path)
let p = PathBuf::from(path);
if p.exists() {
p
} else {
log::warn!("Stored Camoufox executable path does not exist: {path}, falling back to dynamic resolution");
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
}
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
@@ -204,7 +231,15 @@ impl CamoufoxManager {
// Get executable path
let executable_path = if let Some(path) = &config.executable_path {
PathBuf::from(path)
let p = PathBuf::from(path);
if p.exists() {
p
} else {
log::warn!("Stored Camoufox executable path does not exist: {path}, falling back to dynamic resolution");
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
}
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
@@ -233,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());
@@ -288,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 {
@@ -295,6 +334,7 @@ impl CamoufoxManager {
processId: process_id,
profilePath: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
{
@@ -360,8 +400,11 @@ impl CamoufoxManager {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let result = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T"])
.creation_flags(CREATE_NO_WINDOW)
.status();
match result {
@@ -409,6 +452,7 @@ impl CamoufoxManager {
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
}));
}
}
@@ -419,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
@@ -435,6 +481,7 @@ impl CamoufoxManager {
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
},
);
@@ -443,6 +490,7 @@ impl CamoufoxManager {
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
cdp_port,
}));
}
@@ -453,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(
@@ -478,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() {
@@ -489,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));
}
}
}
@@ -548,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();
@@ -576,10 +642,15 @@ impl CamoufoxManager {
profile: BrowserProfile,
config: CamoufoxConfig,
url: Option<String>,
override_profile_path: Option<std::path::PathBuf>,
) -> Result<CamoufoxLaunchResult, String> {
// Get profile path
let profiles_dir = self.get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
let profile_path = if let Some(ref override_path) = override_profile_path {
override_path.clone()
} else {
let profiles_dir = self.get_profiles_dir();
profile.get_profile_data_path(&profiles_dir)
};
let profile_path_str = profile_path.to_string_lossy();
// Check if there's already a running instance for this profile
@@ -591,6 +662,29 @@ impl CamoufoxManager {
// Clean up any dead instances before launching
let _ = self.cleanup_dead_instances().await;
// For ephemeral profiles, write Firefox prefs to minimize disk writes
if override_profile_path.is_some() {
let user_js_path = profile_path.join("user.js");
let prefs = concat!(
"user_pref(\"browser.cache.disk.enable\", false);\n",
"user_pref(\"browser.cache.memory.enable\", true);\n",
"user_pref(\"browser.sessionstore.resume_from_crash\", false);\n",
"user_pref(\"browser.sessionstore.max_tabs_undo\", 0);\n",
"user_pref(\"browser.sessionstore.max_windows_undo\", 0);\n",
"user_pref(\"places.history.enabled\", false);\n",
"user_pref(\"browser.formfill.enable\", false);\n",
"user_pref(\"signon.rememberSignons\", false);\n",
"user_pref(\"browser.bookmarks.max_backups\", 0);\n",
"user_pref(\"browser.shell.checkDefaultBrowser\", false);\n",
"user_pref(\"toolkit.crashreporter.enabled\", false);\n",
"user_pref(\"browser.pagethumbnails.capturing_disabled\", true);\n",
"user_pref(\"browser.download.manager.addToRecentDocs\", false);\n",
);
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write ephemeral user.js: {e}");
}
}
self
.launch_camoufox(
&app_handle,
+620 -43
View File
@@ -26,7 +26,7 @@ pub struct CloudUser {
pub email: String,
pub plan: String,
#[serde(rename = "planPeriod")]
pub plan_period: String,
pub plan_period: Option<String>,
#[serde(rename = "subscriptionStatus")]
pub subscription_status: String,
#[serde(rename = "profileLimit")]
@@ -37,6 +37,14 @@ pub struct CloudUser {
pub proxy_bandwidth_limit_mb: i64,
#[serde(rename = "proxyBandwidthUsedMb")]
pub proxy_bandwidth_used_mb: i64,
#[serde(rename = "proxyBandwidthExtraMb", default)]
pub proxy_bandwidth_extra_mb: i64,
#[serde(rename = "teamId", default)]
pub team_id: Option<String>,
#[serde(rename = "teamName", default)]
pub team_name: Option<String>,
#[serde(rename = "teamRole", default)]
pub team_role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -73,6 +81,20 @@ 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,
pub name: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct CloudProxyConfigResponse {
@@ -90,6 +112,8 @@ struct CloudProxyConfigResponse {
pub struct CloudAuthManager {
client: Client,
state: Mutex<Option<CloudAuthState>>,
refresh_lock: tokio::sync::Mutex<()>,
wayfern_token: Mutex<Option<String>>,
}
lazy_static! {
@@ -102,6 +126,8 @@ impl CloudAuthManager {
Self {
client: Client::new(),
state: Mutex::new(state),
refresh_lock: tokio::sync::Mutex::new(()),
wayfern_token: Mutex::new(None),
}
}
@@ -239,7 +265,7 @@ impl CloudAuthManager {
Self::encrypt_and_store(&path, b"DBCAT", token)
}
fn load_access_token() -> Result<Option<String>, String> {
pub(crate) fn load_access_token() -> Result<Option<String>, String> {
let path = Self::get_settings_dir().join("cloud_access_token.dat");
Self::decrypt_from_file(&path, b"DBCAT")
}
@@ -382,9 +408,37 @@ impl CloudAuthManager {
.map_err(|e| format!("Failed to parse response: {e}"))?;
// Store tokens
log::info!(
"Storing access token (len={}) and refresh token (len={})",
result.access_token.len(),
result.refresh_token.len()
);
Self::store_access_token(&result.access_token)?;
Self::store_refresh_token(&result.refresh_token)?;
// Verify tokens survived the encrypt/decrypt round-trip
match Self::load_access_token() {
Ok(Some(loaded)) if loaded == result.access_token => {
log::info!(
"Access token verified after store/load (len={})",
loaded.len()
);
}
Ok(Some(loaded)) => {
log::error!(
"Access token CORRUPTED during store/load: original_len={}, loaded_len={}",
result.access_token.len(),
loaded.len()
);
}
Ok(None) => {
log::error!("Access token missing immediately after store");
}
Err(e) => {
log::error!("Failed to load access token for verification: {e}");
}
}
// Build and persist auth state
let auth_state = CloudAuthState {
user: result.user,
@@ -392,6 +446,13 @@ impl CloudAuthManager {
};
Self::store_auth_state(&auth_state)?;
log::info!(
"Login successful: plan={}, subscription_status={}, proxy_bandwidth_limit={}MB",
auth_state.user.plan,
auth_state.user.subscription_status,
auth_state.user.proxy_bandwidth_limit_mb
);
// Update in-memory state
let mut state = self.state.lock().await;
*state = Some(auth_state.clone());
@@ -400,6 +461,9 @@ impl CloudAuthManager {
}
pub async fn refresh_access_token(&self) -> Result<(), String> {
let _guard = self.refresh_lock.lock().await;
log::info!("Refreshing access token (holding lock)...");
let refresh_token =
Self::load_refresh_token()?.ok_or_else(|| "No refresh token stored".to_string())?;
@@ -414,14 +478,8 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
// Refresh token expired — clear everything
PROXY_MANAGER.remove_cloud_proxy();
self.clear_auth().await;
let _ = crate::events::emit_empty("cloud-auth-expired");
return Err("Session expired. Please log in again.".to_string());
}
let body = response.text().await.unwrap_or_default();
log::warn!("Token refresh failed ({status}): {body}");
return Err(format!("Token refresh failed ({status}): {body}"));
}
@@ -433,9 +491,20 @@ impl CloudAuthManager {
Self::store_access_token(&result.access_token)?;
Self::store_refresh_token(&result.refresh_token)?;
log::info!("Access token refreshed successfully");
Ok(())
}
/// Invalidate the session: clear all auth state and notify the frontend.
/// Only call this when the session is definitively dead (explicit logout
/// or repeated background refresh failures).
pub async fn invalidate_session(&self) {
log::warn!("Invalidating session — clearing all auth state");
PROXY_MANAGER.remove_cloud_proxy();
self.clear_auth().await;
let _ = crate::events::emit_empty("cloud-auth-expired");
}
pub async fn fetch_profile(&self) -> Result<CloudUser, String> {
let user = self
.api_call_with_retry(|access_token| {
@@ -519,6 +588,12 @@ 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;
// Try to call the logout API (best-effort)
if let Ok(Some(access_token)) = Self::load_access_token() {
let refresh_token = Self::load_refresh_token().ok().flatten();
@@ -551,11 +626,46 @@ impl CloudAuthManager {
pub async fn has_active_paid_subscription(&self) -> bool {
let state = self.state.lock().await;
match &*state {
Some(auth) => auth.user.plan != "free" && auth.user.subscription_status == "active",
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
}
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() {
Ok(state) => match &*state {
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
},
Err(_) => false,
}
}
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
let host_os = crate::profile::types::get_host_os();
match fingerprint_os {
None => true,
Some(os) if os == host_os => true,
Some(_) => self.has_active_paid_subscription().await,
}
}
pub async fn is_on_team_plan(&self) -> bool {
if let Some(state) = self.get_user().await {
return state.user.team_id.is_some();
}
false
}
pub async fn get_user(&self) -> Option<CloudAuthState> {
let state = self.state.lock().await;
state.clone()
@@ -568,20 +678,30 @@ impl CloudAuthManager {
}
/// API call with 401 retry: if first attempt gets 401, refresh access token and retry once.
async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
/// Uses refresh_lock to prevent concurrent token rotations from racing.
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,
{
let access_token = Self::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
match make_request(access_token).await {
match make_request(access_token.clone()).await {
Ok(result) => Ok(result),
Err(e) if e.contains("(401)") => {
// Try refreshing the access token
Err(e) if e.contains("(401") || e.contains("Unauthorized") => {
log::info!("Got 401/Unauthorized response, attempting token refresh...");
// Check if another caller already refreshed while we waited
let current_token = Self::load_access_token()?.unwrap_or_default();
if current_token != access_token && !current_token.is_empty() {
log::info!("Token was already refreshed by another caller, retrying...");
return make_request(current_token).await;
}
self.refresh_access_token().await?;
let new_token =
Self::load_access_token()?.ok_or_else(|| "Not logged in after refresh".to_string())?;
log::info!("Token refreshed, retrying request...");
make_request(new_token).await
}
Err(e) => Err(e),
@@ -590,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),
}
}
@@ -613,6 +734,8 @@ impl CloudAuthManager {
let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN {
let body = response.text().await.unwrap_or_default();
log::warn!("Proxy config returned 403: {body}");
return Err("__403__".to_string());
}
@@ -640,8 +763,15 @@ impl CloudAuthManager {
/// Sync the cloud-managed proxy: fetch config and upsert or remove
pub async fn sync_cloud_proxy(&self) {
log::info!("Syncing cloud proxy configuration...");
match self.fetch_proxy_config().await {
Ok(Some(config)) => {
log::info!(
"Cloud proxy config received: host={}, port={}, protocol={}",
config.host,
config.port,
config.protocol
);
let settings = ProxySettings {
proxy_type: config.protocol,
host: config.host,
@@ -650,21 +780,256 @@ impl CloudAuthManager {
password: config.password,
};
match PROXY_MANAGER.upsert_cloud_proxy(settings) {
Ok(_) => log::debug!("Cloud proxy synced successfully"),
Ok(_) => {
log::info!("Cloud proxy synced successfully");
// Propagate credential changes to derived location proxies
PROXY_MANAGER.update_cloud_derived_proxies();
}
Err(e) => log::warn!("Failed to upsert cloud proxy: {e}"),
}
}
Ok(None) => {
log::info!("No cloud proxy config available (user may not have proxy bandwidth)");
PROXY_MANAGER.remove_cloud_proxy();
}
Err(e) => {
log::warn!("Failed to sync cloud proxy: {e}");
log::error!("Failed to sync cloud proxy: {e}");
}
}
}
/// Report the number of sync-enabled profiles to the cloud backend
pub async fn report_sync_profile_count(&self, count: i64) -> Result<(), String> {
self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/sync-profile-usage");
let client = reqwest::Client::new();
async move {
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.json(&serde_json::json!({ "count": count }))
.send()
.await
.map_err(|e| format!("Failed to report profile usage: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Profile usage report failed ({status}): {body}"));
}
Ok(())
}
})
.await
}
/// Fetch country list from the cloud backend
pub async fn fetch_countries(&self) -> Result<Vec<LocationItem>, String> {
self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/proxy/locations/countries");
let client = self.client.clone();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch countries: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Countries fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse countries: {e}"))
}
})
.await
}
/// 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/regions?country={}",
country
);
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 regions: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Regions fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse regions: {e}"))
}
})
.await
}
/// Fetch city list for a country, optionally filtered by region
pub async fn fetch_cities(
&self,
country: &str,
region: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
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
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch cities: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Cities fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse cities: {e}"))
}
})
.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
@@ -672,6 +1037,24 @@ 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() {
if Self::is_jwt_expiring_soon(&token) {
if let Err(e) = CLOUD_AUTH.refresh_access_token().await {
log::warn!("Failed to refresh cloud access token: {e}");
// If the refresh token itself was rejected, session is irrecoverable
if e.contains("(401") || e.contains("Unauthorized") {
log::warn!("Refresh token rejected — invalidating session");
CLOUD_AUTH.invalidate_session().await;
continue;
}
}
}
}
match CLOUD_AUTH.get_or_refresh_sync_token().await {
Ok(Some(_)) => {
log::debug!("Cloud sync token refreshed successfully");
@@ -682,23 +1065,33 @@ impl CloudAuthManager {
}
}
// Also refresh the access token if needed
if let Ok(Some(token)) = Self::load_access_token() {
if Self::is_jwt_expiring_soon(&token) {
if let Err(e) = CLOUD_AUTH.refresh_access_token().await {
log::warn!("Failed to refresh cloud access token: {e}");
}
}
}
// Refresh profile data periodically
if let Err(e) = CLOUD_AUTH.fetch_profile().await {
log::debug!("Failed to refresh cloud profile: {e}");
}
// Reconnect team lock manager if needed
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
if let Some(tid) = &auth_state.user.team_id {
crate::team_lock::TEAM_LOCK.connect(tid).await;
}
}
// 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
}
}
@@ -719,16 +1112,40 @@ pub async fn cloud_verify_otp(
) -> Result<CloudAuthState, String> {
let state = CLOUD_AUTH.verify_otp(&email, &code).await?;
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
log::info!(
"Post-login: plan={}, has_active_subscription={}",
state.user.plan,
has_subscription
);
// Pre-fetch sync token so sync can start immediately
if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Failed to pre-fetch sync token after login: {e}");
if has_subscription {
log::info!("Pre-fetching sync token...");
match CLOUD_AUTH.get_or_refresh_sync_token().await {
Ok(Some(_)) => log::info!("Sync token pre-fetched successfully"),
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
CLOUD_AUTH.sync_cloud_proxy().await;
// Connect team lock manager if on a team plan
if state.user.team_id.is_some() {
if let Some(tid) = &state.user.team_id {
crate::team_lock::TEAM_LOCK.connect(tid).await;
}
}
let _ = crate::events::emit_empty("cloud-auth-changed");
let _ = &app_handle;
Ok(state)
}
@@ -746,7 +1163,20 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
#[tauri::command]
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?;
let _ = &app_handle;
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
let manager = crate::settings_manager::SettingsManager::instance();
if let Ok(sync_settings) = manager.get_sync_settings() {
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
let _ = manager.save_sync_server_url(None);
}
}
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(())
}
@@ -755,27 +1185,168 @@ 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_regions(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_regions(&country).await
}
#[tauri::command]
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,
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, region, city, isp)
}
#[derive(Debug, Serialize)]
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),
}
}
@@ -811,9 +1382,15 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
{
log::warn!("Failed to check for missing profiles: {}", e);
}
if let Err(e) = engine
.check_for_missing_synced_entities(&app_handle_sync)
.await
{
log::warn!("Failed to check for missing entities: {}", e);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e);
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
+689 -24
View File
@@ -2,10 +2,117 @@ use crate::profile::manager::ProfileManager;
use crate::profile::BrowserProfile;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
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 {
@@ -62,12 +169,26 @@ pub struct CookieCopyResult {
pub errors: Vec<String>,
}
/// Result of a cookie import operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieImportResult {
pub cookies_imported: usize,
pub cookies_replaced: usize,
pub errors: Vec<String>,
}
pub struct CookieManager;
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);
@@ -146,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}"))?
@@ -247,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}"))?;
@@ -263,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",
@@ -274,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,
@@ -299,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,
@@ -339,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)),
};
@@ -392,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)),
};
@@ -459,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(),
@@ -493,4 +660,502 @@ impl CookieManager {
Ok(results)
}
/// Parse Netscape format cookies from text content
fn parse_netscape_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
let mut cookies = Vec::new();
let mut errors = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
for (i, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() < 7 {
errors.push(format!(
"Line {}: expected 7 tab-separated fields, got {}",
i + 1,
fields.len()
));
continue;
}
let domain = fields[0].to_string();
let path = fields[2].to_string();
let is_secure = fields[3].eq_ignore_ascii_case("TRUE");
let expires = fields[4].parse::<i64>().unwrap_or(0);
let name = fields[5].to_string();
let value = fields[6].to_string();
cookies.push(UnifiedCookie {
name,
value,
domain,
path,
expires,
is_secure,
is_http_only: false,
same_site: 0,
creation_time: now,
last_accessed: now,
});
}
(cookies, errors)
}
/// Parse JSON format cookies (array of cookie objects, e.g. from browser extensions)
fn parse_json_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
let mut cookies = Vec::new();
let mut errors = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let arr: Vec<Value> = match serde_json::from_str(content) {
Ok(v) => v,
Err(e) => {
errors.push(format!("Failed to parse JSON: {e}"));
return (cookies, errors);
}
};
for (i, obj) in arr.iter().enumerate() {
let name = match obj.get("name").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
errors.push(format!("Cookie {}: missing 'name' field", i + 1));
continue;
}
};
let value = obj
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let domain = match obj.get("domain").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
errors.push(format!("Cookie {}: missing 'domain' field", i + 1));
continue;
}
};
let path = obj
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("/")
.to_string();
let is_secure = obj.get("secure").and_then(|v| v.as_bool()).unwrap_or(false);
let is_http_only = obj
.get("httpOnly")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_session = obj
.get("session")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let expires = if is_session {
0
} else {
obj
.get("expirationDate")
.and_then(|v| v.as_f64())
.map(|f| f as i64)
.unwrap_or(0)
};
let same_site = obj
.get("sameSite")
.and_then(|v| v.as_str())
.map(|s| match s {
"lax" => 1,
"strict" => 2,
_ => 0, // "no_restriction" or unrecognized
})
.unwrap_or(0);
cookies.push(UnifiedCookie {
name,
value,
domain,
path,
expires,
is_secure,
is_http_only,
same_site,
creation_time: now,
last_accessed: now,
});
}
(cookies, errors)
}
/// Auto-detect cookie format and parse
fn parse_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
let trimmed = content.trim();
if trimmed.starts_with('[') && serde_json::from_str::<Vec<Value>>(trimmed).is_ok() {
return Self::parse_json_cookies(trimmed);
}
Self::parse_netscape_cookies(content)
}
/// Format cookies as Netscape TXT
pub fn format_netscape_cookies(cookies: &[UnifiedCookie]) -> String {
let mut lines = Vec::new();
lines.push("# Netscape HTTP Cookie File".to_string());
for cookie in cookies {
let flag = if cookie.domain.starts_with('.') {
"TRUE"
} else {
"FALSE"
};
let secure = if cookie.is_secure { "TRUE" } else { "FALSE" };
lines.push(format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
cookie.domain, flag, cookie.path, secure, cookie.expires, cookie.name, cookie.value
));
}
lines.join("\n")
}
/// Format cookies as JSON
pub fn format_json_cookies(cookies: &[UnifiedCookie]) -> String {
let arr: Vec<Value> = cookies
.iter()
.map(|c| {
let same_site_str = match c.same_site {
1 => "lax",
2 => "strict",
_ => "no_restriction",
};
serde_json::json!({
"name": c.name,
"value": c.value,
"domain": c.domain,
"path": c.path,
"secure": c.is_secure,
"httpOnly": c.is_http_only,
"sameSite": same_site_str,
"expirationDate": c.expires,
"session": c.expires == 0,
"hostOnly": !c.domain.starts_with('.'),
})
})
.collect();
serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string())
}
/// Public API: Import cookies with auto-format detection
pub async fn import_cookies(
app_handle: &AppHandle,
profile_id: &str,
content: &str,
) -> Result<CookieImportResult, String> {
let profile_manager = ProfileManager::instance();
let profiles_dir = profile_manager.get_profiles_dir();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile not found: {profile_id}"))?;
let is_running = profile_manager
.check_browser_status(app_handle.clone(), profile)
.await
.unwrap_or(false);
if is_running {
return Err(format!(
"Cannot import cookies while browser is running for profile: {}",
profile.name
));
}
let (cookies, parse_errors) = Self::parse_cookies(content);
if cookies.is_empty() {
return Err("No valid cookies found in the file".to_string());
}
let db_path = Self::get_cookie_db_path(profile, &profiles_dir)?;
let write_result = match profile.browser.as_str() {
"camoufox" => Self::write_firefox_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)),
};
match write_result {
Ok((imported, replaced)) => Ok(CookieImportResult {
cookies_imported: imported,
cookies_replaced: replaced,
errors: parse_errors,
}),
Err(e) => Err(format!("Failed to write cookies: {e}")),
}
}
/// Public API: Export cookies from a profile in the specified format
pub fn export_cookies(profile_id: &str, format: &str) -> Result<String, String> {
let result = Self::read_cookies(profile_id)?;
let all_cookies: Vec<UnifiedCookie> =
result.domains.into_iter().flat_map(|d| d.cookies).collect();
match format {
"json" => Ok(Self::format_json_cookies(&all_cookies)),
"netscape" => Ok(Self::format_netscape_cookies(&all_cookies)),
_ => Err(format!("Unsupported export format: {format}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_netscape_cookies_valid() {
let content = "# Netscape HTTP Cookie File\n\
.example.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc123\n\
example.com\tFALSE\t/path\tFALSE\t0\ttoken\txyz";
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
assert_eq!(cookies.len(), 2);
assert!(errors.is_empty());
assert_eq!(cookies[0].domain, ".example.com");
assert_eq!(cookies[0].name, "session_id");
assert_eq!(cookies[0].value, "abc123");
assert_eq!(cookies[0].path, "/");
assert!(cookies[0].is_secure);
assert_eq!(cookies[0].expires, 1700000000);
assert_eq!(cookies[1].domain, "example.com");
assert!(!cookies[1].is_secure);
assert_eq!(cookies[1].expires, 0);
}
#[test]
fn test_parse_netscape_cookies_skips_comments_and_blanks() {
let content = "# Comment line\n\n \n# Another comment\n\
.test.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
assert_eq!(cookies.len(), 1);
assert!(errors.is_empty());
}
#[test]
fn test_parse_netscape_cookies_malformed_lines() {
let content = "not\tenough\tfields\n\
.ok.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
assert_eq!(cookies.len(), 1);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("expected 7 tab-separated fields"));
}
#[test]
fn test_parse_json_cookies_valid() {
let content = r#"[
{
"name": "sid",
"value": "abc",
"domain": ".example.com",
"path": "/",
"secure": true,
"httpOnly": true,
"sameSite": "lax",
"expirationDate": 1700000000,
"session": false
}
]"#;
let (cookies, errors) = CookieManager::parse_json_cookies(content);
assert_eq!(cookies.len(), 1);
assert!(errors.is_empty());
assert_eq!(cookies[0].name, "sid");
assert_eq!(cookies[0].domain, ".example.com");
assert!(cookies[0].is_secure);
assert!(cookies[0].is_http_only);
assert_eq!(cookies[0].same_site, 1);
assert_eq!(cookies[0].expires, 1700000000);
}
#[test]
fn test_parse_json_cookies_session() {
let content = r#"[{"name": "s", "value": "v", "domain": ".d.com", "session": true, "expirationDate": 9999}]"#;
let (cookies, errors) = CookieManager::parse_json_cookies(content);
assert_eq!(cookies.len(), 1);
assert!(errors.is_empty());
assert_eq!(cookies[0].expires, 0);
}
#[test]
fn test_parse_json_cookies_same_site_mapping() {
let content = r#"[
{"name": "a", "value": "", "domain": ".d.com", "sameSite": "no_restriction"},
{"name": "b", "value": "", "domain": ".d.com", "sameSite": "lax"},
{"name": "c", "value": "", "domain": ".d.com", "sameSite": "strict"}
]"#;
let (cookies, _) = CookieManager::parse_json_cookies(content);
assert_eq!(cookies[0].same_site, 0);
assert_eq!(cookies[1].same_site, 1);
assert_eq!(cookies[2].same_site, 2);
}
#[test]
fn test_parse_cookies_auto_detect_json() {
let content = r#"[{"name": "x", "value": "y", "domain": ".test.com"}]"#;
let (cookies, _) = CookieManager::parse_cookies(content);
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, "x");
}
#[test]
fn test_parse_cookies_auto_detect_netscape() {
let content = ".test.com\tTRUE\t/\tFALSE\t0\tname\tvalue";
let (cookies, _) = CookieManager::parse_cookies(content);
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, "name");
}
#[test]
fn test_format_netscape_cookies() {
let cookies = vec![UnifiedCookie {
name: "sid".to_string(),
value: "abc".to_string(),
domain: ".example.com".to_string(),
path: "/".to_string(),
expires: 1700000000,
is_secure: true,
is_http_only: false,
same_site: 0,
creation_time: 0,
last_accessed: 0,
}];
let output = CookieManager::format_netscape_cookies(&cookies);
assert!(output.contains("# Netscape HTTP Cookie File"));
assert!(output.contains(".example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc"));
}
#[test]
fn test_format_json_cookies() {
let cookies = vec![UnifiedCookie {
name: "sid".to_string(),
value: "abc".to_string(),
domain: ".example.com".to_string(),
path: "/".to_string(),
expires: 1700000000,
is_secure: true,
is_http_only: true,
same_site: 1,
creation_time: 0,
last_accessed: 0,
}];
let output = CookieManager::format_json_cookies(&cookies);
let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0]["name"], "sid");
assert_eq!(parsed[0]["sameSite"], "lax");
assert_eq!(parsed[0]["session"], false);
assert_eq!(parsed[0]["hostOnly"], false);
}
#[test]
fn test_netscape_roundtrip() {
let cookies = vec![
UnifiedCookie {
name: "a".to_string(),
value: "1".to_string(),
domain: ".d.com".to_string(),
path: "/".to_string(),
expires: 1700000000,
is_secure: true,
is_http_only: false,
same_site: 0,
creation_time: 0,
last_accessed: 0,
},
UnifiedCookie {
name: "b".to_string(),
value: "2".to_string(),
domain: "d.com".to_string(),
path: "/p".to_string(),
expires: 0,
is_secure: false,
is_http_only: false,
same_site: 0,
creation_time: 0,
last_accessed: 0,
},
];
let formatted = CookieManager::format_netscape_cookies(&cookies);
let (parsed, errors) = CookieManager::parse_netscape_cookies(&formatted);
assert!(errors.is_empty());
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].name, "a");
assert_eq!(parsed[0].domain, ".d.com");
assert!(parsed[0].is_secure);
assert_eq!(parsed[1].name, "b");
assert_eq!(parsed[1].domain, "d.com");
}
#[test]
fn test_json_roundtrip() {
let cookies = vec![UnifiedCookie {
name: "tok".to_string(),
value: "xyz".to_string(),
domain: ".site.org".to_string(),
path: "/app".to_string(),
expires: 1700000000,
is_secure: false,
is_http_only: true,
same_site: 2,
creation_time: 0,
last_accessed: 0,
}];
let formatted = CookieManager::format_json_cookies(&cookies);
let (parsed, errors) = CookieManager::parse_json_cookies(&formatted);
assert!(errors.is_empty());
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "tok");
assert_eq!(parsed[0].domain, ".site.org");
assert_eq!(parsed[0].path, "/app");
assert!(!parsed[0].is_secure);
assert!(parsed[0].is_http_only);
assert_eq!(parsed[0].same_site, 2);
assert_eq!(parsed[0].expires, 1700000000);
}
#[test]
fn test_chrome_time_to_unix() {
assert_eq!(CookieManager::chrome_time_to_unix(0), 0);
let chrome_time: i64 = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
assert_eq!(CookieManager::chrome_time_to_unix(chrome_time), 1700000000);
}
#[test]
fn test_unix_to_chrome_time() {
assert_eq!(CookieManager::unix_to_chrome_time(0), 0);
let expected = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
assert_eq!(CookieManager::unix_to_chrome_time(1700000000), expected);
}
#[test]
fn test_chrome_time_roundtrip() {
let unix = 1700000000_i64;
let chrome = CookieManager::unix_to_chrome_time(unix);
assert_eq!(CookieManager::chrome_time_to_unix(chrome), unix);
}
}
+29 -8
View File
@@ -103,11 +103,6 @@ pub fn enable_autostart() -> io::Result<()> {
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
@@ -188,6 +183,26 @@ pub fn load_launch_agent() -> io::Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
pub fn start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
@@ -229,16 +244,22 @@ pub fn enable_autostart() -> io::Result<()> {
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let escaped_daemon_path = daemon_path
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec={} start
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
daemon_path.display()
);
fs::write(&desktop_path, desktop_content)?;
@@ -281,7 +302,7 @@ pub fn enable_autostart() -> io::Result<()> {
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" start", daemon_path.display()),
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
+100 -28
View File
@@ -1,10 +1,7 @@
use muda::{Menu, MenuItem, PredefinedMenuItem};
use muda::{Menu, MenuItem};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
static GUI_RUNNING: AtomicBool = AtomicBool::new(false);
pub fn load_icon() -> Icon {
// On Windows, use the full-color icon so it renders well on dark taskbars.
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
@@ -25,7 +22,6 @@ pub fn load_icon() -> Icon {
pub struct TrayMenu {
pub menu: Menu,
pub open_item: MenuItem,
pub quit_item: MenuItem,
}
@@ -39,19 +35,11 @@ impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let open_item = MenuItem::new("Open Donut Browser", true, None);
let separator = PredefinedMenuItem::separator();
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&open_item).unwrap();
menu.append(&separator).unwrap();
menu.append(&quit_item).unwrap();
Self {
menu,
open_item,
quit_item,
}
Self { menu, quit_item }
}
}
@@ -68,25 +56,47 @@ pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
builder.build().expect("Failed to create tray icon")
}
pub fn open_gui() {
if GUI_RUNNING.load(Ordering::SeqCst) {
log::info!("GUI already running, activating...");
activate_gui();
return;
/// Resolve the .app bundle path from the current daemon executable.
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos_dir = exe.parent()?;
let contents_dir = macos_dir.parent()?;
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
Some(app_dir.to_path_buf())
} else {
None
}
}
pub fn open_gui() {
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
let _ = Command::new("open").arg("-a").arg("Donut Browser").spawn();
// Launch the GUI binary directly. The daemon lives inside the same .app
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
// of launching the GUI. Directly running the binary avoids macOS's app
// activation machinery. The single-instance Tauri plugin in the GUI
// handles deduplication if a GUI instance is already running.
if let Some(app_bundle) = get_app_bundle_path() {
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
if gui_binary.exists() {
let _ = Command::new(&gui_binary).spawn();
} else {
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
}
} else {
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
}
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
// In dev mode, find the main exe next to the daemon binary
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let app_path = exe_dir.join("donutbrowser.exe");
@@ -118,15 +128,77 @@ pub fn open_gui() {
}
}
pub fn activate_gui() {
#[cfg(target_os = "macos")]
fn read_gui_pid() -> Option<u32> {
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
let content = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
val.get("gui_pid")?.as_u64().map(|p| p as u32)
}
fn kill_gui_by_pid() -> bool {
let Some(pid) = read_gui_pid() else {
return false;
};
#[cfg(unix)]
{
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut Browser\" to activate"])
.spawn();
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
ret == 0
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
pub fn set_gui_running(running: bool) {
GUI_RUNNING.store(running, Ordering::SeqCst);
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
if kill_gui_by_pid() {
log::info!("[daemon] GUI killed by PID");
return;
}
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
#[cfg(target_os = "macos")]
{
// Use spawn() instead of output() to avoid blocking the event loop.
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut\" to quit"])
.spawn();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
+57 -8
View File
@@ -9,6 +9,26 @@ use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
@@ -32,7 +52,7 @@ fn read_state() -> DaemonState {
DaemonState::default()
}
fn is_daemon_running() -> bool {
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
@@ -43,12 +63,7 @@ fn is_daemon_running() -> bool {
#[cfg(windows)]
{
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid)])
.output();
output
.map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
.unwrap_or(false)
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
@@ -113,7 +128,13 @@ fn get_daemon_path() -> Option<PathBuf> {
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
if let Ok(output) = Command::new("where").arg("donut-daemon").output() {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
@@ -243,6 +264,11 @@ fn spawn_daemon_macos() -> Result<(), String> {
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
Ok(())
}
@@ -308,3 +334,26 @@ pub fn ensure_daemon_running() -> Result<(), String> {
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
+2 -2
View File
@@ -204,7 +204,7 @@ mod windows {
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
app_key
.set_value("ApplicationIcon", &format!("{},0", exe_path))
.set_value("ApplicationIcon", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
// Create Capabilities key
@@ -273,7 +273,7 @@ mod windows {
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
icon_key
.set_value("", &format!("{},0", exe_path))
.set_value("", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set default icon: {}", e))?;
// Create shell\open\command key
+80 -46
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@@ -71,16 +70,7 @@ impl DownloadedBrowsersRegistry {
}
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("data");
path.push("downloaded_browsers.json");
Ok(path)
Ok(crate::app_dirs::data_subdir().join("downloaded_browsers.json"))
}
pub fn add_browser(&self, info: DownloadedBrowserInfo) {
@@ -128,19 +118,7 @@ impl DownloadedBrowsersRegistry {
};
let browser_instance = create_browser(browser_type.clone());
// Get binaries directory
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
} else {
return false;
};
let binaries_dir = crate::app_dirs::binaries_dir();
let files_exist = browser_instance.is_version_downloaded(version, &binaries_dir);
@@ -312,6 +290,30 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
}
to_remove.retain(|(browser, version)| {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
return false;
}
true
});
}
// Remove unused binaries and their version folders
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
@@ -511,15 +513,7 @@ impl DownloadedBrowsersRegistry {
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Get binaries directory path
let base_dirs = directories::BaseDirs::new().ok_or("Failed to get base directories")?;
let mut binaries_dir = base_dirs.data_local_dir().to_path_buf();
binaries_dir.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
binaries_dir.push("binaries");
let binaries_dir = crate::app_dirs::binaries_dir();
let version_dir = binaries_dir.join(browser).join(version);
@@ -806,19 +800,7 @@ impl DownloadedBrowsersRegistry {
let browser = create_browser(browser_type.clone());
// Get binaries directory
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
} else {
return Err("Failed to get base directories".into());
};
let binaries_dir = crate::app_dirs::binaries_dir();
log::info!(
"binaries_dir: {binaries_dir:?} for profile: {}",
@@ -1164,6 +1146,58 @@ mod tests {
);
}
#[test]
fn test_last_version_kept_during_cleanup() {
let registry = DownloadedBrowsersRegistry::new();
// Add a single version for "firefox"
registry.add_browser(DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
file_path: PathBuf::from("/test/firefox/139.0"),
});
// Add two versions for "chromium"
registry.add_browser(DownloadedBrowserInfo {
browser: "chromium".to_string(),
version: "120.0".to_string(),
file_path: PathBuf::from("/test/chromium/120.0"),
});
registry.add_browser(DownloadedBrowserInfo {
browser: "chromium".to_string(),
version: "121.0".to_string(),
file_path: PathBuf::from("/test/chromium/121.0"),
});
// No active or running profiles
let result = registry
.cleanup_unused_binaries_internal(&[], &[])
.expect("cleanup should succeed");
// firefox 139.0 should be kept (last version), chromium should lose one but keep one
// The exact one kept depends on iteration order, but at least one must remain
assert!(
!result.contains(&"firefox 139.0".to_string()),
"Last version of firefox should not be cleaned up"
);
// At most one chromium version should have been cleaned up
let chromium_cleaned: Vec<_> = result
.iter()
.filter(|r| r.starts_with("chromium"))
.collect();
assert!(
chromium_cleaned.len() <= 1,
"At most one chromium version should be cleaned up, got: {:?}",
chromium_cleaned
);
// Verify firefox is still registered
assert!(
registry.is_browser_registered("firefox", "139.0"),
"Last firefox version should still be registered"
);
}
#[test]
fn test_is_browser_registered_vs_downloaded() {
let registry = DownloadedBrowsersRegistry::new();
+187 -352
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
@@ -158,7 +124,11 @@ impl Downloader {
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or(format!("Camoufox version {version} not found"))?;
.or_else(|| {
log::info!("Camoufox: requested version {version} not found, using latest available");
releases.first()
})
.ok_or("No Camoufox releases found".to_string())?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
@@ -179,14 +149,10 @@ impl Downloader {
.fetch_wayfern_version_with_caching(true)
.await?;
// Verify requested version matches available version
if version_info.version != version {
return Err(
format!(
"Wayfern version {version} not found. Available version: {}",
version_info.version
)
.into(),
log::info!(
"Wayfern: requested version {version}, using available version {}",
version_info.version
);
}
@@ -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,
@@ -450,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 = {
@@ -548,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(),
@@ -614,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(),
@@ -652,6 +526,41 @@ impl Downloader {
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
}
// For Wayfern/Camoufox, resolve the actual available version from the API
let version = if browser_str == "wayfern" {
match self
.api_client
.fetch_wayfern_version_with_caching(true)
.await
{
Ok(info) if info.version != version => {
log::info!(
"Wayfern: requested {version}, using available {}",
info.version
);
info.version
}
_ => version,
}
} else if browser_str == "camoufox" {
match self
.api_client
.fetch_camoufox_releases_with_caching(true)
.await
{
Ok(releases) if !releases.is_empty() && releases[0].tag_name != version => {
log::info!(
"Camoufox: requested {version}, using available {}",
releases[0].tag_name
);
releases[0].tag_name.clone()
}
_ => version,
}
} else {
version
};
// Check if this browser-version pair is already being downloaded
let download_key = format!("{browser_str}-{version}");
let cancel_token = {
@@ -674,21 +583,7 @@ impl Downloader {
// Use injected registry instance
// Get binaries directory - we need to get it from somewhere
// This is a bit tricky since we don't have access to BrowserRunner's get_binaries_dir
// We'll need to replicate this logic
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
} else {
return Err("Failed to get base directories".into());
};
let binaries_dir = crate::app_dirs::binaries_dir();
// Check if registry thinks it's downloaded, but also verify files actually exist
if self.registry.is_browser_downloaded(&browser_str, &version) {
@@ -816,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);
@@ -829,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());
}
}
@@ -975,7 +889,6 @@ impl Downloader {
.await
{
log::warn!("Failed to create version.json for Camoufox: {e}");
// Don't fail the download if version.json creation fails
}
}
@@ -1002,6 +915,40 @@ impl Downloader {
tokens.remove(&download_key);
}
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
{
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.update_profiles_to_latest_installed(&app_handle_for_update) {
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Auto-updated {} profiles to latest installed versions: {:?}",
updated.len(),
updated
);
}
}
Err(e) => {
log::error!("Failed to auto-update profile versions: {e}");
}
}
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
match registry.cleanup_unused_binaries() {
Ok(cleaned) => {
if !cleaned.is_empty() {
log::info!("Cleaned up unused binaries after download: {:?}", cleaned);
}
}
Err(e) => {
log::error!("Failed to cleanup unused binaries: {e}");
}
}
});
}
Ok(version)
}
}
@@ -1040,85 +987,21 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
#[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(
@@ -1130,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"))
@@ -1222,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());
+329
View File
@@ -0,0 +1,329 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::profile::BrowserProfile;
lazy_static::lazy_static! {
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
}
/// Get or create the RAM-backed base directory for ephemeral profiles.
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk RAM disk.
fn get_ephemeral_base_dir() -> Result<PathBuf, String> {
#[cfg(target_os = "linux")]
{
let base = PathBuf::from("/dev/shm/donut-ephemeral");
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create ephemeral base in /dev/shm: {e}"))?;
Ok(base)
}
#[cfg(not(target_os = "linux"))]
{
#[cfg(target_os = "macos")]
{
if let Ok(mount) = get_or_create_macos_ramdisk() {
return Ok(mount);
}
log::warn!("Failed to create macOS RAM disk, ephemeral profiles may use disk");
}
#[cfg(target_os = "windows")]
{
if let Ok(mount) = get_or_create_windows_ramdisk() {
return Ok(mount);
}
log::warn!("Failed to create Windows RAM disk, ephemeral profiles may use disk");
}
// Fallback
let base = std::env::temp_dir().join("donut-ephemeral");
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create ephemeral base dir: {e}"))?;
Ok(base)
}
}
#[cfg(target_os = "macos")]
fn get_or_create_macos_ramdisk() -> Result<PathBuf, String> {
let mount_point = PathBuf::from("/Volumes/DonutEphemeral");
// Reuse existing RAM disk from a previous session
if mount_point.exists() && mount_point.is_dir() {
return Ok(mount_point);
}
// 256 MB in 512-byte sectors
let sectors = 256 * 2048;
let output = std::process::Command::new("hdiutil")
.args(["attach", "-nomount", &format!("ram://{sectors}")])
.output()
.map_err(|e| format!("hdiutil attach failed: {e}"))?;
if !output.status.success() {
return Err(format!(
"hdiutil attach failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let dev = String::from_utf8_lossy(&output.stdout).trim().to_string();
let fmt = std::process::Command::new("diskutil")
.args(["erasevolume", "HFS+", "DonutEphemeral", &dev])
.output()
.map_err(|e| format!("diskutil erasevolume failed: {e}"))?;
if !fmt.status.success() {
let _ = std::process::Command::new("hdiutil")
.args(["detach", &dev])
.output();
return Err(format!(
"diskutil erasevolume failed: {}",
String::from_utf8_lossy(&fmt.stderr)
));
}
log::info!("Created macOS RAM disk at {}", mount_point.display());
Ok(mount_point)
}
#[cfg(target_os = "windows")]
fn get_or_create_windows_ramdisk() -> Result<PathBuf, String> {
// Check if a previous RAM disk with our directory already exists
for letter in ['R', 'Q', 'P', 'O'] {
let base = PathBuf::from(format!("{}:\\DonutEphemeral", letter));
if base.exists() && base.is_dir() {
return Ok(base);
}
}
// Try to create a RAM disk using imdisk (open-source RAM disk driver)
for letter in ['R', 'Q', 'P', 'O'] {
let drive = format!("{}:", letter);
if PathBuf::from(format!("{}\\", drive)).exists() {
continue;
}
let output = std::process::Command::new("imdisk")
.args(["-a", "-s", "256M", "-m", &drive, "-p", "/fs:ntfs /q /y"])
.output();
match output {
Ok(out) if out.status.success() => {
let base = PathBuf::from(format!("{}\\DonutEphemeral", drive));
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create dir on RAM disk: {e}"))?;
log::info!("Created Windows RAM disk at {}", base.display());
return Ok(base);
}
Ok(out) => {
log::debug!(
"imdisk failed for drive {}: {}",
drive,
String::from_utf8_lossy(&out.stderr)
);
}
Err(e) => {
return Err(format!("imdisk not available: {e}"));
}
}
}
Err("Could not create Windows RAM disk".to_string())
}
pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
let base = get_ephemeral_base_dir()?;
let dir_path = base.join(profile_id);
std::fs::create_dir_all(&dir_path).map_err(|e| format!("Failed to create ephemeral dir: {e}"))?;
EPHEMERAL_DIRS
.lock()
.map_err(|e| format!("Failed to lock ephemeral dirs: {e}"))?
.insert(profile_id.to_string(), dir_path.clone());
log::info!(
"Created ephemeral dir for profile {}: {}",
profile_id,
dir_path.display()
);
Ok(dir_path)
}
pub fn get_ephemeral_dir(profile_id: &str) -> Option<PathBuf> {
EPHEMERAL_DIRS.lock().ok()?.get(profile_id).cloned()
}
pub fn remove_ephemeral_dir(profile_id: &str) {
let dir = EPHEMERAL_DIRS
.lock()
.ok()
.and_then(|mut map| map.remove(profile_id));
if let Some(dir_path) = dir {
if dir_path.exists() {
if let Err(e) = std::fs::remove_dir_all(&dir_path) {
log::warn!("Failed to remove ephemeral dir {}: {e}", dir_path.display());
} else {
log::info!(
"Removed ephemeral dir for profile {}: {}",
profile_id,
dir_path.display()
);
}
}
}
}
/// Recover ephemeral dir mappings on startup by scanning the RAM-backed base dir.
/// Dir names are profile UUIDs, so we re-populate the in-memory HashMap.
/// Also cleans up old disk-based dirs from previous versions.
pub fn recover_ephemeral_dirs() {
cleanup_legacy_dirs();
let base = match get_ephemeral_base_dir() {
Ok(base) => base,
Err(e) => {
log::warn!("Cannot recover ephemeral dirs: {e}");
return;
}
};
let entries = match std::fs::read_dir(&base) {
Ok(entries) => entries,
Err(_) => return,
};
let mut dirs = match EPHEMERAL_DIRS.lock() {
Ok(dirs) => dirs,
Err(_) => return,
};
for entry in entries.flatten() {
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
if uuid::Uuid::parse_str(name).is_ok() {
dirs.insert(name.to_string(), entry.path());
log::info!("Recovered ephemeral dir for profile {}", name);
}
}
}
}
}
/// Remove old-format ephemeral dirs from /tmp (pre-tmpfs migration).
fn cleanup_legacy_dirs() {
let temp_dir = std::env::temp_dir();
let entries = match std::fs::read_dir(&temp_dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.starts_with("donut-ephemeral-") && entry.path().is_dir() {
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
log::warn!("Failed to clean up legacy ephemeral dir: {e}");
} else {
log::info!(
"Cleaned up legacy ephemeral dir: {}",
entry.path().display()
);
}
}
}
}
}
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
if profile.ephemeral {
if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
return dir;
}
}
profile.get_profile_data_path(profiles_dir)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_profile(id: uuid::Uuid, ephemeral: bool) -> BrowserProfile {
BrowserProfile {
id,
name: "test".to_string(),
browser: "camoufox".to_string(),
version: "1.0".to_string(),
proxy_id: None,
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: crate::profile::types::SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
}
}
#[test]
#[serial_test::serial]
fn test_ephemeral_dir_lifecycle() {
let profile_id = uuid::Uuid::new_v4();
let id_str = profile_id.to_string();
let dir = create_ephemeral_dir(&id_str).unwrap();
assert!(dir.is_dir());
assert_eq!(get_ephemeral_dir(&id_str), Some(dir.clone()));
let ephemeral_profile = make_test_profile(profile_id, true);
let profiles_dir = std::env::temp_dir().join("test_profiles_ephemeral");
assert_eq!(
get_effective_profile_path(&ephemeral_profile, &profiles_dir),
dir
);
remove_ephemeral_dir(&id_str);
assert!(!dir.exists());
assert!(get_ephemeral_dir(&id_str).is_none());
let persistent_profile = make_test_profile(uuid::Uuid::new_v4(), false);
let expected = persistent_profile.get_profile_data_path(&profiles_dir);
assert_eq!(
get_effective_profile_path(&persistent_profile, &profiles_dir),
expected
);
}
#[test]
fn test_recover_ephemeral_dirs() {
let base = get_ephemeral_base_dir().unwrap();
let test_id = uuid::Uuid::new_v4().to_string();
let test_dir = base.join(&test_id);
std::fs::create_dir_all(&test_dir).unwrap();
// Clear the HashMap so recovery has something to find
EPHEMERAL_DIRS.lock().unwrap().remove(&test_id);
assert!(get_ephemeral_dir(&test_id).is_none());
recover_ephemeral_dirs();
assert_eq!(get_ephemeral_dir(&test_id), Some(test_dir.clone()));
// Clean up
remove_ephemeral_dir(&test_id);
}
}
File diff suppressed because it is too large Load Diff
+119 -101
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);
@@ -593,7 +589,11 @@ impl Extractor {
}
}
log::info!("ZIP extraction completed. Searching for executable...");
log::info!("ZIP extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self
.find_extracted_executable(dest_dir)
.await
@@ -617,7 +617,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.gz extraction completed. Searching for executable...");
log::info!("tar.gz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -638,7 +640,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.bz2 extraction completed. Searching for executable...");
log::info!("tar.bz2 extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -673,7 +677,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.xz extraction completed. Searching for executable...");
log::info!("tar.xz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -691,7 +697,9 @@ impl Extractor {
extractor.to(dest_dir);
}
log::info!("MSI extraction completed. Searching for executable...");
log::info!("MSI extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -727,55 +735,91 @@ 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(
fn flatten_single_directory_archive(
&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()?;
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let entries: Vec<_> = fs::read_dir(dest_dir)?.filter_map(|e| e.ok()).collect();
if !output.status.success() {
return Err(
format!(
"Failed to install Zen: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
let archive_extensions = ["zip", "tar", "xz", "gz", "bz2", "dmg", "msi", "exe"];
let mut dirs = Vec::new();
let mut has_non_archive_files = false;
for entry in &entries {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if !archive_extensions.contains(&ext.to_lowercase().as_str()) {
has_non_archive_files = true;
}
} else {
has_non_archive_files = true;
}
}
// Find the installed executable
self.find_extracted_executable(dest_dir).await
if dirs.len() == 1 && !has_non_archive_files {
let single_dir = &dirs[0];
if single_dir.extension().is_some_and(|ext| ext == "app") {
log::info!(
"Skipping flatten: {} is a macOS app bundle",
single_dir.display()
);
return Ok(());
}
log::info!(
"Flattening single-directory archive: moving contents of {} to {}",
single_dir.display(),
dest_dir.display()
);
let inner_entries: Vec<_> = fs::read_dir(single_dir)?.filter_map(|e| e.ok()).collect();
for entry in inner_entries {
let source = entry.path();
let file_name = match source.file_name() {
Some(name) => name.to_owned(),
None => continue,
};
let target = dest_dir.join(&file_name);
fs::rename(&source, &target).map_err(|e| {
format!(
"Failed to move {} to {}: {}",
source.display(),
target.display(),
e
)
})?;
}
fs::remove_dir(single_dir).map_err(|e| {
format!(
"Failed to remove empty directory {}: {}",
single_dir.display(),
e
)
})?;
log::info!("Successfully flattened archive directory structure");
}
Ok(())
}
async fn find_extracted_executable(
@@ -868,8 +912,6 @@ impl Extractor {
"firefox.exe",
"chrome.exe",
"chromium.exe",
"zen.exe",
"brave.exe",
"camoufox.exe",
"wayfern.exe",
];
@@ -937,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")
@@ -989,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",
@@ -1044,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",
+90 -6
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,
}
@@ -76,21 +79,39 @@ impl GeoIPDownloader {
false
}
}
/// Check if GeoIP database is missing for Camoufox profiles
fn get_timestamp_path() -> PathBuf {
crate::app_dirs::cache_dir().join("geoip_last_download")
}
fn is_geoip_stale() -> bool {
let timestamp_path = Self::get_timestamp_path();
let Ok(content) = std::fs::read_to_string(&timestamp_path) else {
return true;
};
let Ok(timestamp) = content.trim().parse::<u64>() else {
return true;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
now.saturating_sub(timestamp) > SEVEN_DAYS
}
/// Check if GeoIP database is missing or stale for Camoufox profiles
pub fn check_missing_geoip_database(
&self,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
// Get all profiles
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
// Check if there are any Camoufox profiles
let has_camoufox_profiles = profiles.iter().any(|profile| profile.browser == "camoufox");
if has_camoufox_profiles {
// Check if GeoIP database is available
return Ok(!Self::is_geoip_database_available());
return Ok(!Self::is_geoip_database_available() || Self::is_geoip_stale());
}
Ok(false)
@@ -108,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(
@@ -137,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?;
@@ -152,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;
@@ -200,6 +244,21 @@ 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();
if let Some(parent) = timestamp_path.parent() {
let _ = fs::create_dir_all(parent).await;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let _ = fs::write(&timestamp_path, now.to_string()).await;
// Emit completion
let _ = events::emit(
@@ -362,6 +421,31 @@ mod tests {
assert!(path.to_string_lossy().ends_with("GeoLite2-City.mmdb"));
}
#[test]
fn test_is_geoip_stale() {
let tmp = tempfile::TempDir::new().unwrap();
let _guard = crate::app_dirs::set_test_cache_dir(tmp.path().to_path_buf());
// No timestamp file → stale
assert!(GeoIPDownloader::is_geoip_stale());
let timestamp_path = GeoIPDownloader::get_timestamp_path();
std::fs::create_dir_all(timestamp_path.parent().unwrap()).unwrap();
// Recent timestamp → not stale
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
std::fs::write(&timestamp_path, now.to_string()).unwrap();
assert!(!GeoIPDownloader::is_geoip_stale());
// 8 days ago → stale
let eight_days_ago = now - 8 * 24 * 60 * 60;
std::fs::write(&timestamp_path, eight_days_ago.to_string()).unwrap();
assert!(GeoIPDownloader::is_geoip_stale());
}
#[test]
fn test_is_geoip_database_available() {
// Test that the function works correctly regardless of file system state.
+35 -40
View File
@@ -1,8 +1,6 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::events;
@@ -33,48 +31,15 @@ struct GroupsData {
groups: Vec<ProfileGroup>,
}
pub struct GroupManager {
base_dirs: BaseDirs,
data_dir_override: Option<PathBuf>,
}
pub struct GroupManager;
impl GroupManager {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
.ok()
.map(PathBuf::from),
}
Self
}
// Helper for tests to override data directory without global env var
#[allow(dead_code)]
pub fn with_data_dir_override(dir: &Path) -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: Some(dir.to_path_buf()),
}
}
fn get_groups_file_path(&self) -> PathBuf {
if let Some(dir) = &self.data_dir_override {
let mut override_path = dir.clone();
// Ensure the directory exists before returning the path
let _ = fs::create_dir_all(&override_path);
override_path.push("groups.json");
return override_path;
}
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("data");
path.push("groups.json");
path
fn get_groups_file_path(&self) -> std::path::PathBuf {
crate::app_dirs::data_subdir().join("groups.json")
}
fn load_groups_data(&self) -> Result<GroupsData, Box<dyn std::error::Error>> {
@@ -119,10 +84,11 @@ impl GroupManager {
return Err(format!("Group with name '{name}' already exists").into());
}
let sync_enabled = crate::sync::is_sync_configured();
let group = ProfileGroup {
id: uuid::Uuid::new_v4().to_string(),
name,
sync_enabled: false,
sync_enabled,
last_sync: None,
};
@@ -134,6 +100,15 @@ impl GroupManager {
log::error!("Failed to emit groups-changed event: {e}");
}
if group.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = group.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_group_sync(id).await;
});
}
}
Ok(group)
}
@@ -170,6 +145,15 @@ impl GroupManager {
log::error!("Failed to emit groups-changed event: {e}");
}
if updated_group.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_group.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_group_sync(id).await;
});
}
}
Ok(updated_group)
}
@@ -207,6 +191,17 @@ impl GroupManager {
Ok(())
}
pub fn delete_group_internal(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut groups_data = self.load_groups_data()?;
let initial_len = groups_data.groups.len();
groups_data.groups.retain(|g| g.id != id);
if groups_data.groups.len() == initial_len {
return Err(format!("Group with id '{id}' not found").into());
}
self.save_groups_data(&groups_data)?;
Ok(())
}
pub fn delete_group(
&self,
app_handle: &tauri::AppHandle,
+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);
}
}
+645 -119
View File
File diff suppressed because it is too large Load Diff
+2498 -154
View File
File diff suppressed because it is too large Load Diff
+154 -8
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::*;
@@ -651,8 +653,11 @@ pub mod windows {
use std::process::Command;
// Try taskkill command as fallback
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
match output {
@@ -677,6 +682,7 @@ pub mod windows {
}
#[cfg(target_os = "linux")]
#[allow(dead_code)]
pub mod linux {
use super::*;
@@ -871,18 +877,158 @@ pub mod linux {
pub async fn kill_browser_process_impl(
pid: u32,
profile_data_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use sysinfo::{Pid, System};
let system = System::new_all();
if let Some(process) = system.process(Pid::from(pid as usize)) {
if !process.kill() {
return Err(format!("Failed to kill process {}", pid).into());
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
log::info!("Attempting to kill browser process with PID: {pid}");
let mut pids_to_kill = vec![pid];
// Find all descendant processes
let descendants = get_all_descendant_pids(pid);
pids_to_kill.extend(descendants);
// Find additional processes using the same profile path
if let Some(profile_path) = profile_data_path {
let additional_pids = find_processes_by_profile_path(profile_path);
for p in additional_pids {
if !pids_to_kill.contains(&p) {
log::info!("Found additional process {} using profile path", p);
pids_to_kill.push(p);
}
}
} else {
return Err(format!("Process {} not found", pid).into());
}
log::info!("Successfully killed browser process with PID: {pid}");
log::info!("Total processes to kill: {:?}", pids_to_kill);
// Send SIGKILL to all identified processes
for &p in &pids_to_kill {
log::info!("Sending SIGKILL to PID: {p}");
let _ = Command::new("kill")
.args(["-KILL", &p.to_string()])
.output();
}
// Also kill by process group and parent PID
let pid_str = pid.to_string();
let _ = Command::new("pkill")
.args(["-KILL", "-P", &pid_str])
.output();
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Verify processes are dead
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let mut still_running = Vec::new();
for &p in &pids_to_kill {
if system.process(Pid::from(p as usize)).is_some() {
still_running.push(p);
}
}
if !still_running.is_empty() {
log::info!(
"Processes {:?} still running, trying final termination",
still_running
);
for p in &still_running {
let _ = Command::new("kill")
.args(["-KILL", &p.to_string()])
.output();
}
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let mut final_still_running = Vec::new();
for &p in &pids_to_kill {
if system.process(Pid::from(p as usize)).is_some() {
final_still_running.push(p);
}
}
if !final_still_running.is_empty() {
log::error!(
"ERROR: Processes {:?} could not be terminated despite aggressive attempts",
final_still_running
);
return Err(
format!(
"Failed to terminate browser processes {:?} - still running",
final_still_running
)
.into(),
);
}
}
log::info!("Browser termination completed for PID: {pid}");
Ok(())
}
fn find_processes_by_profile_path(profile_path: &str) -> Vec<u32> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let mut pids = Vec::new();
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
pids.push(pid.as_u32());
}
}
pids
}
fn get_all_descendant_pids(parent_pid: u32) -> Vec<u32> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let mut descendants = Vec::new();
let mut to_check = vec![parent_pid];
let mut checked = std::collections::HashSet::new();
while let Some(current_pid) = to_check.pop() {
if checked.contains(&current_pid) {
continue;
}
checked.insert(current_pid);
for (pid, process) in system.processes() {
let pid_u32 = pid.as_u32();
if let Some(parent) = process.parent() {
if parent.as_u32() == current_pid && !checked.contains(&pid_u32) {
descendants.push(pid_u32);
to_check.push(pid_u32);
}
}
}
}
descendants
}
}
+318 -77
View File
@@ -1,18 +1,17 @@
use crate::api_client::is_browser_version_nightly;
use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::camoufox_manager::CamoufoxConfig;
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::BrowserProfile;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
use directories::BaseDirs;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use sysinfo::{Pid, System};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
pub struct ProfileManager {
base_dirs: BaseDirs,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
@@ -20,7 +19,6 @@ pub struct ProfileManager {
impl ProfileManager {
fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
}
@@ -31,25 +29,11 @@ impl ProfileManager {
}
pub fn get_profiles_dir(&self) -> PathBuf {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("profiles");
path
crate::app_dirs::profiles_dir()
}
pub fn get_binaries_dir(&self) -> PathBuf {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
crate::app_dirs::binaries_dir()
}
#[allow(clippy::too_many_arguments)]
@@ -61,10 +45,24 @@ impl ProfileManager {
version: &str,
release_type: &str,
proxy_id: Option<String>,
vpn_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
ephemeral: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
if proxy_id.is_some() && vpn_id.is_some() {
return Err("Cannot set both proxy_id and vpn_id".into());
}
// Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy
if let Some(ref pid) = proxy_id {
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
log::info!("Syncing cloud proxy credentials before profile creation");
CLOUD_AUTH.sync_cloud_proxy().await;
}
}
log::info!("Attempting to create profile: {name}");
// Check if a profile with this name already exists (case insensitive)
@@ -85,7 +83,9 @@ impl ProfileManager {
// Create profile directory with UUID and profile subdirectory
create_dir_all(&profile_uuid_dir)?;
create_dir_all(&profile_data_dir)?;
if !ephemeral {
create_dir_all(&profile_data_dir)?;
}
// For Camoufox profiles, generate fingerprint during creation
let final_camoufox_config = if browser == "camoufox" {
@@ -163,6 +163,7 @@ impl ProfileManager {
browser: browser.to_string(),
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -171,8 +172,15 @@ impl ProfileManager {
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
sync_enabled: false,
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
@@ -276,6 +284,7 @@ impl ProfileManager {
browser: browser.to_string(),
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -284,8 +293,15 @@ impl ProfileManager {
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
sync_enabled: false,
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
@@ -321,6 +337,7 @@ impl ProfileManager {
browser: browser.to_string(),
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: vpn_id.clone(),
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -329,8 +346,15 @@ impl ProfileManager {
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
sync_enabled: false,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: Some(get_host_os()),
ephemeral,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
// Save profile info
@@ -344,16 +368,19 @@ impl ProfileManager {
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
// Create user.js with common Firefox preferences and apply proxy settings if provided
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
// Skip for ephemeral profiles since the data dir is created at launch time
if !ephemeral {
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
} else {
// Proxy ID provided but not found, disable proxy
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
}
} else {
// Proxy ID provided but not found, disable proxy
// Create user.js with common Firefox preferences but no proxy
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
}
} else {
// Create user.js with common Firefox preferences but no proxy
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
}
// Emit profile creation event
@@ -466,15 +493,15 @@ impl ProfileManager {
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
// Check if browser is running (cross-OS profiles can't be running locally)
if profile.process_id.is_some() && !profile.is_cross_os() {
return Err(
"Cannot delete profile while browser is running. Please stop the browser first.".into(),
);
}
// Remember sync_enabled before deleting local files
let was_sync_enabled = profile.sync_enabled;
// Remember sync mode before deleting local files
let was_sync_enabled = profile.is_sync_enabled();
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
@@ -620,7 +647,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
// Auto-enable sync for new group if profile has sync enabled
if profile.sync_enabled {
if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id {
let group_id_clone = new_group_id.clone();
let app_handle_clone = app_handle.clone();
@@ -717,6 +744,31 @@ impl ProfileManager {
Ok(profile)
}
pub fn update_profile_proxy_bypass_rules(
&self,
_app_handle: &tauri::AppHandle,
profile_id: &str,
rules: Vec<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.proxy_bypass_rules = rules;
self.save_profile(&profile)?;
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn delete_multiple_profiles(
&self,
app_handle: &tauri::AppHandle,
@@ -733,8 +785,8 @@ impl ProfileManager {
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
// Check if browser is running (cross-OS profiles can't be running locally)
if profile.process_id.is_some() && !profile.is_cross_os() {
return Err(
format!(
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
@@ -745,7 +797,7 @@ impl ProfileManager {
}
// Track sync-enabled profiles for remote deletion
if profile.sync_enabled {
if profile.is_sync_enabled() {
sync_enabled_ids.push(profile_id.clone());
}
@@ -803,6 +855,7 @@ impl ProfileManager {
pub fn clone_profile(
&self,
profile_id: &str,
custom_name: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
@@ -819,7 +872,10 @@ impl ProfileManager {
}
let new_id = uuid::Uuid::new_v4();
let clone_name = self.generate_clone_name(&source.name)?;
let clone_name = match custom_name {
Some(name) if !name.trim().is_empty() => name.trim().to_string(),
_ => self.generate_clone_name(&source.name)?,
};
let profiles_dir = self.get_profiles_dir();
let source_dir = profiles_dir.join(source.id.to_string());
@@ -837,6 +893,7 @@ impl ProfileManager {
browser: source.browser,
version: source.version,
proxy_id: source.proxy_id,
vpn_id: source.vpn_id,
process_id: None,
last_launch: None,
release_type: source.release_type,
@@ -845,8 +902,15 @@ impl ProfileManager {
group_id: source.group_id,
tags: source.tags,
note: source.note,
sync_enabled: false,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: Some(get_host_os()),
ephemeral: false,
extension_group_id: source.extension_group_id,
proxy_bypass_rules: source.proxy_bypass_rules,
created_by_id: None,
created_by_email: None,
};
self.save_profile(&new_profile)?;
@@ -1007,8 +1071,9 @@ impl ProfileManager {
// Remember old proxy_id for cleanup (not used yet, but may be needed for cleanup)
let _old_proxy_id = profile.proxy_id.clone();
// Update proxy settings
// Update proxy settings and clear VPN (mutual exclusion)
profile.proxy_id = proxy_id.clone();
profile.vpn_id = None;
// Save the updated profile
self
@@ -1018,7 +1083,7 @@ impl ProfileManager {
})?;
// Auto-enable sync for new proxy if profile has sync enabled
if profile.sync_enabled {
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id, &app_handle).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
@@ -1071,6 +1136,78 @@ impl ProfileManager {
Ok(profile)
}
pub async fn update_profile_vpn(
&self,
_app_handle: tauri::AppHandle,
profile_id: &str,
vpn_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Invalid profile ID: {profile_id}").into()
},
)?;
let profiles =
self
.list_profiles()
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to list profiles: {e}").into()
})?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
format!("Profile with ID '{profile_id}' not found").into()
})?;
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id;
profile.proxy_id = None;
self
.save_profile(&profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to save profile: {e}").into()
})?;
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn update_profile_extension_group(
&self,
profile_id: &str,
extension_group_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id;
self.save_profile(&profile)?;
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Failed to emit profile update event: {e}");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub async fn check_browser_status(
&self,
app_handle: tauri::AppHandle,
@@ -1088,7 +1225,9 @@ impl ProfileManager {
// For non-camoufox browsers, use the existing PID-based logic
let inner_profile = profile.clone();
let mut system = System::new();
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let mut is_running = false;
let mut found_pid: Option<u32> = None;
@@ -1103,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"
@@ -1114,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
}
@@ -1123,31 +1259,24 @@ impl ProfileManager {
if profile_path_match {
is_running = true;
found_pid = Some(pid);
// Found existing browser process
}
}
}
// If we didn't find the browser with the stored PID, search all processes
if !is_running {
// Refresh all processes only when we need to search (expensive but necessary)
system.refresh_all();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.len() >= 2 {
// 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,
};
@@ -1163,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"
@@ -1177,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
}
@@ -1248,7 +1370,8 @@ impl ProfileManager {
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager;
let profiles_dir = self.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_data_path =
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy();
// Check if there's a running Camoufox instance for this profile
@@ -1292,6 +1415,10 @@ impl ProfileManager {
}
Ok(None) => {
// No running instance found, clear process ID if set and stop proxy
if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
@@ -1322,6 +1449,10 @@ impl ProfileManager {
Err(e) => {
// Error checking status, assume not running and clear process ID
log::warn!("Warning: Failed to check Camoufox status: {e}");
if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
@@ -1363,7 +1494,8 @@ impl ProfileManager {
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager;
let profiles_dir = self.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_data_path =
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy();
// Check if there's a running Wayfern instance for this profile
@@ -1407,6 +1539,10 @@ impl ProfileManager {
}
None => {
// No running instance found, clear process ID if set
if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
@@ -1451,9 +1587,10 @@ impl ProfileManager {
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
// Keep extension updates enabled
// Keep extension updates enabled and allow sideloaded extensions
"user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
// Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(),
@@ -1623,9 +1760,11 @@ impl ProfileManager {
let pac_content = "function FindProxyForURL(url, host) { return 'DIRECT'; }";
let pac_path = uuid_dir.join("proxy.pac");
fs::write(&pac_path, pac_content)?;
let pac_url =
url::Url::from_file_path(&pac_path).map_err(|_| "Failed to convert PAC path to file URL")?;
preferences.push(format!(
"user_pref(\"network.proxy.autoconfig_url\", \"file://{}\");",
pac_path.to_string_lossy()
"user_pref(\"network.proxy.autoconfig_url\", \"{}\");",
pac_url.as_str()
));
fs::write(user_js_path, preferences.join("\n"))?;
@@ -1784,6 +1923,33 @@ mod tests {
"Should set SSL proxy port"
);
}
#[test]
fn test_pac_url_encodes_spaces_in_path() {
let (manager, temp_dir) = create_test_profile_manager();
let uuid_dir = temp_dir.path().join("path with spaces");
let profile_dir = uuid_dir.join("profile");
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
let result = manager.disable_proxy_settings_in_profile(&profile_dir);
assert!(result.is_ok(), "Should handle paths with spaces");
let user_js = fs::read_to_string(profile_dir.join("user.js")).unwrap();
let pac_line = user_js
.lines()
.find(|l| l.contains("autoconfig_url"))
.expect("Should have autoconfig_url preference");
assert!(
!pac_line.contains("path with spaces"),
"PAC URL should not contain raw spaces: {pac_line}"
);
assert!(
pac_line.contains("path%20with%20spaces"),
"PAC URL should percent-encode spaces: {pac_line}"
);
}
}
#[allow(clippy::too_many_arguments)]
@@ -1795,9 +1961,11 @@ pub async fn create_browser_profile_with_group(
version: String,
release_type: String,
proxy_id: Option<String>,
vpn_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
ephemeral: bool,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
@@ -1808,9 +1976,11 @@ pub async fn create_browser_profile_with_group(
&version,
&release_type,
proxy_id,
vpn_id,
camoufox_config,
wayfern_config,
group_id,
ephemeral,
)
.await
.map_err(|e| format!("Failed to create profile: {e}"))
@@ -1837,6 +2007,19 @@ pub async fn update_profile_proxy(
.map_err(|e| format!("Failed to update profile: {e}"))
}
#[tauri::command]
pub async fn update_profile_vpn(
app_handle: tauri::AppHandle,
profile_id: String,
vpn_id: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_vpn(app_handle, &profile_id, vpn_id)
.await
.map_err(|e| format!("Failed to update profile VPN: {e}"))
}
#[tauri::command]
pub fn update_profile_tags(
app_handle: tauri::AppHandle,
@@ -1861,6 +2044,18 @@ pub fn update_profile_note(
.map_err(|e| format!("Failed to update profile note: {e}"))
}
#[tauri::command]
pub fn update_profile_proxy_bypass_rules(
app_handle: tauri::AppHandle,
profile_id: String,
rules: Vec<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_proxy_bypass_rules(&app_handle, &profile_id, rules)
.map_err(|e| format!("Failed to update proxy bypass rules: {e}"))
}
#[tauri::command]
pub async fn check_browser_status(
app_handle: tauri::AppHandle,
@@ -1894,10 +2089,24 @@ pub async fn create_browser_profile_new(
version: String,
release_type: String,
proxy_id: Option<String>,
vpn_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
ephemeral: Option<bool>,
) -> Result<BrowserProfile, 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 browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group(
@@ -1907,9 +2116,11 @@ pub async fn create_browser_profile_new(
version,
release_type,
proxy_id,
vpn_id,
camoufox_config,
wayfern_config,
group_id,
ephemeral.unwrap_or(false),
)
.await
}
@@ -1920,6 +2131,21 @@ pub async fn update_camoufox_config(
profile_id: String,
config: CamoufoxConfig,
) -> Result<(), String> {
if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err("Fingerprint editing requires an active Pro subscription".to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(config.os.as_deref())
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let profile_manager = ProfileManager::instance();
profile_manager
.update_camoufox_config(app_handle, &profile_id, config)
@@ -1933,6 +2159,21 @@ pub async fn update_wayfern_config(
profile_id: String,
config: WayfernConfig,
) -> Result<(), String> {
if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err("Fingerprint editing requires an active Pro subscription".to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(config.os.as_deref())
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let profile_manager = ProfileManager::instance();
profile_manager
.update_wayfern_config(app_handle, &profile_id, config)
@@ -1941,9 +2182,9 @@ pub async fn update_wayfern_config(
}
#[tauri::command]
pub fn clone_profile(profile_id: String) -> Result<BrowserProfile, String> {
pub fn clone_profile(profile_id: String, name: Option<String>) -> Result<BrowserProfile, String> {
ProfileManager::instance()
.clone_profile(&profile_id)
.clone_profile(&profile_id, name)
.map_err(|e| format!("Failed to clone profile: {e}"))
}
+54 -1
View File
@@ -13,6 +13,14 @@ pub enum SyncStatus {
Error,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
pub enum SyncMode {
#[default]
Disabled,
Regular,
Encrypted,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrowserProfile {
pub id: uuid::Uuid,
@@ -22,6 +30,8 @@ pub struct BrowserProfile {
#[serde(default)]
pub proxy_id: Option<String>, // Reference to stored proxy
#[serde(default)]
pub vpn_id: Option<String>, // Reference to stored VPN config
#[serde(default)]
pub process_id: Option<u32>,
#[serde(default)]
pub last_launch: Option<u64>,
@@ -38,18 +48,61 @@ pub struct BrowserProfile {
#[serde(default)]
pub note: Option<String>, // User note
#[serde(default)]
pub sync_enabled: bool, // Whether sync is enabled for this profile
pub sync_mode: SyncMode,
#[serde(default)]
pub encryption_salt: Option<String>,
#[serde(default)]
pub last_sync: Option<u64>, // Timestamp of last successful sync (epoch seconds)
#[serde(default)]
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
#[serde(default)]
pub ephemeral: bool,
#[serde(default)]
pub extension_group_id: Option<String>,
#[serde(default)]
pub proxy_bypass_rules: Vec<String>,
#[serde(default)]
pub created_by_id: Option<String>,
#[serde(default)]
pub created_by_email: Option<String>,
}
pub fn default_release_type() -> String {
"stable".to_string()
}
pub fn get_host_os() -> String {
if cfg!(target_os = "macos") {
"macos".to_string()
} else if cfg!(target_os = "windows") {
"windows".to_string()
} else {
"linux".to_string()
}
}
impl BrowserProfile {
/// Get the path to the profile data directory (profiles/{uuid}/profile)
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
profiles_dir.join(self.id.to_string()).join("profile")
}
/// Returns true when the profile was created on a different OS than the current host.
/// Profiles without an `os` field (backward compat) are treated as native.
pub fn is_cross_os(&self) -> bool {
match &self.host_os {
Some(host_os) => host_os != &get_host_os(),
None => false,
}
}
/// Returns true if sync is enabled (either Regular or Encrypted mode).
pub fn is_sync_enabled(&self) -> bool {
self.sync_mode != SyncMode::Disabled
}
/// Returns true if sync uses E2E encryption.
pub fn is_encrypted_sync(&self) -> bool {
self.sync_mode == SyncMode::Encrypted
}
}
+302 -90
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,234 @@ 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_enabled: false,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: Some(get_host_os()),
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
// Save the profile metadata
self.profile_manager.save_profile(&profile)?;
log::info!(
@@ -569,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);
@@ -583,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,
@@ -616,7 +810,6 @@ impl ProfileImporter {
}
}
// Tauri commands
#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
let importer = ProfileImporter::instance();
@@ -627,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();
}
@@ -650,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)
}
@@ -661,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]
@@ -685,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]
@@ -756,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");
@@ -780,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");
@@ -822,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 -3
View File
@@ -12,13 +12,14 @@ pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None).await
start_proxy_process_with_profile(upstream_url, port, None, Vec::new()).await
}
pub async fn start_proxy_process_with_profile(
upstream_url: Option<String>,
port: Option<u16>,
profile_id: Option<String>,
bypass_rules: Vec<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -30,8 +31,9 @@ pub async fn start_proxy_process_with_profile(
listener.local_addr().unwrap().port()
});
let config =
ProxyConfig::new(id.clone(), upstream, Some(local_port)).with_profile_id(profile_id.clone());
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
.with_profile_id(profile_id.clone())
.with_bypass_rules(bypass_rules);
save_proxy_config(&config)?;
// Log profile_id for debugging
@@ -254,9 +256,12 @@ pub async fn stop_proxy_process(id: &str) -> Result<bool, Box<dyn std::error::Er
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
+75 -18
View File
@@ -6,6 +6,7 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use regex_lite::Regex;
use std::convert::Infallible;
use std::io;
use std::net::SocketAddr;
@@ -18,6 +19,38 @@ use tokio::net::TcpListener;
use tokio::net::TcpStream;
use url::Url;
enum CompiledRule {
Regex(Regex),
Exact(String),
}
#[derive(Clone)]
pub struct BypassMatcher {
rules: Arc<Vec<CompiledRule>>,
}
impl BypassMatcher {
pub fn new(rules: &[String]) -> Self {
let compiled = rules
.iter()
.map(|rule| match Regex::new(rule) {
Ok(re) => CompiledRule::Regex(re),
Err(_) => CompiledRule::Exact(rule.clone()),
})
.collect();
Self {
rules: Arc::new(compiled),
}
}
pub fn should_bypass(&self, host: &str) -> bool {
self.rules.iter().any(|rule| match rule {
CompiledRule::Regex(re) => re.is_match(host),
CompiledRule::Exact(exact) => host == exact,
})
}
}
/// Wrapper stream that counts bytes read and written
struct CountingStream<S> {
inner: S,
@@ -133,19 +166,21 @@ impl AsyncWrite for PrependReader {
async fn handle_request(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Handle CONNECT method for HTTPS tunneling
if req.method() == Method::CONNECT {
return handle_connect(req, upstream_url).await;
return handle_connect(req, upstream_url, bypass_matcher).await;
}
// Handle regular HTTP requests
handle_http(req, upstream_url).await
handle_http(req, upstream_url, bypass_matcher).await
}
async fn handle_connect(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
let authority = req.uri().authority().cloned();
@@ -161,12 +196,13 @@ async fn handle_connect(
(&target_addr[..], 443)
};
// If no upstream proxy, connect directly
// If no upstream proxy, or bypass rule matches, connect directly
if upstream_url.is_none()
|| upstream_url
.as_ref()
.map(|s| s == "DIRECT")
.unwrap_or(false)
|| bypass_matcher.should_bypass(target_host)
{
match TcpStream::connect(&target_addr).await {
Ok(_stream) => {
@@ -674,6 +710,7 @@ async fn handle_http_via_socks4(
async fn handle_http(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Extract domain for traffic tracking
let domain = req
@@ -689,13 +726,17 @@ async fn handle_http(
req.uri().host()
);
let should_bypass = bypass_matcher.should_bypass(&domain);
// Check if we need to handle SOCKS4 manually (reqwest doesn't support it)
if let Some(ref upstream) = upstream_url {
if upstream != "DIRECT" {
if let Ok(url) = Url::parse(upstream) {
if url.scheme() == "socks4" {
// Handle SOCKS4 manually for HTTP requests
return handle_http_via_socks4(req, upstream).await;
if !should_bypass {
if let Some(ref upstream) = upstream_url {
if upstream != "DIRECT" {
if let Ok(url) = Url::parse(upstream) {
if url.scheme() == "socks4" {
// Handle SOCKS4 manually for HTTP requests
return handle_http_via_socks4(req, upstream).await;
}
}
}
}
@@ -705,7 +746,9 @@ async fn handle_http(
use reqwest::Client;
let client_builder = Client::builder();
let client = if let Some(ref upstream) = upstream_url {
let client = if should_bypass {
client_builder.build().unwrap_or_default()
} else if let Some(ref upstream) = upstream_url {
if upstream == "DIRECT" {
client_builder.build().unwrap_or_default()
} else {
@@ -1003,6 +1046,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}
});
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
// Keep the runtime alive with an infinite loop
// This ensures the process doesn't exit even if there are no active connections
loop {
@@ -1014,16 +1059,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
let upstream = upstream_url.clone();
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)
@@ -1108,7 +1156,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
"DEBUG: Handling CONNECT manually for: {}",
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
);
if let Err(e) = handle_connect_from_buffer(stream, full_request, upstream).await {
if let Err(e) =
handle_connect_from_buffer(stream, full_request, upstream, matcher).await
{
log::error!("Error handling CONNECT request: {:?}", e);
} else {
log::error!("DEBUG: CONNECT handled successfully");
@@ -1130,7 +1180,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service = service_fn(move |req| handle_request(req, upstream.clone()));
let service =
service_fn(move |req| handle_request(req, upstream.clone(), matcher.clone()));
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
@@ -1156,6 +1207,7 @@ async fn handle_connect_from_buffer(
mut client_stream: TcpStream,
request_buffer: Vec<u8>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<(), Box<dyn std::error::Error>> {
// Parse the CONNECT request from the buffer
let request_str = String::from_utf8_lossy(&request_buffer);
@@ -1193,6 +1245,7 @@ async fn handle_connect_from_buffer(
}
// Connect to target (directly or via upstream proxy)
let should_bypass = bypass_matcher.should_bypass(target_host);
let target_stream = match upstream_url.as_ref() {
None => {
// Direct connection
@@ -1202,6 +1255,10 @@ async fn handle_connect_from_buffer(
// Direct connection
TcpStream::connect((target_host, target_port)).await?
}
_ if should_bypass => {
// Bypass rule matched - connect directly
TcpStream::connect((target_host, target_port)).await?
}
Some(upstream_url_str) => {
// Connect via upstream proxy
let upstream = Url::parse(upstream_url_str)?;
+65 -13
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
@@ -13,6 +12,8 @@ pub struct ProxyConfig {
pub pid: Option<u32>,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub bypass_rules: Vec<String>,
}
impl ProxyConfig {
@@ -25,6 +26,7 @@ impl ProxyConfig {
local_url: None,
pid: None,
profile_id: None,
bypass_rules: Vec::new(),
}
}
@@ -32,18 +34,15 @@ impl ProxyConfig {
self.profile_id = profile_id;
self
}
pub fn with_bypass_rules(mut self, bypass_rules: Vec<String>) -> Self {
self.bypass_rules = bypass_rules;
self
}
}
pub fn get_storage_dir() -> PathBuf {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("proxies");
path
crate::app_dirs::proxy_workers_dir()
}
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
@@ -132,7 +131,60 @@ pub fn generate_proxy_id() -> String {
}
pub fn is_process_running(pid: u32) -> bool {
use sysinfo::{Pid, System};
let system = System::new();
system.process(Pid::from(pid as usize)).is_some()
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
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"
);
}
}
+81 -84
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
@@ -54,6 +53,10 @@ pub struct AppSettings {
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
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)]
@@ -87,31 +90,17 @@ impl Default for AppSettings {
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
}
}
}
pub struct SettingsManager {
base_dirs: BaseDirs,
data_dir_override: Option<PathBuf>,
}
pub struct SettingsManager;
impl SettingsManager {
fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
.ok()
.map(PathBuf::from),
}
}
#[cfg(test)]
fn with_data_dir_override(dir: &std::path::Path) -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: Some(dir.to_path_buf()),
}
pub(crate) fn new() -> Self {
Self
}
pub fn instance() -> &'static SettingsManager {
@@ -119,18 +108,7 @@ impl SettingsManager {
}
pub fn get_settings_dir(&self) -> PathBuf {
if let Some(dir) = &self.data_dir_override {
return dir.join("settings");
}
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("settings");
path
crate::app_dirs::settings_dir()
}
pub fn get_settings_file(&self) -> PathBuf {
@@ -153,23 +131,10 @@ impl SettingsManager {
// Parse the settings file - serde will use default values for missing fields
match serde_json::from_str::<AppSettings>(&content) {
Ok(settings) => {
// Save the settings back to ensure any missing fields are written with defaults
if let Err(e) = self.save_settings(&settings) {
log::warn!("Warning: Failed to update settings file with defaults: {e}");
}
Ok(settings)
}
Ok(settings) => Ok(settings),
Err(e) => {
log::warn!("Warning: Failed to parse settings file, using defaults: {e}");
let default_settings = AppSettings::default();
// Try to save default settings to fix the corrupted file
if let Err(save_error) = self.save_settings(&default_settings) {
log::warn!("Warning: Failed to save default settings: {save_error}");
}
Ok(default_settings)
Ok(AppSettings::default())
}
}
}
@@ -235,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);
@@ -425,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);
@@ -769,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);
}
}
}
@@ -793,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);
}
}
}
@@ -809,6 +786,15 @@ pub async fn save_app_settings(
settings.mcp_token = None;
}
// Preserve server-managed flags that the frontend may not have up-to-date.
// Read directly from file to avoid load_settings' save-on-load behavior.
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.launch_on_login_declined = current.launch_on_login_declined;
}
}
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
persist_settings.mcp_token = None;
@@ -926,6 +912,27 @@ pub async fn save_sync_settings(
})
}
#[tauri::command]
pub async fn dismiss_window_resize_warning() -> Result<(), String> {
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
settings.window_resize_warning_dismissed = true;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
}
#[tauri::command]
pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
Ok(settings.window_resize_warning_dismissed)
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
@@ -950,16 +957,16 @@ mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_settings_manager() -> (SettingsManager, TempDir) {
fn create_test_settings_manager() -> (SettingsManager, TempDir, crate::app_dirs::TestDirGuard) {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let manager = SettingsManager::with_data_dir_override(temp_dir.path());
(manager, temp_dir)
let guard = crate::app_dirs::set_test_data_dir(temp_dir.path().to_path_buf());
let manager = SettingsManager::new();
(manager, temp_dir, guard)
}
#[test]
fn test_settings_manager_creation() {
let (_manager, _temp_dir) = create_test_settings_manager();
// Test passes if no panic occurs
let (_manager, _temp_dir, _guard) = create_test_settings_manager();
}
#[test]
@@ -992,7 +999,7 @@ mod tests {
#[test]
fn test_load_settings_nonexistent_file() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.load_settings();
assert!(
@@ -1010,7 +1017,7 @@ mod tests {
#[test]
fn test_save_and_load_settings() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let test_settings = AppSettings {
set_as_default_browser: true,
@@ -1027,13 +1034,13 @@ mod tests {
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
};
// Save settings
let save_result = manager.save_settings(&test_settings);
assert!(save_result.is_ok(), "Should save settings successfully");
// Load settings back
let load_result = manager.load_settings();
assert!(load_result.is_ok(), "Should load settings successfully");
@@ -1050,7 +1057,7 @@ mod tests {
#[test]
fn test_load_table_sorting_nonexistent_file() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.load_table_sorting();
assert!(
@@ -1065,18 +1072,16 @@ mod tests {
#[test]
fn test_save_and_load_table_sorting() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let test_sorting = TableSortingSettings {
column: "browser".to_string(),
direction: "desc".to_string(),
};
// Save sorting
let save_result = manager.save_table_sorting(&test_sorting);
assert!(save_result.is_ok(), "Should save sorting successfully");
// Load sorting back
let load_result = manager.load_table_sorting();
assert!(load_result.is_ok(), "Should load sorting successfully");
@@ -1093,45 +1098,37 @@ mod tests {
#[test]
fn test_should_show_launch_on_login_prompt() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.should_show_launch_on_login_prompt();
assert!(result.is_ok(), "Should not fail");
// By default, should show prompt (not declined, autostart not enabled)
let _should_show = result.unwrap();
// Note: The actual value depends on system autostart state, so we just test it doesn't fail
}
#[test]
fn test_decline_launch_on_login() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
// Initially not declined
let settings = manager.load_settings().unwrap();
assert!(!settings.launch_on_login_declined);
// Decline
manager.decline_launch_on_login().unwrap();
// Should be declined now
let settings = manager.load_settings().unwrap();
assert!(settings.launch_on_login_declined);
}
#[test]
fn test_load_corrupted_settings_file() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
// Create settings directory
let settings_dir = manager.get_settings_dir();
fs::create_dir_all(&settings_dir).expect("Should create settings directory");
// Write corrupted JSON
let settings_file = manager.get_settings_file();
fs::write(&settings_file, "{ invalid json }").expect("Should write corrupted file");
// Should handle corrupted file gracefully
let result = manager.load_settings();
assert!(
result.is_ok(),
@@ -1151,7 +1148,7 @@ mod tests {
#[test]
fn test_settings_file_paths() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings_dir = manager.get_settings_dir();
let settings_file = manager.get_settings_file();
+117 -47
View File
@@ -34,7 +34,9 @@ impl SyncClient {
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
response
@@ -62,7 +64,9 @@ impl SyncClient {
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
response
@@ -85,7 +89,9 @@ impl SyncClient {
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
response
@@ -109,7 +115,9 @@ impl SyncClient {
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
response
@@ -119,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"))
@@ -126,14 +142,16 @@ impl SyncClient {
.json(&ListRequest {
prefix: prefix.to_string(),
max_keys: Some(1000),
continuation_token: None,
continuation_token,
})
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
response
@@ -142,13 +160,38 @@ 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,
data: &[u8],
content_type: Option<&str>,
) -> SyncResult<()> {
let mut req = self.client.put(presigned_url).body(data.to_vec());
let mut req = self
.client
.put(presigned_url)
.header("Content-Length", data.len().to_string())
.body(data.to_vec());
if let Some(ct) = content_type {
req = req.header("Content-Type", ct);
@@ -196,59 +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() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
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() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
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(
@@ -270,7 +338,9 @@ impl SyncClient {
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
return Err(SyncError::AuthError("Invalid or missing token".to_string()));
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
response
+351
View File
@@ -0,0 +1,351 @@
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
const E2E_FILE_VERSION: u8 = 1;
fn get_e2e_password_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("e2e_password.dat")
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
pub fn store_e2e_password(password: &str) -> Result<(), String> {
let file_path = get_e2e_password_path();
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
let vault_password = get_vault_password();
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, password.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(E2E_FILE_HEADER);
file_data.push(E2E_FILE_VERSION);
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
file_data.extend_from_slice(&nonce);
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(&file_path, file_data)
.map_err(|e| format!("Failed to write e2e password file: {e}"))?;
Ok(())
}
pub fn load_e2e_password() -> Result<Option<String>, String> {
let file_path = get_e2e_password_path();
if !file_path.exists() {
return Ok(None);
}
let file_data =
std::fs::read(&file_path).map_err(|e| format!("Failed to read e2e password file: {e}"))?;
if file_data.len() < E2E_FILE_HEADER.len() + 1 {
return Ok(None);
}
if &file_data[..E2E_FILE_HEADER.len()] != E2E_FILE_HEADER {
return Ok(None);
}
let version = file_data[E2E_FILE_HEADER.len()];
if version != E2E_FILE_VERSION {
return Ok(None);
}
let mut offset = E2E_FILE_HEADER.len() + 1;
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_str = std::str::from_utf8(&file_data[offset..offset + salt_len])
.map_err(|_| "Invalid salt encoding")?;
offset += salt_len;
let salt = SaltString::from_b64(salt_str).map_err(|e| format!("Invalid salt: {e}"))?;
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce")?;
let nonce = aes_gcm::Nonce::from(nonce_bytes);
offset += 12;
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len =
u32::from_le_bytes(file_data[offset..offset + 4].try_into().unwrap()) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))?;
let password =
String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in password: {e}"))?;
Ok(Some(password))
}
pub fn has_e2e_password() -> bool {
get_e2e_password_path().exists()
}
pub fn remove_e2e_password() -> Result<(), String> {
let file_path = get_e2e_password_path();
if file_path.exists() {
std::fs::remove_file(&file_path)
.map_err(|e| format!("Failed to remove e2e password file: {e}"))?;
}
Ok(())
}
/// Derive a per-profile encryption key using Argon2id
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
let salt_bytes = BASE64
.decode(profile_salt)
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
let salt = SaltString::encode_b64(&salt_bytes)
.map_err(|e| format!("Failed to create salt string: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(user_password.as_bytes(), &salt)
.map_err(|e| format!("Key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes[..32]);
Ok(key)
}
/// Generate a random 16-byte salt, base64-encoded
pub fn generate_salt() -> String {
let mut salt = [0u8; 16];
use aes_gcm::aead::rand_core::RngCore;
OsRng.fill_bytes(&mut salt);
BASE64.encode(salt)
}
/// Encrypt bytes with AES-256-GCM. Output format: [nonce 12B][ciphertext]
pub fn encrypt_bytes(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> {
let aes_key = Key::<Aes256Gcm>::from(*key);
let cipher = Aes256Gcm::new(&aes_key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut output = Vec::with_capacity(12 + ciphertext.len());
output.extend_from_slice(&nonce);
output.extend_from_slice(&ciphertext);
Ok(output)
}
/// Decrypt bytes encrypted with encrypt_bytes. Input format: [nonce 12B][ciphertext]
pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String> {
if encrypted.len() < 12 {
return Err("Encrypted data too short".to_string());
}
let nonce_bytes: [u8; 12] = encrypted[..12].try_into().map_err(|_| "Invalid nonce")?;
let nonce = aes_gcm::Nonce::from(nonce_bytes);
let ciphertext = &encrypted[12..];
let aes_key = Key::<Aes256Gcm>::from(*key);
let cipher = Aes256Gcm::new(&aes_key);
cipher
.decrypt(&nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))
}
// Tauri commands
#[tauri::command]
pub fn set_e2e_password(password: String) -> Result<(), String> {
if password.len() < 8 {
return Err("Password must be at least 8 characters".to_string());
}
store_e2e_password(&password)
}
#[tauri::command]
pub fn check_has_e2e_password() -> bool {
has_e2e_password()
}
#[tauri::command]
pub fn delete_e2e_password() -> Result<(), String> {
remove_e2e_password()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = [42u8; 32];
let plaintext = b"Hello, World!";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_decrypt_empty_data() {
let key = [1u8; 32];
let plaintext = b"";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext.to_vec());
}
#[test]
fn test_encrypt_decrypt_large_data() {
let key = [7u8; 32];
let plaintext = vec![0xABu8; 1_048_576]; // 1MB
let encrypted = encrypt_bytes(&key, &plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_different_keys_different_ciphertext() {
let key1 = [1u8; 32];
let key2 = [2u8; 32];
let plaintext = b"same data";
let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap();
// Nonces are random so ciphertexts will differ regardless,
// but decrypting with wrong key should fail
assert!(decrypt_bytes(&key2, &encrypted1).is_err());
assert!(decrypt_bytes(&key1, &encrypted2).is_err());
}
#[test]
fn test_nonce_uniqueness() {
let key = [5u8; 32];
let plaintext = b"same data encrypted twice";
let encrypted1 = encrypt_bytes(&key, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key, plaintext).unwrap();
// Different nonces should produce different ciphertext
assert_ne!(encrypted1, encrypted2);
// But both should decrypt to the same plaintext
assert_eq!(
decrypt_bytes(&key, &encrypted1).unwrap(),
decrypt_bytes(&key, &encrypted2).unwrap()
);
}
#[test]
fn test_wrong_key_fails() {
let key = [10u8; 32];
let wrong_key = [20u8; 32];
let plaintext = b"secret data";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
assert!(decrypt_bytes(&wrong_key, &encrypted).is_err());
}
#[test]
fn test_key_derivation_deterministic() {
let salt = generate_salt();
let key1 = derive_profile_key("my_password", &salt).unwrap();
let key2 = derive_profile_key("my_password", &salt).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn test_key_derivation_different_salts() {
let salt1 = generate_salt();
let salt2 = generate_salt();
let key1 = derive_profile_key("my_password", &salt1).unwrap();
let key2 = derive_profile_key("my_password", &salt2).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn test_salt_generation_unique() {
let salt1 = generate_salt();
let salt2 = generate_salt();
assert_ne!(salt1, salt2);
}
#[test]
fn test_password_storage_roundtrip() {
let password = "test_password_12345";
store_e2e_password(password).unwrap();
assert!(has_e2e_password());
let loaded = load_e2e_password().unwrap();
assert_eq!(loaded, Some(password.to_string()));
remove_e2e_password().unwrap();
assert!(!has_e2e_password());
}
#[test]
fn test_decrypt_too_short_data() {
let key = [1u8; 32];
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
}
}
+2369 -153
View File
File diff suppressed because it is too large Load Diff
+178 -14
View File
@@ -9,23 +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",
"**/LOG",
"**/LOG.old",
"**/LOCK",
"**/*-journal",
"**/*-wal",
".donut-sync/**",
];
@@ -51,6 +72,8 @@ pub struct SyncManifest {
#[serde(rename = "excludeGlobs")]
pub exclude_globs: Vec<String>,
pub files: Vec<ManifestFileEntry>,
#[serde(default)]
pub encrypted: bool,
}
impl SyncManifest {
@@ -63,6 +86,7 @@ impl SyncManifest {
updated_at: now,
exclude_globs,
files: Vec::new(),
encrypted: false,
}
}
@@ -384,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();
@@ -524,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 {
@@ -546,6 +643,7 @@ mod tests {
hash: "def".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, None);
@@ -587,6 +685,7 @@ mod tests {
hash: "new".to_string(),
},
],
encrypted: false,
};
let remote = SyncManifest {
@@ -615,6 +714,7 @@ mod tests {
hash: "gone".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, Some(&remote));
@@ -633,4 +733,68 @@ mod tests {
.files_to_delete_remote
.contains(&"deleted.txt".to_string()));
}
#[test]
fn test_manifest_encrypted_flag_default() {
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[]}"#;
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
assert!(!manifest.encrypted);
}
#[test]
fn test_manifest_with_encrypted_flag() {
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[],"encrypted":true}"#;
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
assert!(manifest.encrypted);
let serialized = serde_json::to_string(&manifest).unwrap();
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());
}
}
+8 -4
View File
@@ -1,4 +1,5 @@
mod client;
pub mod encryption;
mod engine;
pub mod manifest;
pub mod scheduler;
@@ -6,12 +7,15 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use engine::{
enable_group_sync_if_needed, enable_proxy_sync_if_needed, is_group_in_use_by_synced_profile,
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities,
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_proxy_used_by_synced_profile, request_profile_sync, set_group_sync_enabled,
set_profile_sync_enabled, set_proxy_sync_enabled, sync_profile, trigger_sync_for_profile,
SyncEngine,
is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile,
is_vpn_used_by_synced_profile, request_profile_sync, set_extension_group_sync_enabled,
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
};
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
+405 -130
View File
@@ -34,6 +34,9 @@ pub struct SyncScheduler {
pending_profiles: Arc<Mutex<HashMap<String, ProfileStopTime>>>,
pending_proxies: Arc<Mutex<HashSet<String>>>,
pending_groups: Arc<Mutex<HashSet<String>>>,
pending_vpns: Arc<Mutex<HashSet<String>>>,
pending_extensions: Arc<Mutex<HashSet<String>>>,
pending_extension_groups: Arc<Mutex<HashSet<String>>>,
pending_tombstones: Arc<Mutex<Vec<(String, String)>>>,
running_profiles: Arc<Mutex<HashSet<String>>>,
in_flight_profiles: Arc<Mutex<HashSet<String>>>,
@@ -52,6 +55,9 @@ impl SyncScheduler {
pending_profiles: Arc::new(Mutex::new(HashMap::new())),
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
pending_groups: Arc::new(Mutex::new(HashSet::new())),
pending_vpns: Arc::new(Mutex::new(HashSet::new())),
pending_extensions: Arc::new(Mutex::new(HashSet::new())),
pending_extension_groups: Arc::new(Mutex::new(HashSet::new())),
pending_tombstones: Arc::new(Mutex::new(Vec::new())),
running_profiles: Arc::new(Mutex::new(HashSet::new())),
in_flight_profiles: Arc::new(Mutex::new(HashSet::new())),
@@ -92,6 +98,24 @@ impl SyncScheduler {
}
drop(pending_groups);
let pending_vpns = self.pending_vpns.lock().await;
if !pending_vpns.is_empty() {
return true;
}
drop(pending_vpns);
let pending_extensions = self.pending_extensions.lock().await;
if !pending_extensions.is_empty() {
return true;
}
drop(pending_extensions);
let pending_extension_groups = self.pending_extension_groups.lock().await;
if !pending_extension_groups.is_empty() {
return true;
}
drop(pending_extension_groups);
let pending_tombstones = self.pending_tombstones.lock().await;
if !pending_tombstones.is_empty() {
return true;
@@ -140,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
}
@@ -190,11 +228,26 @@ impl SyncScheduler {
pending.insert(proxy_id);
}
pub async fn queue_vpn_sync(&self, vpn_id: String) {
let mut pending = self.pending_vpns.lock().await;
pending.insert(vpn_id);
}
pub async fn queue_group_sync(&self, group_id: String) {
let mut pending = self.pending_groups.lock().await;
pending.insert(group_id);
}
pub async fn queue_extension_sync(&self, extension_id: String) {
let mut pending = self.pending_extensions.lock().await;
pending.insert(extension_id);
}
pub async fn queue_extension_group_sync(&self, extension_group_id: String) {
let mut pending = self.pending_extension_groups.lock().await;
pending.insert(extension_group_id);
}
pub async fn queue_tombstone(&self, entity_type: String, entity_id: String) {
let mut pending = self.pending_tombstones.lock().await;
if !pending
@@ -219,7 +272,10 @@ impl SyncScheduler {
}
};
let sync_enabled_profiles: Vec<_> = profiles.into_iter().filter(|p| p.sync_enabled).collect();
let sync_enabled_profiles: Vec<_> = profiles
.into_iter()
.filter(|p| p.is_sync_enabled() && !p.is_cross_os())
.collect();
if sync_enabled_profiles.is_empty() {
log::debug!("No sync-enabled profiles found");
@@ -234,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;
}
}
@@ -269,6 +346,9 @@ impl SyncScheduler {
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
SyncWorkItem::Tombstone(entity_type, entity_id) => {
scheduler.queue_tombstone(entity_type, entity_id).await
}
@@ -288,6 +368,9 @@ impl SyncScheduler {
self.process_pending_profiles(app_handle).await;
self.process_pending_proxies(app_handle).await;
self.process_pending_groups(app_handle).await;
self.process_pending_vpns(app_handle).await;
self.process_pending_extensions(app_handle).await;
self.process_pending_extension_groups(app_handle).await;
self.process_pending_tombstones(app_handle).await;
}
@@ -313,94 +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.sync_enabled)
})
};
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()
};
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");
}
}
}
@@ -446,7 +547,8 @@ impl SyncScheduler {
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
@@ -512,7 +614,8 @@ impl SyncScheduler {
"group-sync-status",
serde_json::json!({
"id": group_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
@@ -537,7 +640,146 @@ impl SyncScheduler {
}
}
async fn process_pending_tombstones(&self, app_handle: &tauri::AppHandle) {
async fn process_pending_vpns(&self, app_handle: &tauri::AppHandle) {
let vpns_to_sync: Vec<String> = {
let mut pending = self.pending_vpns.lock().await;
let list: Vec<String> = pending.drain().collect();
list
};
if vpns_to_sync.is_empty() {
return;
}
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
for vpn_id in vpns_to_sync {
log::info!("Syncing VPN {}", vpn_id);
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "syncing"
}),
);
match engine.sync_vpn_by_id_with_handle(&vpn_id, app_handle).await {
Ok(()) => {
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync VPN {}: {}", vpn_id, e);
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "error",
"error": e.to_string()
}),
);
}
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after VPN sync, 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");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
}
}
async fn process_pending_extensions(&self, app_handle: &tauri::AppHandle) {
let extensions_to_sync: Vec<String> = {
let mut pending = self.pending_extensions.lock().await;
let list: Vec<String> = pending.drain().collect();
list
};
if extensions_to_sync.is_empty() {
return;
}
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
for ext_id in extensions_to_sync {
log::info!("Syncing extension {}", ext_id);
if let Err(e) = engine
.sync_extension_by_id_with_handle(&ext_id, app_handle)
.await
{
log::error!("Failed to sync extension {}: {}", ext_id, e);
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after extension sync, 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}");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
}
}
async fn process_pending_extension_groups(&self, app_handle: &tauri::AppHandle) {
let groups_to_sync: Vec<String> = {
let mut pending = self.pending_extension_groups.lock().await;
let list: Vec<String> = pending.drain().collect();
list
};
if groups_to_sync.is_empty() {
return;
}
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
for group_id in groups_to_sync {
log::info!("Syncing extension group {}", group_id);
if let Err(e) = engine
.sync_extension_group_by_id_with_handle(&group_id, app_handle)
.await
{
log::error!("Failed to sync extension group {}: {}", group_id, e);
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after extension group sync, 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}");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
}
}
async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) {
let tombstones: Vec<(String, String)> = {
let mut pending = self.pending_tombstones.lock().await;
std::mem::take(&mut *pending)
@@ -551,61 +793,94 @@ impl SyncScheduler {
log::info!("Processing tombstone for {} {}", entity_type, entity_id);
match entity_type.as_str() {
"profile" => {
let exists_locally = {
let profile_manager = ProfileManager::instance();
let profile_manager = ProfileManager::instance();
let profile_to_delete = {
if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid
.as_ref()
.map(|uuid| profiles.iter().any(|p| p.id == *uuid))
.unwrap_or(false)
profile_uuid.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
} else {
false
None
}
};
if exists_locally {
// Profile exists locally but was deleted remotely - delete locally
if let Some(mut profile) = profile_to_delete {
log::info!(
"Profile {} exists locally, deleting due to remote tombstone",
"Profile {} was deleted remotely, disabling sync locally",
entity_id
);
// Note: We don't actually delete here to avoid data loss.
// The user should be notified or we could add a confirmation step.
// For now, just log it.
} else {
// Profile doesn't exist locally - check if it still exists remotely
// (tombstone might have been created but profile files still exist)
// Try to download it
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
if let Ok(true) = engine
.download_profile_if_missing(app_handle, &entity_id)
.await
{
log::info!(
"Downloaded missing profile {} from remote storage",
entity_id
);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping profile download: {}", e);
}
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
if let Err(e) = profile_manager.save_profile(&profile) {
log::warn!("Failed to disable sync for profile {}: {}", entity_id, e);
} else {
log::info!(
"Profile {} sync disabled due to remote tombstone (local copy kept)",
entity_id
);
let _ = events::emit("profiles-changed", ());
}
}
}
"proxy" => {
log::debug!(
"Proxy tombstone for {} - local deletion not implemented",
entity_id
);
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxies = proxy_manager.get_stored_proxies();
if let Some(proxy) = proxies.iter().find(|p| p.id == entity_id) {
if proxy.sync_enabled {
log::info!("Proxy {} was deleted remotely, deleting locally", entity_id);
let proxy_file = proxy_manager.get_proxy_file_path(&entity_id);
if proxy_file.exists() {
let _ = std::fs::remove_file(&proxy_file);
}
proxy_manager.remove_from_memory(&entity_id);
let _ = events::emit("stored-proxies-changed", ());
}
}
}
"group" => {
log::debug!(
"Group tombstone for {} - local deletion not implemented",
entity_id
);
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
let groups = group_manager.get_all_groups().unwrap_or_default();
if let Some(group) = groups.iter().find(|g| g.id == entity_id) {
if group.sync_enabled {
log::info!("Group {} was deleted remotely, deleting locally", entity_id);
let _ = group_manager.delete_group_internal(&entity_id);
let _ = events::emit("groups-changed", ());
}
}
}
"vpn" => {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
if let Ok(vpn) = storage.load_config(&entity_id) {
if vpn.sync_enabled {
log::info!("VPN {} was deleted remotely, deleting locally", entity_id);
let _ = storage.delete_config(&entity_id);
let _ = events::emit("vpn-configs-changed", ());
}
}
}
"extension" => {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
if let Ok(ext) = manager.get_extension(&entity_id) {
if ext.sync_enabled {
log::info!(
"Extension {} was deleted remotely, deleting locally",
entity_id
);
let _ = manager.delete_extension_internal(&entity_id);
let _ = events::emit("extensions-changed", ());
}
}
}
"extension_group" => {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
if let Ok(group) = manager.get_group(&entity_id) {
if group.sync_enabled {
log::info!(
"Extension group {} was deleted remotely, deleting locally",
entity_id
);
let _ = manager.delete_group_internal(&entity_id);
let _ = events::emit("extensions-changed", ());
}
}
}
_ => {}
}
+49 -1
View File
@@ -23,6 +23,9 @@ pub enum SyncWorkItem {
Profile(String),
Proxy(String),
Group(String),
Vpn(String),
Extension(String),
ExtensionGroup(String),
Tombstone(String, String),
}
@@ -205,8 +208,21 @@ impl SyncSubscription {
data_line.and_then(|data| serde_json::from_str(data).ok())
}
fn strip_team_prefix(key: &str) -> &str {
if key.starts_with("teams/") {
if let Some(rest) = key.find('/').and_then(|first_slash| {
key[first_slash + 1..]
.find('/')
.map(|second_slash| first_slash + 1 + second_slash + 1)
}) {
return &key[rest..];
}
}
key
}
fn handle_event(event: &SubscribeEvent, work_tx: &mpsc::UnboundedSender<SyncWorkItem>) {
let Some(key) = &event.key else {
let Some(raw_key) = &event.key else {
return;
};
@@ -214,6 +230,8 @@ impl SyncSubscription {
return;
}
let key = Self::strip_team_prefix(raw_key);
let work_item = if key.starts_with("profiles/") {
key
.strip_prefix("profiles/")
@@ -229,6 +247,21 @@ impl SyncSubscription {
.strip_prefix("groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::Group(s.to_string()))
} else if key.starts_with("vpns/") {
key
.strip_prefix("vpns/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::Vpn(s.to_string()))
} else if key.starts_with("extensions/") {
key
.strip_prefix("extensions/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::Extension(s.to_string()))
} else if key.starts_with("extension_groups/") {
key
.strip_prefix("extension_groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::ExtensionGroup(s.to_string()))
} else if key.starts_with("tombstones/") {
key.strip_prefix("tombstones/").and_then(|rest| {
if rest.starts_with("profiles/") {
@@ -246,6 +279,21 @@ impl SyncSubscription {
.strip_prefix("groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("group".to_string(), id.to_string()))
} else if rest.starts_with("vpns/") {
rest
.strip_prefix("vpns/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("vpn".to_string(), id.to_string()))
} else if rest.starts_with("extensions/") {
rest
.strip_prefix("extensions/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("extension".to_string(), id.to_string()))
} else if rest.starts_with("extension_groups/") {
rest
.strip_prefix("extension_groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("extension_group".to_string(), id.to_string()))
} else {
None
}
File diff suppressed because it is too large Load Diff
+4 -38
View File
@@ -1,56 +1,22 @@
use crate::profile::BrowserProfile;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct TagsData {
tags: Vec<String>,
}
pub struct TagManager {
base_dirs: BaseDirs,
data_dir_override: Option<PathBuf>,
}
pub struct TagManager;
impl TagManager {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
.ok()
.map(PathBuf::from),
}
Self
}
// Helper for tests to override data directory without global env var
#[allow(dead_code)]
pub fn with_data_dir_override(dir: &Path) -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: Some(dir.to_path_buf()),
}
}
fn get_tags_file_path(&self) -> PathBuf {
if let Some(dir) = &self.data_dir_override {
let mut override_path = dir.clone();
let _ = fs::create_dir_all(&override_path);
override_path.push("tags.json");
return override_path;
}
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("data");
path.push("tags.json");
path
fn get_tags_file_path(&self) -> std::path::PathBuf {
crate::app_dirs::data_subdir().join("tags.json")
}
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
+335
View File
@@ -0,0 +1,335 @@
use lazy_static::lazy_static;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
use crate::cloud_auth::{CloudAuthManager, CLOUD_API_URL, CLOUD_AUTH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileLockInfo {
#[serde(rename = "profileId")]
pub profile_id: String,
#[serde(rename = "lockedBy")]
pub locked_by: String,
#[serde(rename = "lockedByEmail")]
pub locked_by_email: String,
#[serde(rename = "lockedAt")]
pub locked_at: String,
#[serde(rename = "expiresAt", default)]
pub expires_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct AcquireLockResponse {
success: bool,
#[serde(rename = "lockedBy")]
locked_by: Option<String>,
#[serde(rename = "lockedByEmail")]
locked_by_email: Option<String>,
}
pub struct TeamLockManager {
locks: RwLock<HashMap<String, ProfileLockInfo>>,
heartbeat_handle: Mutex<Option<JoinHandle<()>>>,
connected_team_id: Mutex<Option<String>>,
}
lazy_static! {
pub static ref TEAM_LOCK: TeamLockManager = TeamLockManager::new();
}
impl TeamLockManager {
fn new() -> Self {
Self {
locks: RwLock::new(HashMap::new()),
heartbeat_handle: Mutex::new(None),
connected_team_id: Mutex::new(None),
}
}
pub async fn connect(&self, team_id: &str) {
log::info!("Connecting team lock manager for team: {team_id}");
{
let mut tid = self.connected_team_id.lock().await;
*tid = Some(team_id.to_string());
}
if let Err(e) = self.fetch_initial_locks(team_id).await {
log::warn!("Failed to fetch initial locks: {e}");
}
self.start_heartbeat_loop().await;
}
pub async fn disconnect(&self) {
log::info!("Disconnecting team lock manager");
{
let mut handle = self.heartbeat_handle.lock().await;
if let Some(h) = handle.take() {
h.abort();
}
}
{
let mut locks = self.locks.write().await;
locks.clear();
}
{
let mut tid = self.connected_team_id.lock().await;
*tid = None;
}
}
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
let team_id = self.get_team_id().await?;
let client = Client::new();
let access_token =
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.json(&serde_json::json!({ "profileId": profile_id }))
.send()
.await
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Lock acquisition failed ({status}): {body}"));
}
let result: AcquireLockResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse lock response: {e}"))?;
if !result.success {
let email = result
.locked_by_email
.unwrap_or_else(|| "another user".to_string());
return Err(format!("Profile is in use by {email}"));
}
// Update local cache
if let Some(user) = CLOUD_AUTH.get_user().await {
let mut locks = self.locks.write().await;
locks.insert(
profile_id.to_string(),
ProfileLockInfo {
profile_id: profile_id.to_string(),
locked_by: user.user.id.clone(),
locked_by_email: user.user.email.clone(),
locked_at: chrono::Utc::now().to_rfc3339(),
expires_at: None,
},
);
}
let _ = crate::events::emit(
"team-lock-acquired",
serde_json::json!({ "profileId": profile_id }),
);
Ok(())
}
pub async fn release_lock(&self, profile_id: &str) -> Result<(), String> {
let team_id = self.get_team_id().await?;
let client = Client::new();
let access_token =
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}");
let _ = client
.delete(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await;
{
let mut locks = self.locks.write().await;
locks.remove(profile_id);
}
let _ = crate::events::emit(
"team-lock-released",
serde_json::json!({ "profileId": profile_id }),
);
Ok(())
}
pub async fn get_locks(&self) -> Vec<ProfileLockInfo> {
let locks = self.locks.read().await;
locks.values().cloned().collect()
}
pub async fn get_lock_status(&self, profile_id: &str) -> Option<ProfileLockInfo> {
let locks = self.locks.read().await;
locks.get(profile_id).cloned()
}
pub async fn is_locked_by_another(&self, profile_id: &str) -> bool {
let locks = self.locks.read().await;
if let Some(lock) = locks.get(profile_id) {
if let Some(user) = CLOUD_AUTH.get_user().await {
return lock.locked_by != user.user.id;
}
}
false
}
async fn fetch_initial_locks(&self, team_id: &str) -> Result<(), String> {
let client = Client::new();
let access_token =
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch locks: {e}"))?;
if !response.status().is_success() {
return Err("Failed to fetch locks".to_string());
}
let lock_list: Vec<ProfileLockInfo> = response
.json()
.await
.map_err(|e| format!("Failed to parse locks: {e}"))?;
let mut locks = self.locks.write().await;
locks.clear();
for lock in lock_list {
locks.insert(lock.profile_id.clone(), lock);
}
Ok(())
}
async fn start_heartbeat_loop(&self) {
let mut handle = self.heartbeat_handle.lock().await;
if let Some(h) = handle.take() {
h.abort();
}
let h = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
let team_id = match TEAM_LOCK.get_team_id().await {
Ok(id) => id,
Err(_) => break,
};
let held_locks: Vec<String> = {
let locks = TEAM_LOCK.locks.read().await;
if let Some(user) = CLOUD_AUTH.get_user().await {
locks
.values()
.filter(|l| l.locked_by == user.user.id)
.map(|l| l.profile_id.clone())
.collect()
} else {
vec![]
}
};
for profile_id in held_locks {
let client = Client::new();
if let Ok(Some(token)) = CloudAuthManager::load_access_token() {
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}/heartbeat");
let _ = client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await;
}
}
// Refresh lock state from server
if let Err(e) = TEAM_LOCK.fetch_initial_locks(&team_id).await {
log::debug!("Failed to refresh locks: {e}");
}
}
});
*handle = Some(h);
}
async fn get_team_id(&self) -> Result<String, String> {
let tid = self.connected_team_id.lock().await;
tid
.clone()
.ok_or_else(|| "Not connected to a team".to_string())
}
}
/// Acquire team lock if profile is sync-enabled and user is on a team.
/// Returns Ok(()) if lock acquired or not applicable, Err with message if locked by another.
pub async fn acquire_team_lock_if_needed(
profile: &crate::profile::BrowserProfile,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
return Ok(());
}
if !CLOUD_AUTH.is_on_team_plan().await {
return Ok(());
}
if TEAM_LOCK
.is_locked_by_another(&profile.id.to_string())
.await
{
if let Some(lock) = TEAM_LOCK.get_lock_status(&profile.id.to_string()).await {
return Err(format!("Profile is in use by {}", lock.locked_by_email));
}
return Err("Profile is in use by another team member".to_string());
}
TEAM_LOCK.acquire_lock(&profile.id.to_string()).await
}
/// Release team lock if profile is sync-enabled and user is on a team.
/// Logs warnings on failure but does not return errors.
pub async fn release_team_lock_if_needed(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
if !CLOUD_AUTH.is_on_team_plan().await {
return;
}
if let Err(e) = TEAM_LOCK.release_lock(&profile.id.to_string()).await {
log::warn!(
"Failed to release team lock for profile {}: {e}",
profile.id
);
}
}
// --- Tauri commands ---
#[tauri::command]
pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
Ok(TEAM_LOCK.get_locks().await)
}
#[tauri::command]
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
Ok(TEAM_LOCK.get_lock_status(&profile_id).await)
}
+1 -11
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@@ -328,17 +327,8 @@ fn acquire_file_lock(lock_path: &PathBuf) -> Result<FileLockGuard, Box<dyn std::
Ok(FileLockGuard { _file: file })
}
/// Get the traffic stats storage directory
pub fn get_traffic_stats_dir() -> PathBuf {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let mut path = base_dirs.cache_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("traffic_stats");
path
crate::app_dirs::cache_dir().join("traffic_stats")
}
/// Get the storage key for traffic stats (profile_id if available, otherwise proxy_id)
+11 -28
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
@@ -67,13 +66,7 @@ impl VersionUpdater {
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let app_name = if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
};
let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache");
let cache_dir = crate::app_dirs::cache_dir().join("version_cache");
fs::create_dir_all(&cache_dir)?;
Ok(cache_dir)
}
@@ -150,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...");
@@ -369,20 +357,15 @@ impl VersionUpdater {
eprintln!("Failed to emit completion progress: {e}");
}
// After all version updates are complete, trigger auto-update check
if total_new_versions > 0 {
println!(
"Found {total_new_versions} new versions across all browsers. Checking for auto-updates..."
);
// Trigger auto-update check which will automatically download browsers
self
.auto_updater
.check_for_updates_with_progress(app_handle)
.await;
} else {
println!("No new versions found, skipping auto-update check");
}
// Always check for auto-updates — profiles may still be on older versions
// even if no new versions were found in the cache this cycle
println!(
"Checking for browser auto-updates (found {total_new_versions} new versions in cache)..."
);
self
.auto_updater
.check_for_updates_with_progress(app_handle)
.await;
Ok(results)
}
+4
View File
@@ -52,6 +52,10 @@ pub struct VpnConfig {
pub config_data: String, // Raw config content (encrypted at rest)
pub created_at: i64,
pub last_used: Option<i64>,
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
}
/// Parsed WireGuard configuration
+2 -4
View File
@@ -7,6 +7,8 @@
mod config;
mod openvpn;
pub mod openvpn_socks5;
pub mod socks5_server;
mod storage;
mod tunnel;
mod wireguard;
@@ -25,7 +27,3 @@ use std::sync::Mutex;
/// Global VPN storage instance
pub static VPN_STORAGE: Lazy<Mutex<VpnStorage>> = Lazy::new(|| Mutex::new(VpnStorage::new()));
/// Global tunnel manager instance
pub static TUNNEL_MANAGER: Lazy<tokio::sync::Mutex<TunnelManager>> =
Lazy::new(|| tokio::sync::Mutex::new(TunnelManager::new()));
+7 -1
View File
@@ -73,7 +73,13 @@ impl OpenVpnTunnel {
#[cfg(windows)]
{
if let Ok(output) = Command::new("where").arg("openvpn").output() {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("openvpn")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
+239
View File
@@ -0,0 +1,239 @@
use super::config::{OpenVpnConfig, VpnError};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
pub struct OpenVpnSocks5Server {
config: OpenVpnConfig,
port: u16,
}
impl OpenVpnSocks5Server {
pub fn new(config: OpenVpnConfig, port: u16) -> Self {
Self { config, port }
}
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
let locations = [
"/usr/sbin/openvpn",
"/usr/local/sbin/openvpn",
"/opt/homebrew/bin/openvpn",
"/usr/bin/openvpn",
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
];
for loc in &locations {
let path = PathBuf::from(loc);
if path.exists() {
return Ok(path);
}
}
#[cfg(unix)]
{
if let Ok(output) = Command::new("which").arg("openvpn").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("openvpn")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
}
Err(VpnError::Connection(
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
))
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
let openvpn_bin = Self::find_openvpn_binary()?;
// Write config to temp file
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
std::fs::write(&config_path, &self.config.raw_config).map_err(VpnError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
}
// Find a management port
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
let mgmt_port = mgmt_listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
.port();
drop(mgmt_listener);
// Start OpenVPN with SOCKS proxy mode
let mut cmd = Command::new(&openvpn_bin);
cmd
.arg("--config")
.arg(&config_path)
.arg("--management")
.arg("127.0.0.1")
.arg(mgmt_port.to_string())
.arg("--socks-proxy")
.arg("127.0.0.1")
.arg(self.port.to_string())
.arg("--verb")
.arg("3")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
// Wait for OpenVPN to start
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
match child.try_wait() {
Ok(Some(status)) => {
let _ = std::fs::remove_file(&config_path);
return Err(VpnError::Connection(format!(
"OpenVPN exited early with status: {status}. OpenVPN requires elevated privileges (sudo/admin)."
)));
}
Ok(None) => {}
Err(e) => {
let _ = std::fs::remove_file(&config_path);
return Err(VpnError::Connection(format!(
"Failed to check OpenVPN status: {e}"
)));
}
}
// Start a basic SOCKS5 proxy that tunnels through the OpenVPN TUN interface
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
.await
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
let actual_port = listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
.port();
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
wc.local_port = Some(actual_port);
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
}
log::info!(
"[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}",
actual_port
);
loop {
match listener.accept().await {
Ok((client, _)) => {
tokio::spawn(Self::handle_socks5_client(client));
}
Err(e) => {
log::warn!("[vpn-worker] Accept error: {e}");
}
}
}
}
async fn handle_socks5_client(
mut client: TcpStream,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// SOCKS5 greeting
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await?;
if n < 3 || buf[0] != 0x05 {
return Ok(());
}
client.write_all(&[0x05, 0x00]).await?;
// SOCKS5 connect request
let n = client.read(&mut buf).await?;
if n < 10 || buf[0] != 0x05 || buf[1] != 0x01 {
return Ok(());
}
let dest_addr = match buf[3] {
0x01 => {
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
format!("{}:{}", ip, port)
}
0x03 => {
let domain_len = buf[4] as usize;
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
let port_start = 5 + domain_len;
let port = u16::from_be_bytes([buf[port_start], buf[port_start + 1]]);
format!("{}:{}", domain, port)
}
_ => return Ok(()),
};
// Connect to destination through OpenVPN tunnel (OS routing handles it)
match TcpStream::connect(&dest_addr).await {
Ok(upstream) => {
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
.await?;
let (mut cr, mut cw) = client.into_split();
let (mut ur, mut uw) = upstream.into_split();
let c2u = tokio::io::copy(&mut cr, &mut uw);
let u2c = tokio::io::copy(&mut ur, &mut cw);
let _ = tokio::try_join!(c2u, u2c);
}
Err(_) => {
client
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_openvpn_binary_format() {
let result = OpenVpnSocks5Server::find_openvpn_binary();
match result {
Ok(path) => assert!(!path.as_os_str().is_empty()),
Err(e) => assert!(e.to_string().contains("not found")),
}
}
}
+656
View File
@@ -0,0 +1,656 @@
use super::config::{VpnError, WireGuardConfig};
use boringtun::noise::{Tunn, TunnResult};
use boringtun::x25519::{PublicKey, StaticSecret};
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
use smoltcp::time::Instant as SmolInstant;
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
use std::collections::VecDeque;
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use std::sync::{Arc, Mutex};
use tokio::net::{TcpListener, TcpStream};
const SMOLTCP_TCP_RX_BUF: usize = 65536;
const SMOLTCP_TCP_TX_BUF: usize = 65536;
struct WgDevice {
tunn: Arc<Mutex<Box<Tunn>>>,
udp_socket: Arc<UdpSocket>,
peer_addr: SocketAddr,
rx_queue: VecDeque<Vec<u8>>,
tx_queue: VecDeque<Vec<u8>>,
}
impl WgDevice {
fn pump_wg_to_rx(&mut self) {
let mut recv_buf = vec![0u8; 2048];
loop {
match self.udp_socket.recv_from(&mut recv_buf) {
Ok((len, _)) => {
let mut dst = vec![0u8; 2048];
let mut tunn = self.tunn.lock().unwrap();
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
match result {
TunnResult::WriteToTunnelV4(data, _) | TunnResult::WriteToTunnelV6(data, _) => {
self.rx_queue.push_back(data.to_vec());
}
TunnResult::WriteToNetwork(response) => {
let _ = self.udp_socket.send_to(response, self.peer_addr);
}
_ => {}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
}
fn flush_tx_queue(&mut self) {
while let Some(ip_packet) = self.tx_queue.pop_front() {
let mut dst = vec![0u8; ip_packet.len() + 256];
let mut tunn = self.tunn.lock().unwrap();
let result = tunn.encapsulate(&ip_packet, &mut dst);
if let TunnResult::WriteToNetwork(packet) = result {
let _ = self.udp_socket.send_to(packet, self.peer_addr);
}
}
}
fn tick_timers(&mut self) {
let mut dst = vec![0u8; 2048];
let mut tunn = self.tunn.lock().unwrap();
let result = tunn.update_timers(&mut dst);
if let TunnResult::WriteToNetwork(packet) = result {
let _ = self.udp_socket.send_to(packet, self.peer_addr);
}
}
}
struct WgRxToken {
data: Vec<u8>,
}
impl RxToken for WgRxToken {
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&[u8]) -> R,
{
f(&self.data)
}
}
struct WgTxToken<'a> {
tx_queue: &'a mut VecDeque<Vec<u8>>,
}
impl<'a> TxToken for WgTxToken<'a> {
fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
let mut buf = vec![0u8; len];
let result = f(&mut buf);
self.tx_queue.push_back(buf);
result
}
}
impl Device for WgDevice {
type RxToken<'a> = WgRxToken;
type TxToken<'a> = WgTxToken<'a>;
fn receive(&mut self, _timestamp: SmolInstant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
if let Some(data) = self.rx_queue.pop_front() {
Some((
WgRxToken { data },
WgTxToken {
tx_queue: &mut self.tx_queue,
},
))
} else {
None
}
}
fn transmit(&mut self, _timestamp: SmolInstant) -> Option<Self::TxToken<'_>> {
Some(WgTxToken {
tx_queue: &mut self.tx_queue,
})
}
fn capabilities(&self) -> DeviceCapabilities {
let mut caps = DeviceCapabilities::default();
caps.medium = Medium::Ip;
caps.max_transmission_unit = 1420;
caps
}
}
fn parse_key(key: &str) -> Result<[u8; 32], VpnError> {
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key)
.map_err(|e| VpnError::InvalidWireGuard(format!("Invalid key encoding: {e}")))?;
if decoded.len() != 32 {
return Err(VpnError::InvalidWireGuard(format!(
"Invalid key length: {} (expected 32)",
decoded.len()
)));
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&decoded);
Ok(key_bytes)
}
fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> {
let first_addr = addr.split(',').next().unwrap_or(addr).trim();
let parts: Vec<&str> = first_addr.split('/').collect();
let ip_str = parts[0];
let prefix = if parts.len() > 1 {
parts[1]
.parse::<u8>()
.map_err(|_| VpnError::InvalidWireGuard(format!("Invalid prefix length: {}", parts[1])))?
} else {
32
};
let ip: std::net::IpAddr = ip_str
.parse()
.map_err(|_| VpnError::InvalidWireGuard(format!("Invalid IP address: {ip_str}")))?;
match ip {
std::net::IpAddr::V4(v4) => {
let smol_ip = Ipv4Address::new(
v4.octets()[0],
v4.octets()[1],
v4.octets()[2],
v4.octets()[3],
);
Ok((
IpCidr::new(IpAddress::Ipv4(smol_ip), prefix),
IpAddress::Ipv4(smol_ip),
))
}
std::net::IpAddr::V6(v6) => {
let smol_ip = smoltcp::wire::Ipv6Address::from(v6.octets());
Ok((
IpCidr::new(IpAddress::Ipv6(smol_ip), prefix),
IpAddress::Ipv6(smol_ip),
))
}
}
}
pub struct WireGuardSocks5Server {
config: WireGuardConfig,
port: u16,
}
impl WireGuardSocks5Server {
pub fn new(config: WireGuardConfig, port: u16) -> Self {
Self { config, port }
}
fn create_tunnel(&self) -> Result<Box<Tunn>, VpnError> {
let private_key_bytes = parse_key(&self.config.private_key)?;
let static_private = StaticSecret::from(private_key_bytes);
let peer_public_bytes = parse_key(&self.config.peer_public_key)?;
let peer_public = PublicKey::from(peer_public_bytes);
let preshared_key = if let Some(ref psk) = self.config.preshared_key {
Some(parse_key(psk)?)
} else {
None
};
Ok(Box::new(Tunn::new(
static_private,
peer_public,
preshared_key,
self.config.persistent_keepalive,
0,
None,
)))
}
fn resolve_endpoint(&self) -> Result<SocketAddr, VpnError> {
self
.config
.peer_endpoint
.to_socket_addrs()
.map_err(|e| {
VpnError::Connection(format!(
"Failed to resolve endpoint '{}': {e}",
self.config.peer_endpoint
))
})?
.next()
.ok_or_else(|| {
VpnError::Connection(format!(
"No addresses found for endpoint: {}",
self.config.peer_endpoint
))
})
}
fn do_handshake(
tunn: &mut Tunn,
socket: &UdpSocket,
peer_addr: SocketAddr,
) -> Result<(), VpnError> {
let mut dst = vec![0u8; 2048];
let result = tunn.format_handshake_initiation(&mut dst, false);
match result {
TunnResult::WriteToNetwork(packet) => {
socket
.send_to(packet, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake initiation failed: {e:?}"
)));
}
_ => {}
}
socket
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
let mut recv_buf = vec![0u8; 2048];
match socket.recv_from(&mut recv_buf) {
Ok((len, _)) => {
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
match result {
TunnResult::WriteToNetwork(response) => {
socket
.send_to(response, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
}
TunnResult::Done => {}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake response failed: {e:?}"
)));
}
_ => {}
}
}
Err(e) => {
return Err(VpnError::Connection(format!(
"Handshake timeout or error: {e}"
)));
}
}
socket
.set_read_timeout(None)
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
Ok(())
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
let peer_addr = self.resolve_endpoint()?;
let mut tunn = self.create_tunnel()?;
let udp_socket = UdpSocket::bind("0.0.0.0:0")
.map_err(|e| VpnError::Connection(format!("Failed to create UDP socket: {e}")))?;
Self::do_handshake(&mut tunn, &udp_socket, peer_addr)?;
udp_socket
.set_nonblocking(true)
.map_err(|e| VpnError::Connection(format!("Failed to set non-blocking: {e}")))?;
log::info!("[vpn-worker] WireGuard handshake completed");
let (cidr, local_ip) = parse_cidr_address(&self.config.address)?;
let tunn_arc = Arc::new(Mutex::new(tunn));
let udp_arc = Arc::new(udp_socket);
let mut device = WgDevice {
tunn: tunn_arc.clone(),
udp_socket: udp_arc.clone(),
peer_addr,
rx_queue: VecDeque::new(),
tx_queue: VecDeque::new(),
};
let iface_config = IfaceConfig::new(HardwareAddress::Ip);
let mut iface = Interface::new(iface_config, &mut device, SmolInstant::now());
iface.update_ip_addrs(|addrs| {
let _ = addrs.push(cidr);
});
// Set default gateway
match local_ip {
IpAddress::Ipv4(v4) => {
let octets = v4.octets();
let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1);
iface
.routes_mut()
.add_default_ipv4_route(gw)
.map_err(|e| VpnError::Tunnel(format!("Failed to add default route: {e}")))?;
}
IpAddress::Ipv6(_) => {
// IPv6 routing not yet implemented
}
}
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
.await
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5 listener: {e}")))?;
let actual_port = listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
.port();
// Update config with actual port and local_url
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
wc.local_port = Some(actual_port);
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
}
log::info!(
"[vpn-worker] SOCKS5 server listening on 127.0.0.1:{}",
actual_port
);
let mut sockets = SocketSet::new(vec![]);
struct Connection {
smol_handle: SocketHandle,
tcp_stream: TcpStream,
socks_done: bool,
read_buf: Vec<u8>,
dest_addr: Option<SocketAddr>,
}
let mut connections: Vec<Connection> = Vec::new();
let mut timer_counter: u64 = 0;
loop {
// Accept new SOCKS5 connections (non-blocking via short timeout)
if let Ok(Ok((stream, _addr))) =
tokio::time::timeout(tokio::time::Duration::from_millis(1), listener.accept()).await
{
let tcp_rx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_RX_BUF]);
let tcp_tx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_TX_BUF]);
let tcp_socket = TcpSocket::new(tcp_rx, tcp_tx);
let handle = sockets.add(tcp_socket);
connections.push(Connection {
smol_handle: handle,
tcp_stream: stream,
socks_done: false,
read_buf: Vec::new(),
dest_addr: None,
});
}
// Pump WireGuard packets into smoltcp rx queue
device.pump_wg_to_rx();
// Poll the smoltcp interface
let timestamp = SmolInstant::now();
let _changed = iface.poll(timestamp, &mut device, &mut sockets);
// Flush encrypted packets out through WireGuard
device.flush_tx_queue();
// Process each connection
let mut completed = Vec::new();
for (idx, conn) in connections.iter_mut().enumerate() {
if !conn.socks_done {
// Handle SOCKS5 handshake
let mut buf = [0u8; 512];
match conn.tcp_stream.try_read(&mut buf) {
Ok(0) => {
completed.push(idx);
continue;
}
Ok(n) => {
conn.read_buf.extend_from_slice(&buf[..n]);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(_) => {
completed.push(idx);
continue;
}
}
if conn.dest_addr.is_none() && conn.read_buf.len() >= 3 {
// SOCKS5 greeting: version, nmethods, methods
if conn.read_buf[0] != 0x05 {
completed.push(idx);
continue;
}
// Reply: no auth required
let _ = conn.tcp_stream.try_write(&[0x05, 0x00]);
let nmethods = conn.read_buf[1] as usize;
conn.read_buf.drain(..2 + nmethods);
}
if conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
// SOCKS5 connect request
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
completed.push(idx);
continue;
}
let (addr, addr_len) = match conn.read_buf[3] {
0x01 => {
// IPv4
if conn.read_buf.len() < 10 {
continue;
}
let ip = std::net::Ipv4Addr::new(
conn.read_buf[4],
conn.read_buf[5],
conn.read_buf[6],
conn.read_buf[7],
);
let port = u16::from_be_bytes([conn.read_buf[8], conn.read_buf[9]]);
(SocketAddr::new(std::net::IpAddr::V4(ip), port), 10)
}
0x03 => {
// Domain name
let domain_len = conn.read_buf[4] as usize;
let needed = 4 + 1 + domain_len + 2;
if conn.read_buf.len() < needed {
continue;
}
let domain = String::from_utf8_lossy(&conn.read_buf[5..5 + domain_len]).to_string();
let port_start = 5 + domain_len;
let port =
u16::from_be_bytes([conn.read_buf[port_start], conn.read_buf[port_start + 1]]);
// Resolve domain
match format!("{}:{}", domain, port).to_socket_addrs() {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
(addr, needed)
} else {
// Send SOCKS5 error: host unreachable
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
}
Err(_) => {
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
}
}
0x04 => {
// IPv6
if conn.read_buf.len() < 22 {
continue;
}
let mut octets = [0u8; 16];
octets.copy_from_slice(&conn.read_buf[4..20]);
let ip = std::net::Ipv6Addr::from(octets);
let port = u16::from_be_bytes([conn.read_buf[20], conn.read_buf[21]]);
(SocketAddr::new(std::net::IpAddr::V6(ip), port), 22)
}
_ => {
completed.push(idx);
continue;
}
};
conn.read_buf.drain(..addr_len);
conn.dest_addr = Some(addr);
// Open smoltcp TCP socket to the destination
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
let smol_addr = match addr.ip() {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3]))
}
std::net::IpAddr::V6(v6) => {
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from(v6.octets()))
}
};
let local_port = 10000 + (rand::random::<u16>() % 50000);
if socket
.connect(iface.context(), (smol_addr, addr.port()), local_port)
.is_err()
{
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
// Send SOCKS5 success reply
let _ = conn.tcp_stream.try_write(&[
0x05,
0x00,
0x00,
0x01,
127,
0,
0,
1,
(actual_port >> 8) as u8,
(actual_port & 0xff) as u8,
]);
conn.socks_done = true;
}
} else {
// Data relay between SOCKS5 client and smoltcp socket
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
// Client → smoltcp
let mut buf = [0u8; 4096];
match conn.tcp_stream.try_read(&mut buf) {
Ok(0) => {
socket.close();
completed.push(idx);
continue;
}
Ok(n) => {
if socket.can_send() {
let _ = socket.send_slice(&buf[..n]);
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(_) => {
socket.close();
completed.push(idx);
continue;
}
}
// smoltcp → Client
if socket.can_recv() {
match socket.recv(|data| (data.len(), data.to_vec())) {
Ok(data) if !data.is_empty() => {
if conn.tcp_stream.try_write(&data).is_err() {
socket.close();
completed.push(idx);
continue;
}
}
_ => {}
}
}
// Check if smoltcp socket closed
if !socket.is_open() && !socket.is_active() {
completed.push(idx);
}
}
}
// Remove completed connections (in reverse order)
completed.sort_unstable();
completed.dedup();
for idx in completed.into_iter().rev() {
let conn = connections.remove(idx);
sockets.remove(conn.smol_handle);
}
// Timer ticks for WireGuard keepalives
timer_counter += 1;
if timer_counter.is_multiple_of(500) {
device.tick_timers();
}
// Small sleep to avoid busy-spinning
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_cidr_ipv4() {
let (cidr, ip) = parse_cidr_address("10.0.0.2/24").unwrap();
assert_eq!(cidr.prefix_len(), 24);
assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2)));
}
#[test]
fn test_parse_cidr_no_prefix() {
let (cidr, _) = parse_cidr_address("10.0.0.2").unwrap();
assert_eq!(cidr.prefix_len(), 32);
}
#[test]
fn test_parse_cidr_multi_address() {
let (_, ip) = parse_cidr_address("10.0.0.2/24, fd00::2/128").unwrap();
assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2)));
}
#[test]
fn test_parse_key_valid() {
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
assert!(parse_key(key).is_ok());
}
#[test]
fn test_parse_key_invalid() {
assert!(parse_key("not-valid").is_err());
}
}

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