Compare commits

...

221 Commits

Author SHA1 Message Date
zhom e388e2e85a refactor: don't allow portable build to be set as the default browser 2026-03-29 15:47:53 +04:00
zhom decfdfcfc7 chore: linting 2026-03-29 15:01:26 +04:00
zhom c516999f7a feat: portable build 2026-03-29 14:55:20 +04:00
zhom 1099459dbb fix: wayfern initial connection on macos doesn't timeout 2026-03-29 13:03:17 +04:00
zhom a3514df0d4 refactor: show app version in settings 2026-03-29 13:02:41 +04:00
zhom 0102cb6c06 chore: do not provide possible cause 2026-03-28 23:50:15 +04:00
zhom 612c6610ce chore: linting 2026-03-28 23:31:20 +04:00
zhom ba750a3401 chore: linting 2026-03-28 20:59:00 +04:00
zhom d0e3e15fd3 chore: linting 2026-03-28 20:55:10 +04:00
zhom 248927ae6f chore: linting 2026-03-28 14:05:45 +04:00
zhom 6d71dbc62c Merge pull request #255 from zhom/dependabot/npm_and_yarn/frontend-dependencies-9854c608ec
deps(deps): bump the frontend-dependencies group with 35 updates
2026-03-28 13:53:32 +04:00
dependabot[bot] 3f0029c778 deps(deps): bump the frontend-dependencies group with 35 updates
Bumps the frontend-dependencies group with 35 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `25.10.5` | `26.0.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.577.0` | `1.7.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `16.6.2` | `17.0.0` |
| [recharts](https://github.com/recharts/recharts) | `3.8.0` | `3.8.1` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1015.0` | `3.1019.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1015.0` | `3.1019.0` |
| [@aws-sdk/core](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/core) | `3.973.24` | `3.973.25` |
| [@aws-sdk/credential-provider-env](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-env) | `3.972.22` | `3.972.23` |
| [@aws-sdk/credential-provider-http](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-http) | `3.972.24` | `3.972.25` |
| [@aws-sdk/credential-provider-ini](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-ini) | `3.972.24` | `3.972.26` |
| [@aws-sdk/credential-provider-login](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-login) | `3.972.24` | `3.972.26` |
| [@aws-sdk/credential-provider-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-node) | `3.972.25` | `3.972.27` |
| [@aws-sdk/credential-provider-process](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-process) | `3.972.22` | `3.972.23` |
| [@aws-sdk/credential-provider-sso](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-sso) | `3.972.24` | `3.972.26` |
| [@aws-sdk/credential-provider-web-identity](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-web-identity) | `3.972.24` | `3.972.26` |
| [@aws-sdk/middleware-flexible-checksums](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-flexible-checksums) | `3.974.4` | `3.974.5` |
| [@aws-sdk/middleware-recursion-detection](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-recursion-detection) | `3.972.8` | `3.972.9` |
| [@aws-sdk/middleware-sdk-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-sdk-s3) | `3.972.24` | `3.972.26` |
| [@aws-sdk/middleware-user-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-user-agent) | `3.972.25` | `3.972.26` |
| [@aws-sdk/nested-clients](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/nested-clients) | `3.996.14` | `3.996.16` |
| [@aws-sdk/region-config-resolver](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/region-config-resolver) | `3.972.9` | `3.972.10` |
| [@aws-sdk/signature-v4-multi-region](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-multi-region) | `3.996.12` | `3.996.14` |
| [@aws-sdk/token-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/token-providers) | `3.1015.0` | `3.1019.0` |
| [@aws-sdk/util-user-agent-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-user-agent-node) | `3.973.11` | `3.973.12` |
| [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder) | `3.972.15` | `3.972.16` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |


Updates `i18next` from 25.10.5 to 26.0.0
- [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.10.5...v26.0.0)

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

Updates `react-i18next` from 16.6.2 to 17.0.0
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v16.6.2...v17.0.0)

Updates `recharts` from 3.8.0 to 3.8.1
- [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.8.0...v3.8.1)

Updates `@biomejs/biome` from 2.4.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

Updates `@aws-sdk/client-s3` from 3.1015.0 to 3.1019.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.1019.0/clients/client-s3)

Updates `@aws-sdk/s3-request-presigner` from 3.1015.0 to 3.1019.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.1019.0/packages/s3-request-presigner)

Updates `@aws-sdk/core` from 3.973.24 to 3.973.25
- [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/credential-provider-env` from 3.972.22 to 3.972.23
- [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.24 to 3.972.25
- [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.24 to 3.972.26
- [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.24 to 3.972.26
- [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.25 to 3.972.27
- [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.22 to 3.972.23
- [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.24 to 3.972.26
- [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.24 to 3.972.26
- [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-flexible-checksums` from 3.974.4 to 3.974.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/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-recursion-detection` from 3.972.8 to 3.972.9
- [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.24 to 3.972.26
- [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-user-agent` from 3.972.25 to 3.972.26
- [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.14 to 3.996.16
- [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.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/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.12 to 3.996.14
- [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.1015.0 to 3.1019.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.1019.0/packages/token-providers)

Updates `@aws-sdk/util-user-agent-node` from 3.973.11 to 3.973.12
- [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.15 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/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.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.4.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

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

Updates `@biomejs/cli-linux-arm64` from 2.4.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

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

Updates `@biomejs/cli-linux-x64` from 2.4.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.4.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.4.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: react-i18next
  dependency-version: 17.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: recharts
  dependency-version: 3.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1019.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.1019.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/core"
  dependency-version: 3.973.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-env"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-http"
  dependency-version: 3.972.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-ini"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-login"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-node"
  dependency-version: 3.972.27
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-process"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-sso"
  dependency-version: 3.972.26
  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.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-flexible-checksums"
  dependency-version: 3.974.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-recursion-detection"
  dependency-version: 3.972.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-sdk-s3"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-user-agent"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/nested-clients"
  dependency-version: 3.996.16
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/region-config-resolver"
  dependency-version: 3.972.10
  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.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/token-providers"
  dependency-version: 3.1019.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.973.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.16
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 09:52:43 +00:00
zhom fff1fe7087 Merge pull request #254 from zhom/dependabot/cargo/src-tauri/rust-dependencies-23f0da4b4d
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 13 updates
2026-03-28 13:48:55 +04:00
dependabot[bot] 1c971c664f deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.22.0` | `1.23.0` |
| [sha1](https://github.com/RustCrypto/hashes) | `0.10.6` | `0.11.0` |
| [tao](https://github.com/tauri-apps/tao) | `0.34.8` | `0.35.0` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.57` | `1.2.58` |
| [embed-resource](https://github.com/nabijaczleweli/rust-embed-resource) | `3.0.7` | `3.0.8` |
| libredox | `0.1.14` | `0.1.15` |
| [mio](https://github.com/tokio-rs/mio) | `1.1.1` | `1.2.0` |
| [num-conv](https://github.com/jhpratt/num-conv) | `0.2.0` | `0.2.1` |
| [rust_decimal](https://github.com/paupino/rust-decimal) | `1.40.0` | `1.41.0` |
| [simd-adler32](https://github.com/mcountryman/simd-adler32) | `0.3.8` | `0.3.9` |
| [unicode-segmentation](https://github.com/unicode-rs/unicode-segmentation) | `1.12.0` | `1.13.2` |
| [zune-jpeg](https://github.com/etemesi254/zune-image) | `0.5.14` | `0.5.15` |


Updates `bzip2` from 0.5.2 to 0.6.1
- [Release notes](https://github.com/trifectatechfoundation/bzip2-rs/releases)
- [Commits](https://github.com/trifectatechfoundation/bzip2-rs/compare/v0.5.2...v0.6.1)

Updates `uuid` from 1.22.0 to 1.23.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.22.0...v1.23.0)

Updates `sha1` from 0.10.6 to 0.11.0
- [Commits](https://github.com/RustCrypto/hashes/compare/sha1-v0.10.6...sha1-v0.11.0)

Updates `tao` from 0.34.8 to 0.35.0
- [Release notes](https://github.com/tauri-apps/tao/releases)
- [Changelog](https://github.com/tauri-apps/tao/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tao/compare/tao-v0.34.8...tao-v0.35)

Updates `cc` from 1.2.57 to 1.2.58
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.57...cc-v1.2.58)

Updates `embed-resource` from 3.0.7 to 3.0.8
- [Release notes](https://github.com/nabijaczleweli/rust-embed-resource/releases)
- [Commits](https://github.com/nabijaczleweli/rust-embed-resource/compare/v3.0.7...v3.0.8)

Updates `libredox` from 0.1.14 to 0.1.15

Updates `mio` from 1.1.1 to 1.2.0
- [Release notes](https://github.com/tokio-rs/mio/releases)
- [Changelog](https://github.com/tokio-rs/mio/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/mio/compare/v1.1.1...v1.2.0)

Updates `num-conv` from 0.2.0 to 0.2.1
- [Commits](https://github.com/jhpratt/num-conv/compare/v0.2.0...v0.2.1)

Updates `rust_decimal` from 1.40.0 to 1.41.0
- [Release notes](https://github.com/paupino/rust-decimal/releases)
- [Changelog](https://github.com/paupino/rust-decimal/blob/master/CHANGELOG.md)
- [Commits](https://github.com/paupino/rust-decimal/compare/1.40.0...1.41.0)

Updates `simd-adler32` from 0.3.8 to 0.3.9
- [Changelog](https://github.com/mcountryman/simd-adler32/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mcountryman/simd-adler32/commits/v0.3.9)

Updates `unicode-segmentation` from 1.12.0 to 1.13.2
- [Commits](https://github.com/unicode-rs/unicode-segmentation/compare/v1.12.0...v1.13.2)

Updates `zune-jpeg` from 0.5.14 to 0.5.15
- [Release notes](https://github.com/etemesi254/zune-image/releases)
- [Changelog](https://github.com/etemesi254/zune-image/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/etemesi254/zune-image/commits)

---
updated-dependencies:
- dependency-name: bzip2
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: uuid
  dependency-version: 1.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: sha1
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tao
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.58
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: embed-resource
  dependency-version: 3.0.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: mio
  dependency-version: 1.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: num-conv
  dependency-version: 0.2.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rust_decimal
  dependency-version: 1.41.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: simd-adler32
  dependency-version: 0.3.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: unicode-segmentation
  dependency-version: 1.13.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zune-jpeg
  dependency-version: 0.5.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 09:48:00 +00:00
zhom 0788797e3f Merge pull request #253 from zhom/dependabot/github_actions/github-actions-f059b8c920
ci(deps): bump the github-actions group with 8 updates
2026-03-28 13:22:06 +04:00
dependabot[bot] 8c338515b7 ci(deps): bump the github-actions group with 8 updates
Bumps the github-actions group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4.3.1` | `6.0.2` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) | `2.3.3` | `2.3.5` |
| [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) | `2.5.0` | `3.0.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3.12.0` | `4.0.0` |
| [docker/login-action](https://github.com/docker/login-action) | `3.7.0` | `4.0.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6.19.2` | `7.0.0` |
| [anomalyco/opencode](https://github.com/anomalyco/opencode) | `1.2.27` | `1.3.3` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `2.3.3` | `2.3.5` |


Updates `actions/checkout` from 4.3.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4.3.1...v6.0.2)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.3.3 to 2.3.5
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c5996e0193a3df57d695c1b8a1dec2a4c62e8730...c51854704019a247608d928f370c98740469d4b5)

Updates `dependabot/fetch-metadata` from 2.5.0 to 3.0.0
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/21025c705c08248db411dc16f3619e6b5f9ea21a...ffa630c65fa7e0ecfa0625b5ceda64399aea1b36)

Updates `docker/setup-buildx-action` from 3.12.0 to 4.0.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd)

Updates `docker/login-action` from 3.7.0 to 4.0.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...b45d80f862d83dbcd57f89517bcf500b2ab88fb2)

Updates `docker/build-push-action` from 6.19.2 to 7.0.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294)

Updates `anomalyco/opencode` from 1.2.27 to 1.3.3
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/4ee426ba549131c4903a71dfb6259200467aca81...54443bfb7e090ec3130dc972e689a3e5cc55a7f9)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.3 to 2.3.5
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c5996e0193a3df57d695c1b8a1dec2a4c62e8730...c51854704019a247608d928f370c98740469d4b5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: dependabot/fetch-metadata
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 09:05:04 +00:00
zhom a8c179fca7 docs: agents 2026-03-28 01:41:01 +04:00
zhom d0f436ce2d chore: commit doc changes directly and pretty discord notifications 2026-03-28 01:41:01 +04:00
zhom 4019701186 Merge pull request #252 from zhom/contributors-readme-action-7fGCZTC5jp
docs(contributor): contributors readme action update
2026-03-27 20:11:15 +04:00
github-actions[bot] 53f85abe24 docs(contributor): contrib-readme-action has updated readme 2026-03-27 16:08:11 +00:00
zhom 2aafb4c7a4 Merge pull request #249 from yb403/fix/sync-loop-circular-dependency
This fix prevents the file watcher from triggering a new sync when th…
2026-03-27 20:07:59 +04:00
zhom 00d5c655dc Merge pull request #251 from zhom/chore/update-flake-0.18.1
chore: update flake.nix for v0.18.1
2026-03-25 03:39:31 +04:00
zhom b12a704d9f Merge pull request #250 from zhom/docs/release-0.18.1
docs: release notes for v0.18.1
2026-03-25 03:39:20 +04:00
github-actions[bot] 0e134fd145 chore: update flake.nix for v0.18.1 [skip ci] 2026-03-24 23:08:33 +00:00
github-actions[bot] adcdc91de2 docs: update CHANGELOG.md and README.md for v0.18.1 [skip ci] 2026-03-24 23:08:31 +00:00
yb 880014d4c4 chore: fix linting and formatting 2026-03-24 22:50:28 +01:00
zhom 71f367f0ae docs: cleanup 2026-03-25 01:36:43 +04:00
zhom daa001cdf2 chore: version bump 2026-03-25 01:05:26 +04:00
zhom 17056360ab chore: require ai disclosure 2026-03-25 01:01:16 +04:00
zhom 80d5b77a80 chore: redeploy web on new release 2026-03-25 00:54:43 +04:00
zhom 701605fa73 chore: fix e2e in pr requests 2026-03-25 00:52:48 +04:00
zhom 19cb24f67f chore: issues get stale after 30 days 2026-03-25 00:33:20 +04:00
zhom c3fec3d095 docs: agents.md 2026-03-24 23:58:33 +04:00
zhom bb8b6ea0b7 chore: better issue validation 2026-03-24 23:58:21 +04:00
zhom a6dfc5664b refactor: run docker workflow on release 2026-03-24 21:12:52 +04:00
yb 001a292185 This fix prevents the file watcher from triggering a new sync when the client updates the last_sync timestamp in metadata.json. 2026-03-24 13:20:28 +01:00
github-actions[bot] c7d7ff19a7 chore: update flake.nix for v0.18.0 [skip ci] (#247)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-24 06:59:11 +00:00
zhom aec05fb725 docs: new star history url 2026-03-24 09:29:03 +04:00
zhom c420318be0 chore: readme download links autobump 2026-03-24 09:21:54 +04:00
zhom 52c9147092 chore: readme 2026-03-24 09:21:39 +04:00
zhom c8a28dde5b docs: readme 2026-03-24 09:18:16 +04:00
zhom 915ed06032 chore: flake.nix autobump 2026-03-24 09:16:43 +04:00
zhom 9bd5b9f6db chore: version bump 2026-03-24 09:10:40 +04:00
zhom 2adbf900ae chore: disable: deamon 2026-03-24 09:08:08 +04:00
zhom 95b17e368d chore: logs 2026-03-24 09:07:45 +04:00
zhom 71563c1cdc refactor: add profile to sync queue on launch 2026-03-24 09:07:02 +04:00
zhom e160f5b2cc chore: changelog 2026-03-24 09:06:43 +04:00
zhom ad18966294 refactor: better claude integration 2026-03-24 09:05:52 +04:00
zhom 9a6b500a4f docs: templates 2026-03-24 05:35:17 +04:00
zhom e9c4e32df2 docs: contributing 2026-03-24 05:20:30 +04:00
zhom 21bc1de298 chore: enable rust codeql 2026-03-24 05:20:22 +04:00
zhom 495a91a364 chore: grep escape 2026-03-24 04:33:08 +04:00
zhom 7b1e966b73 chore: automatically publish docker images 2026-03-24 04:33:08 +04:00
zhom c33d165c6b docs: readme 2026-03-24 04:33:07 +04:00
zhom c0807164cb chore: pin install-nix-action 2026-03-24 04:33:07 +04:00
zhom 06fcd0cfd8 Merge pull request #246 from zhom/contributors-readme-action-dXBtBkB7Gr
docs(contributor): contributors readme action update
2026-03-23 18:28:22 -04:00
github-actions[bot] befccef2c3 docs(contributor): contrib-readme-action has updated readme 2026-03-23 22:27:02 +00:00
zhom 946bd1b81b chore: switch issue validation to openrouter 2026-03-24 02:26:31 +04:00
Alex Hp cae758f0ab feat: overhaul Nix flake with full Linux support and convenience commands
- Remove rust-overlay dependency, use nixpkgs built-in Rust
- Add comprehensive Linux library dependencies for Chromium browsers
- Set up proper LD_LIBRARY_PATH/NIX_LD for NixOS dynamic linking
- Add nix run apps: setup, dev, tauri-dev, full-dev, build, test, info
- Add AppImage release launcher for NixOS users (v0.17.6)
- Add flake-test CI workflow
- Update release version and hashes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 02:09:28 +04:00
zhom aa2e9e2528 refactor: rootless wayfern launch and exponential backoff for downloads 2026-03-24 01:49:34 +04:00
zhom 084e63eb1e chore: changelog generation 2026-03-24 01:48:59 +04:00
zhom c2d59e7faf chore: discord server notifications 2026-03-24 01:26:47 +04:00
zhom e8b800e83b docs: fix wording 2026-03-24 01:21:17 +04:00
zhom b00b773c07 chore: disable workflows in forks 2026-03-24 01:03:05 +04:00
zhom c782ef1961 chore: update dependencies 2026-03-24 01:02:50 +04:00
zhom 888631bc48 refactor: anyone can use e2ee except non-owner team members 2026-03-24 00:55:51 +04:00
zhom cd5fd2c970 refactor: remove executable_path 2026-03-24 00:07:50 +04:00
zhom f63650fa5d test: run ephemeral dir tests sequentially 2026-03-24 00:07:50 +04:00
zhom 7092f2155b refactor: make sync more robust 2026-03-24 00:07:50 +04:00
zhom 861d301451 refactor: make cookie and extension management free 2026-03-24 00:07:49 +04:00
zhom e1a4d8f389 Merge pull request #243 from zhom/dependabot/cargo/src-tauri/rust-dependencies-c95d545b97
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 17 updates
2026-03-23 16:07:31 -04:00
zhom 65d417d17c Merge pull request #244 from zhom/dependabot/npm_and_yarn/frontend-dependencies-9aae2c4938
deps(deps): bump the frontend-dependencies group with 120 updates
2026-03-23 16:07:18 -04:00
dependabot[bot] 0fa3922202 ci(deps): bump the github-actions group with 2 updates (#242)
Bumps the github-actions group with 2 updates: [anomalyco/opencode](https://github.com/anomalyco/opencode) and [tauri-apps/tauri-action](https://github.com/tauri-apps/tauri-action).


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

Updates `tauri-apps/tauri-action` from 0.6.1 to 0.6.2
- [Release notes](https://github.com/tauri-apps/tauri-action/releases)
- [Changelog](https://github.com/tauri-apps/tauri-action/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tauri-action/compare/73fb865345c54760d875b94642314f8c0c894afa...84b9d35b5fc46c1e45415bdb6144030364f7ebc5)

---
updated-dependencies:
- dependency-name: anomalyco/opencode
  dependency-version: 1.2.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: tauri-apps/tauri-action
  dependency-version: 0.6.2
  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-21 09:51:09 +00:00
dependabot[bot] f46f7e8961 deps(deps): bump the frontend-dependencies group with 120 updates
Bumps the frontend-dependencies group with 120 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `25.8.18` | `25.9.0` |
| [motion](https://github.com/motiondivision/motion) | `12.36.0` | `12.38.0` |
| [next](https://github.com/vercel/next.js) | `16.1.7` | `16.2.1` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.2.1` | `4.2.2` |
| [@types/color](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/color) | `4.2.0` | `4.2.1` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `16.3.4` | `16.4.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.1` | `4.2.2` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1009.0` | `3.1014.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1009.0` | `3.1014.0` |
| [@nestjs/common](https://github.com/nestjs/nest/tree/HEAD/packages/common) | `11.1.16` | `11.1.17` |
| [@nestjs/core](https://github.com/nestjs/nest/tree/HEAD/packages/core) | `11.1.16` | `11.1.17` |
| [@nestjs/platform-express](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express) | `11.1.16` | `11.1.17` |
| [@nestjs/testing](https://github.com/nestjs/nest/tree/HEAD/packages/testing) | `11.1.16` | `11.1.17` |
| [@aws-sdk/core](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/core) | `3.973.20` | `3.973.23` |
| [@aws-sdk/credential-provider-env](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-env) | `3.972.18` | `3.972.21` |
| [@aws-sdk/credential-provider-http](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-http) | `3.972.20` | `3.972.23` |
| [@aws-sdk/credential-provider-ini](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-ini) | `3.972.20` | `3.972.23` |
| [@aws-sdk/credential-provider-login](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-login) | `3.972.20` | `3.972.23` |
| [@aws-sdk/credential-provider-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-node) | `3.972.21` | `3.972.24` |
| [@aws-sdk/credential-provider-process](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-process) | `3.972.18` | `3.972.21` |
| [@aws-sdk/credential-provider-sso](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-sso) | `3.972.20` | `3.972.23` |
| [@aws-sdk/credential-provider-web-identity](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-web-identity) | `3.972.20` | `3.972.23` |
| [@aws-sdk/middleware-flexible-checksums](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-flexible-checksums) | `3.973.6` | `3.974.3` |
| [@aws-sdk/middleware-sdk-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-sdk-s3) | `3.972.20` | `3.972.23` |
| [@aws-sdk/middleware-user-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-user-agent) | `3.972.21` | `3.972.24` |
| [@aws-sdk/nested-clients](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/nested-clients) | `3.996.10` | `3.996.13` |
| [@aws-sdk/region-config-resolver](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/region-config-resolver) | `3.972.8` | `3.972.9` |
| [@aws-sdk/signature-v4-multi-region](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-multi-region) | `3.996.8` | `3.996.11` |
| [@aws-sdk/token-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/token-providers) | `3.1009.0` | `3.1014.0` |
| [@aws-sdk/util-user-agent-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-user-agent-node) | `3.973.7` | `3.973.10` |
| [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder) | `3.972.11` | `3.972.15` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.7` | `2.4.8` |
| [@emnapi/runtime](https://github.com/toyobayashi/emnapi) | `1.9.0` | `1.9.1` |
| [@next/env](https://github.com/vercel/next.js/tree/HEAD/packages/next-env) | `16.1.7` | `16.2.1` |
| [@next/swc-darwin-arm64](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/darwin-arm64) | `16.1.7` | `16.2.1` |
| [@next/swc-darwin-x64](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/darwin-x64) | `16.1.7` | `16.2.1` |
| [@next/swc-linux-arm64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/linux-arm64-gnu) | `16.1.7` | `16.2.1` |
| [@next/swc-linux-arm64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/linux-arm64-musl) | `16.1.7` | `16.2.1` |
| [@next/swc-linux-x64-gnu](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/linux-x64-gnu) | `16.1.7` | `16.2.1` |
| [@next/swc-linux-x64-musl](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/linux-x64-musl) | `16.1.7` | `16.2.1` |
| [@next/swc-win32-arm64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/win32-arm64-msvc) | `16.1.7` | `16.2.1` |
| [@next/swc-win32-x64-msvc](https://github.com/vercel/next.js/tree/HEAD/crates/next-napi-bindings/npm/win32-x64-msvc) | `16.1.7` | `16.2.1` |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-loong64-gnu](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-loong64-musl](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-ppc64-gnu](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-ppc64-musl](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-openbsd-x64](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-openharmony-arm64](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-win32-x64-gnu](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [@smithy/config-resolver](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/config-resolver) | `4.4.11` | `4.4.13` |
| [@smithy/core](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/core) | `3.23.11` | `3.23.12` |
| [@smithy/middleware-endpoint](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-endpoint) | `4.4.25` | `4.4.27` |
| [@smithy/middleware-retry](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-retry) | `4.4.42` | `4.4.44` |
| [@smithy/middleware-serde](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/middleware-serde) | `4.2.14` | `4.2.15` |
| [@smithy/node-http-handler](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/node-http-handler) | `4.4.16` | `4.5.0` |
| [@smithy/smithy-client](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/smithy-client) | `4.12.5` | `4.12.7` |
| [@smithy/util-defaults-mode-browser](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-defaults-mode-node) | `4.3.41` | `4.3.43` |
| [@smithy/util-defaults-mode-node](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-defaults-mode-node) | `4.2.44` | `4.2.47` |
| [@smithy/util-stream](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/util-stream) | `4.5.19` | `4.5.20` |
| [@tailwindcss/node](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-node) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-android-arm64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/android-arm64) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-darwin-arm64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/darwin-arm64) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-darwin-x64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/darwin-x64) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-freebsd-x64](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/freebsd-x64) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-linux-arm-gnueabihf](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm-gnueabihf) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-linux-arm64-gnu](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm64-gnu) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-linux-arm64-musl](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-arm64-musl) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-linux-x64-gnu](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-x64-gnu) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-linux-x64-musl](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/linux-x64-musl) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-wasm32-wasi](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-win32-arm64-msvc](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/win32-arm64-msvc) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide-win32-x64-msvc](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node/npm/win32-x64-msvc) | `4.2.1` | `4.2.2` |
| [@tailwindcss/oxide](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/crates/node) | `4.2.1` | `4.2.2` |
| [baseline-browser-mapping](https://github.com/web-platform-dx/baseline-browser-mapping) | `2.10.8` | `2.10.9` |
| [fast-xml-builder](https://github.com/NaturalIntelligence/fast-xml-builder) | `1.1.3` | `1.1.4` |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | `5.4.1` | `5.5.8` |
| [file-type](https://github.com/sindresorhus/file-type) | `21.3.0` | `21.3.2` |
| [framer-motion](https://github.com/motiondivision/motion) | `12.36.0` | `12.38.0` |
| [lightningcss-android-arm64](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-darwin-arm64](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-darwin-x64](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-freebsd-x64](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-linux-arm-gnueabihf](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-linux-arm64-gnu](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-linux-arm64-musl](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-linux-x64-gnu](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-linux-x64-musl](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-win32-arm64-msvc](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss-win32-x64-msvc](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [lightningcss](https://github.com/parcel-bundler/lightningcss) | `1.31.1` | `1.32.0` |
| [motion-dom](https://github.com/motiondivision/motion) | `12.36.0` | `12.38.0` |
| [path-expression-matcher](https://github.com/NaturalIntelligence/path-expression-matcher) | `1.1.3` | `1.2.0` |
| [rollup](https://github.com/rollup/rollup) | `4.59.0` | `4.59.1` |
| [strnum](https://github.com/NaturalIntelligence/strnum) | `2.2.0` | `2.2.1` |


Updates `i18next` from 25.8.18 to 25.9.0
- [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.18...v25.9.0)

Updates `motion` from 12.36.0 to 12.38.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.36.0...v12.38.0)

Updates `next` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.7...v16.2.1)

Updates `@biomejs/biome` from 2.4.7 to 2.4.8
- [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.8/packages/@biomejs/biome)

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

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

Updates `lint-staged` from 16.3.4 to 16.4.0
- [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.4...v16.4.0)

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

Updates `@aws-sdk/client-s3` from 3.1009.0 to 3.1014.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.1014.0/clients/client-s3)

Updates `@aws-sdk/s3-request-presigner` from 3.1009.0 to 3.1014.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.1014.0/packages/s3-request-presigner)

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

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

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

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

Updates `@aws-sdk/core` from 3.973.20 to 3.973.23
- [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/credential-provider-env` from 3.972.18 to 3.972.21
- [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.20 to 3.972.23
- [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.20 to 3.972.23
- [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.20 to 3.972.23
- [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.21 to 3.972.24
- [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.18 to 3.972.21
- [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.20 to 3.972.23
- [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.20 to 3.972.23
- [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-flexible-checksums` from 3.973.6 to 3.974.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/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-sdk-s3` from 3.972.20 to 3.972.23
- [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-user-agent` from 3.972.21 to 3.972.24
- [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.10 to 3.996.13
- [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.8 to 3.972.9
- [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.8 to 3.996.11
- [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.1009.0 to 3.1014.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.1014.0/packages/token-providers)

Updates `@aws-sdk/util-user-agent-node` from 3.973.7 to 3.973.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 `@aws-sdk/xml-builder` from 3.972.11 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/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.7 to 2.4.8
- [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.8/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.4.7 to 2.4.8
- [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.8/packages/@biomejs/biome)

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

Updates `@biomejs/cli-linux-arm64` from 2.4.7 to 2.4.8
- [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.8/packages/@biomejs/biome)

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

Updates `@biomejs/cli-linux-x64` from 2.4.7 to 2.4.8
- [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.8/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.4.7 to 2.4.8
- [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.8/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.4.7 to 2.4.8
- [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.8/packages/@biomejs/biome)

Updates `@emnapi/runtime` from 1.9.0 to 1.9.1
- [Release notes](https://github.com/toyobayashi/emnapi/releases)
- [Commits](https://github.com/toyobayashi/emnapi/compare/v1.9.0...v1.9.1)

Updates `@next/env` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/packages/next-env)

Updates `@next/swc-darwin-arm64` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/darwin-arm64)

Updates `@next/swc-darwin-x64` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/darwin-x64)

Updates `@next/swc-linux-arm64-gnu` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/linux-arm64-gnu)

Updates `@next/swc-linux-arm64-musl` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/linux-arm64-musl)

Updates `@next/swc-linux-x64-gnu` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/linux-x64-gnu)

Updates `@next/swc-linux-x64-musl` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/linux-x64-musl)

Updates `@next/swc-win32-arm64-msvc` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/win32-arm64-msvc)

Updates `@next/swc-win32-x64-msvc` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/crates/next-napi-bindings/npm/win32-x64-msvc)

Updates `@rollup/rollup-android-arm-eabi` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-android-arm64` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-darwin-arm64` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-darwin-x64` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-freebsd-arm64` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-freebsd-x64` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-arm64-musl` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-loong64-gnu` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-loong64-musl` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-ppc64-gnu` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-ppc64-musl` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-x64-gnu` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-linux-x64-musl` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-openbsd-x64` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-openharmony-arm64` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-win32-x64-gnu` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@rollup/rollup-win32-x64-msvc` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

Updates `@smithy/config-resolver` from 4.4.11 to 4.4.13
- [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.13/packages/config-resolver)

Updates `@smithy/core` from 3.23.11 to 3.23.12
- [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.12/packages/core)

Updates `@smithy/middleware-endpoint` from 4.4.25 to 4.4.27
- [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.27/packages/middleware-endpoint)

Updates `@smithy/middleware-retry` from 4.4.42 to 4.4.44
- [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.44/packages/middleware-retry)

Updates `@smithy/middleware-serde` from 4.2.14 to 4.2.15
- [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.15/packages/middleware-serde)

Updates `@smithy/node-http-handler` from 4.4.16 to 4.5.0
- [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.5.0/packages/node-http-handler)

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

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

Updates `@smithy/util-defaults-mode-node` from 4.2.44 to 4.2.47
- [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.47/packages/util-defaults-mode-node)

Updates `@smithy/util-stream` from 4.5.19 to 4.5.20
- [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.20/packages/util-stream)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Updates `baseline-browser-mapping` from 2.10.8 to 2.10.9
- [Release notes](https://github.com/web-platform-dx/baseline-browser-mapping/releases)
- [Commits](https://github.com/web-platform-dx/baseline-browser-mapping/compare/v2.10.8...v2.10.9)

Updates `fast-xml-builder` from 1.1.3 to 1.1.4
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-builder/blob/main/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-builder/commits/v1.1.4)

Updates `fast-xml-parser` from 5.4.1 to 5.5.8
- [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.4.1...v5.5.8)

Updates `file-type` from 21.3.0 to 21.3.2
- [Release notes](https://github.com/sindresorhus/file-type/releases)
- [Commits](https://github.com/sindresorhus/file-type/compare/v21.3.0...v21.3.2)

Updates `framer-motion` from 12.36.0 to 12.38.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.36.0...v12.38.0)

Updates `lightningcss-android-arm64` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-darwin-arm64` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-darwin-x64` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-freebsd-x64` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-linux-arm-gnueabihf` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-linux-arm64-gnu` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-linux-arm64-musl` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-linux-x64-gnu` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-linux-x64-musl` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-win32-arm64-msvc` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss-win32-x64-msvc` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `lightningcss` from 1.31.1 to 1.32.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.32.0)

Updates `motion-dom` from 12.36.0 to 12.38.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.36.0...v12.38.0)

Updates `path-expression-matcher` from 1.1.3 to 1.2.0
- [Commits](https://github.com/NaturalIntelligence/path-expression-matcher/commits)

Updates `rollup` from 4.59.0 to 4.59.1
- [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.59.0...v4.59.1)

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: motion
  dependency-version: 12.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: next
  dependency-version: 16.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/color"
  dependency-version: 4.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: lint-staged
  dependency-version: 16.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1014.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.1014.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/common"
  dependency-version: 11.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/core"
  dependency-version: 11.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/platform-express"
  dependency-version: 11.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/testing"
  dependency-version: 11.1.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/core"
  dependency-version: 3.973.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-env"
  dependency-version: 3.972.21
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-http"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-ini"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-login"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-node"
  dependency-version: 3.972.24
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-process"
  dependency-version: 3.972.21
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-sso"
  dependency-version: 3.972.23
  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.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-flexible-checksums"
  dependency-version: 3.974.3
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-sdk-s3"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-user-agent"
  dependency-version: 3.972.24
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/nested-clients"
  dependency-version: 3.996.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/region-config-resolver"
  dependency-version: 3.972.9
  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.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/token-providers"
  dependency-version: 3.1014.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.973.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@emnapi/runtime"
  dependency-version: 1.9.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/env"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 16.2.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loong64-gnu"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loong64-musl"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-gnu"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-musl"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-openbsd-x64"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-openharmony-arm64"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-gnu"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.59.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/config-resolver"
  dependency-version: 4.4.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/core"
  dependency-version: 3.23.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-endpoint"
  dependency-version: 4.4.27
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-retry"
  dependency-version: 4.4.44
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/middleware-serde"
  dependency-version: 4.2.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/node-http-handler"
  dependency-version: 4.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/smithy-client"
  dependency-version: 4.12.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-defaults-mode-browser"
  dependency-version: 4.3.43
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-defaults-mode-node"
  dependency-version: 4.2.47
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@smithy/util-stream"
  dependency-version: 4.5.20
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/node"
  dependency-version: 4.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-android-arm64"
  dependency-version: 4.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darwin-arm64"
  dependency-version: 4.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darw...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 09:49:14 +00:00
dependabot[bot] 378ece5ea5 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 17 updates:

| Package | From | To |
| --- | --- | --- |
| [zip](https://github.com/zip-rs/zip2) | `8.2.0` | `8.3.0` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) | `0.28.0` | `0.29.0` |
| [smoltcp](https://github.com/smoltcp-rs/smoltcp) | `0.12.0` | `0.13.0` |
| [borsh](https://github.com/near/borsh-rs) | `1.6.0` | `1.6.1` |
| [borsh-derive](https://github.com/near/borsh-rs) | `1.6.0` | `1.6.1` |
| [embed-resource](https://github.com/nabijaczleweli/rust-embed-resource) | `3.0.6` | `3.0.7` |
| [euclid](https://github.com/servo/euclid) | `0.22.13` | `0.22.14` |
| [heapless](https://github.com/rust-embedded/heapless) | `0.8.0` | `0.9.2` |
| [itoa](https://github.com/dtolnay/itoa) | `1.0.17` | `1.0.18` |
| [num_enum](https://github.com/illicitonion/num_enum) | `0.7.5` | `0.7.6` |
| [num_enum_derive](https://github.com/illicitonion/num_enum) | `0.7.5` | `0.7.6` |
| [toml_parser](https://github.com/toml-rs/toml) | `1.0.9+spec-1.1.0` | `1.0.10+spec-1.1.0` |
| [toml_writer](https://github.com/toml-rs/toml) | `1.0.6+spec-1.1.0` | `1.0.7+spec-1.1.0` |
| [wry](https://github.com/tauri-apps/wry) | `0.54.3` | `0.54.4` |
| [zerocopy](https://github.com/google/zerocopy) | `0.8.42` | `0.8.47` |
| [zerocopy-derive](https://github.com/google/zerocopy) | `0.8.42` | `0.8.47` |


Updates `zip` from 8.2.0 to 8.3.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/v8.2.0...v8.3.0)

Updates `bzip2` from 0.5.2 to 0.6.1
- [Release notes](https://github.com/trifectatechfoundation/bzip2-rs/releases)
- [Commits](https://github.com/trifectatechfoundation/bzip2-rs/compare/v0.5.2...v0.6.1)

Updates `tokio-tungstenite` from 0.28.0 to 0.29.0
- [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/snapview/tokio-tungstenite/compare/v0.28.0...v0.29.0)

Updates `smoltcp` from 0.12.0 to 0.13.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.12.0...v0.13.0)

Updates `borsh` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/near/borsh-rs/releases)
- [Changelog](https://github.com/near/borsh-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/near/borsh-rs/compare/borsh-v1.6.0...borsh-v1.6.1)

Updates `borsh-derive` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/near/borsh-rs/releases)
- [Changelog](https://github.com/near/borsh-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/near/borsh-rs/compare/borsh-derive-v1.6.0...borsh-derive-v1.6.1)

Updates `embed-resource` from 3.0.6 to 3.0.7
- [Release notes](https://github.com/nabijaczleweli/rust-embed-resource/releases)
- [Commits](https://github.com/nabijaczleweli/rust-embed-resource/compare/v3.0.6...v3.0.7)

Updates `euclid` from 0.22.13 to 0.22.14
- [Release notes](https://github.com/servo/euclid/releases)
- [Commits](https://github.com/servo/euclid/commits)

Updates `heapless` from 0.8.0 to 0.9.2
- [Release notes](https://github.com/rust-embedded/heapless/releases)
- [Changelog](https://github.com/rust-embedded/heapless/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-embedded/heapless/commits)

Updates `itoa` from 1.0.17 to 1.0.18
- [Release notes](https://github.com/dtolnay/itoa/releases)
- [Commits](https://github.com/dtolnay/itoa/compare/1.0.17...1.0.18)

Updates `num_enum` from 0.7.5 to 0.7.6
- [Commits](https://github.com/illicitonion/num_enum/compare/0.7.5...0.7.6)

Updates `num_enum_derive` from 0.7.5 to 0.7.6
- [Commits](https://github.com/illicitonion/num_enum/compare/0.7.5...0.7.6)

Updates `toml_parser` from 1.0.9+spec-1.1.0 to 1.0.10+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml_parser-v1.0.9...toml_parser-v1.0.10)

Updates `toml_writer` from 1.0.6+spec-1.1.0 to 1.0.7+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml_writer-v1.0.6...toml_writer-v1.0.7)

Updates `wry` from 0.54.3 to 0.54.4
- [Release notes](https://github.com/tauri-apps/wry/releases)
- [Changelog](https://github.com/tauri-apps/wry/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/wry/compare/wry-v0.54.3...wry-v0.54.4)

Updates `zerocopy` from 0.8.42 to 0.8.47
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/compare/v0.8.42...v0.8.47)

Updates `zerocopy-derive` from 0.8.42 to 0.8.47
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/compare/v0.8.42...v0.8.47)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: bzip2
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tokio-tungstenite
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: smoltcp
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: borsh
  dependency-version: 1.6.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: borsh-derive
  dependency-version: 1.6.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: embed-resource
  dependency-version: 3.0.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: euclid
  dependency-version: 0.22.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: heapless
  dependency-version: 0.9.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: itoa
  dependency-version: 1.0.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: num_enum
  dependency-version: 0.7.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: num_enum_derive
  dependency-version: 0.7.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: toml_parser
  dependency-version: 1.0.10+spec-1.1.0
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: toml_writer
  dependency-version: 1.0.7+spec-1.1.0
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: wry
  dependency-version: 0.54.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy
  dependency-version: 0.8.47
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy-derive
  dependency-version: 0.8.47
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 09:45:42 +00:00
zhom 6c76dc1a34 chore: version bump 2026-03-21 10:41:40 +04:00
zhom e45f4a792f fix: remove browser cleanup from profile sync 2026-03-21 10:40:52 +04:00
zhom 0860a3b6e0 deps: bump rustls-webpki to fix RUSTSEC-2026-0049 2026-03-21 04:48:57 +04:00
zhom 0222c7e904 fix: profile sync metadata merge and delta subscription matching 2026-03-21 04:04:48 +04:00
zhom 786acc4356 fix: mcp server spec compliance and claude desktop setup 2026-03-21 02:01:49 +04:00
zhom a813358c49 refactor: usage status 2026-03-21 02:01:49 +04:00
zhom a3fd056d6e refactor: spawn detached proxy process for ip checK 2026-03-21 02:01:49 +04:00
zhom 806e2497c0 refactor: download logging 2026-03-21 02:01:49 +04:00
dependabot[bot] c742964d86 deps(rust)(deps): bump tar from 0.4.44 to 0.4.45 in /src-tauri (#241)
Bumps [tar](https://github.com/alexcrichton/tar-rs) from 0.4.44 to 0.4.45.
- [Commits](https://github.com/alexcrichton/tar-rs/compare/0.4.44...0.4.45)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 0.4.45
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-20 18:57:02 +00:00
zhom 57e17b46e9 chore: version bump 2026-03-20 02:45:11 +04:00
zhom 116a54942d refactor: networking 2026-03-20 02:45:11 +04:00
dependabot[bot] 8936816613 deps(deps): bump next from 16.1.6 to 16.1.7 (#239)
Bumps [next](https://github.com/vercel/next.js) from 16.1.6 to 16.1.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.6...v16.1.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 23:23:58 +00:00
zhom db05ffdef6 chore: version bump 2026-03-17 13:18:56 +04:00
zhom 96614a3f33 refactor: better tombstone handling 2026-03-17 13:15:48 +04:00
zhom 222a8b89f5 chore: version bump 2026-03-16 23:21:17 +04:00
zhom 69e68a7331 chore: don't try to detect magic bits fro dmg 2026-03-16 23:20:34 +04:00
zhom 5e6faf4e2c chore: version bump 2026-03-16 21:38:39 +04:00
zhom cf1e49c761 refactor: more robust output parsing 2026-03-16 21:38:03 +04:00
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
223 changed files with 40150 additions and 10930 deletions
+5
View File
@@ -197,6 +197,7 @@ These are frequently overlooked issues that make UI look unprofessional:
Before delivering UI code, verify these items:
### Visual Quality
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] Brand logos are correct (verified from Simple Icons)
@@ -204,24 +205,28 @@ Before delivering UI code, verify these items:
- [ ] Use theme colors directly (bg-primary) not var() wrapper
### Interaction
- [ ] All clickable elements have `cursor-pointer`
- [ ] Hover states provide clear visual feedback
- [ ] Transitions are smooth (150-300ms)
- [ ] Focus states visible for keyboard navigation
### Light/Dark Mode
- [ ] Light mode text has sufficient contrast (4.5:1 minimum)
- [ ] Glass/transparent elements visible in light mode
- [ ] Borders visible in both modes
- [ ] Test both modes before delivery
### Layout
- [ ] Floating elements have proper spacing from edges
- [ ] No content hidden behind fixed navbars
- [ ] Responsive at 320px, 768px, 1024px, 1440px
- [ ] No horizontal scroll on mobile
### Accessibility
- [ ] All images have alt text
- [ ] Form inputs have labels
- [ ] Color is not the only indicator
-42
View File
@@ -1,42 +0,0 @@
---
name: "Bug report"
about: Report a bug
---
<!--
Hi there! To expedite issue processing please search open and closed issues before submitting a new one. Existing issues often contain information about workarounds, resolution, or progress updates.
-->
# Bug Report
## Description
<!-- A clear and concise description of the problem. -->
## Is this a regression?
<!-- Did this behavior use to work in the previous version? -->
## Minimal Reproduction
<!-- Clear steps to re-produce the issue. -->
1.
2.
3.
## Your Environment
<!-- Please provide as much information as you feel comfortable to help us understand the issue better -->
## Exception or Error or Screenshot
<!-- Please provide any error messages, stack traces, or screenshots that might help -->
<pre><code>
<!-- Paste error logs here -->
</code></pre>
## Additional Context
<!-- Add any other context about the problem here. -->
@@ -1,34 +0,0 @@
---
name: "Feature request"
about: Suggest a feature
---
# Feature Request
## Description
<!-- A clear and concise description of the problem or missing capability. -->
## Describe the solution you'd like
<!-- If you have a solution in mind, please describe it. -->
## Describe alternatives you've considered
<!-- Have you considered any alternative solutions or workarounds? -->
## Use Case
<!-- Describe the specific use case and how this feature would benefit users. -->
## Priority
<!-- How important is this feature to you? -->
- [ ] Low - Nice to have
- [ ] Medium - Would improve my workflow
- [ ] High - Critical for my use case
## Additional Context
<!-- Add any other context, mockups, or examples about the feature request here. -->
+63
View File
@@ -0,0 +1,63 @@
name: Bug Report
description: Something isn't working
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
placeholder: Describe the bug. What did you expect vs what actually happened?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Go to ...
2. Click on ...
3. See error
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Donut Browser version
placeholder: e.g. 0.17.6 or nightly-2026-03-21
validations:
required: true
- type: dropdown
id: browser
attributes:
label: Which browser is affected?
options:
- Wayfern
- Camoufox
- Both
- Not browser-specific
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error logs or screenshots
description: Run from terminal to get logs. Paste errors, screenshots, or screen recordings.
placeholder: Paste logs here or drag screenshots
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussion
url: https://github.com/zhom/donutbrowser/discussions
about: Ask questions or discuss ideas here instead of opening an issue.
@@ -0,0 +1,30 @@
name: Feature Request
description: Suggest a new feature
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want?
placeholder: Describe the feature and why you need it.
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use case
placeholder: How would you use this feature? What problem does it solve?
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
options:
- Nice to have
- Would improve my workflow
- Critical for my use case
validations:
required: true
+12 -46
View File
@@ -1,54 +1,20 @@
# ✨ Pull Request
## Which issue does this PR fix?
## 📓 Referenced Issue
<!-- Link the issue. #123 -->
<!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> -->
## How to test
## ️ About the PR
<!-- Steps for the reviewer to verify your changes work -->
<!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. -->
## Checklist
## 🔄 Type of Change
- [ ] Read [CONTRIBUTING.md](https://github.com/zhom/donutbrowser/blob/main/CONTRIBUTING.md)
- [ ] Ran `pnpm format && pnpm lint && pnpm test` locally and it passes
- [ ] I tested the changes myself by running the app locally
- [ ] Updated translations in all locale files (if UI text changed)
<!-- Mark the relevant option with an "x". -->
## AI usage
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📚 Documentation update
- [ ] 🧹 Code cleanup/refactoring
- [ ] ⚡ Performance improvement
- [ ] I used AI to help write this PR
## 🖼️ Testing Scenarios / Screenshots
<!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. -->
## ✅ Checklist
<!-- Mark completed items with an "x". -->
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
## 🧪 How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. -->
## 📱 Platform Testing
<!-- Which platforms have you tested on? -->
- [ ] macOS (Intel)
- [ ] macOS (Apple Silicon)
- [ ] Windows (if applicable)
- [ ] Linux (if applicable)
## 📋 Additional Notes
<!-- Any additional information that reviewers should know about this PR. -->
<!-- If you checked the box above, briefly explain how AI was used (e.g. "generated the test", "wrote the initial implementation", "full PR"). -->
+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
+4 -2
View File
@@ -27,12 +27,14 @@ jobs:
build-mode: none
- language: javascript-typescript
build-mode: none
- language: rust
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- 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
@@ -14,6 +14,7 @@ permissions:
jobs:
contrib-readme-job:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
name: Automatically update the contributors list in the README
permissions:
@@ -21,7 +22,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env:
+8 -8
View File
@@ -12,8 +12,8 @@ permissions:
jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.actor == 'dependabot[bot]' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -28,7 +28,7 @@ jobs:
lint-js:
name: Lint JavaScript/TypeScript
if: ${{ github.actor == 'dependabot[bot]' }}
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
@@ -36,7 +36,7 @@ jobs:
lint-rust:
name: Lint Rust
if: ${{ github.actor == 'dependabot[bot]' }}
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
@@ -44,7 +44,7 @@ jobs:
codeql:
name: CodeQL
if: ${{ github.actor == 'dependabot[bot]' }}
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
@@ -55,7 +55,7 @@ jobs:
spellcheck:
name: Spell Check
if: ${{ github.actor == 'dependabot[bot]' }}
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions:
@@ -63,13 +63,13 @@ jobs:
dependabot-automerge:
name: Dependabot Automerge
if: ${{ github.actor == 'dependabot[bot]' }}
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
runs-on: ubuntu-latest
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a #v2.5.0
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 #v3.0.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for minor and patch updates
+73
View File
@@ -0,0 +1,73 @@
name: Build and Push donut-sync Docker Image
on:
push:
branches: [main]
paths:
- "donut-sync/**"
workflow_call:
inputs:
tag:
description: "Docker tag (e.g., v1.0.0)"
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: "Docker tag (e.g., v1.0.0, latest)"
required: true
default: "latest"
permissions:
contents: read
env:
REGISTRY: docker.io
IMAGE_NAME: donutbrowser/donut-sync
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
- name: Log in to Docker Hub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 #v4.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine tags
id: tags
run: |
TAGS=""
INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then
# Called from release workflow or manual dispatch
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
elif [ "${{ github.event_name }}" = "push" ]; then
# Push to main (nightly): tag with nightly and commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "Tags: ${TAGS}"
- name: Build and push Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 #v7.0.0
with:
context: .
file: ./donut-sync/Dockerfile
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
+49
View File
@@ -0,0 +1,49 @@
name: Flake Test
on:
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- ".github/workflows/flake-test.yml"
push:
branches:
- main
paths:
- "flake.nix"
- "flake.lock"
- ".github/workflows/flake-test.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
flake:
name: validate-flake
runs-on: ubuntu-22.04
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Install Nix
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Evaluate flake outputs
run: nix flake show --all-systems
- name: Check setup app is exposed
run: nix eval .#apps.x86_64-linux.setup.program --raw
- name: Run flake setup app
env:
CI: "true"
run: nix run .#setup
- name: Run flake info app
run: nix run .#info
+242 -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]
@@ -14,93 +14,15 @@ permissions:
contents: read
issues: write
pull-requests: write
models: read
id-token: write
jobs:
validate-issue:
if: github.event_name == 'issues'
analyze-issue:
if: github.repository == 'zhom/donutbrowser' && 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
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Check if first-time contributor
id: check-first-time
@@ -108,9 +30,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
run: |
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues" \
--jq "map(select(.user.login == \"$ISSUE_AUTHOR\" and .number != ${{ github.event.issue.number }})) | length" \
--paginate || echo "0")
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues?state=all&creator=$ISSUE_AUTHOR&per_page=100" \
--jq "[.[] | select(.number != ${{ github.event.issue.number }}) ] | length" \
|| echo "0")
if [ "$ISSUE_COUNT" = "0" ]; then
echo "is_first_time=true" >> $GITHUB_OUTPUT
@@ -118,108 +40,148 @@ jobs:
echo "is_first_time=false" >> $GITHUB_OUTPUT
fi
- name: Parse validation result and take action
- name: Build repo context and find related files
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RESPONSE_FILE: ${{ steps.validate.outputs.response-file }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
else
echo "::error::Response file not found: $RESPONSE_FILE"
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
# List all source files for the AI to pick from
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
- name: Select relevant files with AI
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
PAYLOAD=$(jq -n \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile files /tmp/all-source-files.txt \
'{
model: "anthropic/claude-opus-4.6",
messages: [
{
role: "system",
content: "You are a file selector for Donut Browser (Tauri + Next.js + Rust anti-detect browser). Given an issue and a list of source files, output ONLY the 10 most likely relevant file paths, one per line. No explanations, no numbering, just paths."
},
{
role: "user",
content: ("Issue: " + $title + "\n\n" + $body + "\n\nFiles:\n" + $files)
}
]
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/selected-files.txt
# Read the selected files in full (skip binary files)
echo "" > /tmp/file-contents.txt
while IFS= read -r filepath; do
filepath=$(echo "$filepath" | xargs)
[ -z "$filepath" ] && continue
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath ===" >> /tmp/file-contents.txt
cat "$filepath" >> /tmp/file-contents.txt
echo "" >> /tmp/file-contents.txt
fi
done < /tmp/selected-files.txt
# Cap total context at 100KB
head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt
- name: Analyze issue with AI
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
run: |
GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING='This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"'
fi
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile author /tmp/issue-author.txt \
--rawfile greeting /tmp/greeting.txt \
--rawfile repo_context /tmp/repo-context.txt \
--rawfile context /tmp/file-context.txt \
'{
model: "anthropic/claude-opus-4.6",
messages: [
{
role: "system",
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <container>` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n - Only ask for information that is actually missing. If the issue is already detailed, just acknowledge it.\n3. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Do NOT include a \"Possible cause\" section. Do not speculate about what code might be causing the issue.\n- Be brief and focused on collecting actionable information from the reporter.\n- If the issue already has everything needed (steps to reproduce, logs, version, OS), just acknowledge it.\n- Never exceed 15 lines.")
},
{
role: "user",
content: (
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
"Analyze this issue:\n\nTitle: " + $title +
"\nAuthor: " + $author +
"\n\nBody:\n" + $body +
"\n\nRelevant source files:\n" + $context
)
}
]
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty"
echo "Raw response:"
echo "$RESPONSE"
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: Post comment and label
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
with:
model: zai-coding-plan/glm-4.7
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
LABEL=$(grep -oP '^Label:\s*\K.*' /tmp/ai-comment.txt | tail -1 | tr '[:upper:]' '[:lower:]' | xargs)
sed -i '/^Label:/d' /tmp/ai-comment.txt
- name: Cleanup
run: rm -f issue_analysis.txt comment.md
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
handle-pr:
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
if [ "$LABEL" = "bug" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "bug" 2>/dev/null || true
elif [ "$LABEL" = "enhancement" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "enhancement" 2>/dev/null || true
fi
analyze-pr:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
with:
fetch-depth: 0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Check if first-time contributor
id: check-first-time
@@ -237,144 +199,123 @@ jobs:
echo "is_first_time=false" >> $GITHUB_OUTPUT
fi
- name: Get PR diff
id: get-diff
- name: Gather PR context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr diff ${{ github.event.pull_request.number }} > pr_diff.txt
head -c 10000 pr_diff.txt > pr_diff_truncated.txt
# Get changed files list
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
> /tmp/pr-files.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
# Get the actual diff
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
--header "Accept: application/vnd.github.diff" \
> /tmp/pr-diff-full.txt 2>/dev/null || true
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
# Get CONTRIBUTING.md and README.md for context
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt
# Read full contents of all changed files (skip binary)
echo "" > /tmp/related-file-contents.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
cat "$filepath" >> /tmp/related-file-contents.txt
echo "" >> /tmp/related-file-contents.txt
fi
done
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.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 }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BASE: ${{ github.event.pull_request.base.ref }}
PR_HEAD: ${{ github.event.pull_request.head.ref }}
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
run: |
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
else
echo "::error::Response file not found"
GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
fi
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.txt
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \
--rawfile title /tmp/pr-title.txt \
--rawfile body /tmp/pr-body.txt \
--rawfile author /tmp/pr-author.txt \
--rawfile base /tmp/pr-base.txt \
--rawfile head /tmp/pr-head.txt \
--rawfile files /tmp/pr-files.txt \
--rawfile diff /tmp/pr-diff.txt \
--rawfile greeting /tmp/greeting.txt \
--rawfile repo_context /tmp/repo-context.txt \
--rawfile contributing /tmp/contributing.txt \
--rawfile file_context /tmp/pr-file-context.txt \
'{
model: "anthropic/claude-opus-4.6",
messages: [
{
role: "system",
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to the full changed files and the diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added/changed, check if all 7 translation files (en, es, fr, ja, pt, ru, zh) in src/i18n/locales/ were updated\n - If Tauri commands were added/removed, the unused-commands test in lib.rs needs updating\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
},
{
role: "user",
content: (
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
"Review this PR:\n\nTitle: " + $title +
"\nAuthor: " + $author +
"\nBase: " + $base + " <- Head: " + $head +
"\n\nDescription:\n" + $body +
"\n\nChanged files:\n" + $files +
"\n\nDiff:\n" + $diff +
"\n\nFull file contents:\n" + $file_context
)
}
]
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty"
echo "Raw response:"
echo "$RESPONSE"
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: Post comment
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
with:
model: zai-coding-plan/glm-4.7
- name: Cleanup
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_analysis.txt comment.md
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
opencode-command:
if: |
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
(contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
@@ -383,11 +324,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
uses: anomalyco/opencode/github@54443bfb7e090ec3130dc972e689a3e5cc55a7f9 #v1.3.3
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: zai-coding-plan/glm-4.7
+2 -2
View File
@@ -34,10 +34,10 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- 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
+3 -2
View File
@@ -29,6 +29,7 @@ permissions:
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-22.04, windows-latest]
@@ -40,10 +41,10 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- 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 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -58,7 +58,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
+9 -42
View File
@@ -13,11 +13,11 @@ permissions:
jobs:
generate-release-notes:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v')
if: github.repository == 'zhom/donutbrowser' && github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v')
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
@@ -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'
+402 -10
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 }}
@@ -12,8 +18,9 @@ env:
jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -27,6 +34,7 @@ jobs:
actions: read
lint-js:
if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
@@ -34,6 +42,7 @@ jobs:
contents: read
lint-rust:
if: github.repository == 'zhom/donutbrowser'
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
@@ -41,6 +50,7 @@ jobs:
contents: read
codeql:
if: github.repository == 'zhom/donutbrowser'
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
@@ -51,6 +61,7 @@ jobs:
actions: read
spellcheck:
if: github.repository == 'zhom/donutbrowser'
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
@@ -58,6 +69,7 @@ jobs:
contents: read
release:
if: github.repository == 'zhom/donutbrowser'
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
@@ -85,13 +97,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
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- 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 +131,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
@@ -191,7 +208,7 @@ jobs:
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
- name: Build Tauri app
uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa #v0.6.1
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REF_NAME: ${{ github.ref_name }}
@@ -208,14 +225,389 @@ jobs:
prerelease: false
args: ${{ matrix.args }}
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
PORTABLE_DIR="Donut-Portable"
mkdir -p "$PORTABLE_DIR"
# Copy main executable
cp "src-tauri/target/${{ matrix.target }}/release/Donut.exe" "$PORTABLE_DIR/"
# Copy sidecar binaries
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
# Copy WebView2Loader if present
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
fi
# Create .portable marker
touch "$PORTABLE_DIR/.portable"
# Create ZIP
7z a "Donut_${VERSION}_x64-portable.zip" "$PORTABLE_DIR"
- name: Upload portable ZIP to release
if: matrix.platform == 'windows-latest'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
gh release upload "$TAG" "Donut_${VERSION}_x64-portable.zip" --clobber
- name: Clean up Apple certificate
if: matrix.platform == 'macos-latest' && always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f $RUNNER_TEMP/build_certificate.p12 || true
# - name: Commit CHANGELOG.md
# uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 #v6.0.1
# with:
# branch: main
# commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
changelog:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
- name: Generate changelog
env:
TAG: ${{ github.ref_name }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
echo "Generating changelog: ${PREV_TAG}..${TAG}"
features=""
fixes=""
refactors=""
perf=""
docs=""
maintenance=""
other=""
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*)
features="${features}- $(strip_prefix "$msg")"$'\n' ;;
fix\(*\):*|fix:*)
fixes="${fixes}- $(strip_prefix "$msg")"$'\n' ;;
refactor\(*\):*|refactor:*)
refactors="${refactors}- $(strip_prefix "$msg")"$'\n' ;;
perf\(*\):*|perf:*)
perf="${perf}- $(strip_prefix "$msg")"$'\n' ;;
docs\(*\):*|docs:*)
docs="${docs}- $(strip_prefix "$msg")"$'\n' ;;
build*|ci*|chore*|test*)
maintenance="${maintenance}- ${msg}"$'\n' ;;
*)
other="${other}- ${msg}"$'\n' ;;
esac
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
{
echo "## ${TAG} ($(date -u +%Y-%m-%d))"
echo ""
[ -n "$features" ] && printf "### Features\n\n%s\n" "$features"
[ -n "$fixes" ] && printf "### Bug Fixes\n\n%s\n" "$fixes"
[ -n "$refactors" ] && printf "### Refactoring\n\n%s\n" "$refactors"
[ -n "$perf" ] && printf "### Performance\n\n%s\n" "$perf"
[ -n "$docs" ] && printf "### Documentation\n\n%s\n" "$docs"
[ -n "$maintenance" ] && printf "### Maintenance\n\n%s\n" "$maintenance"
[ -n "$other" ] && printf "### Other\n\n%s\n" "$other"
} > /tmp/release-changelog.md
echo "Generated changelog:"
cat /tmp/release-changelog.md
- name: Update CHANGELOG.md
run: |
if [ -f CHANGELOG.md ]; then
# Insert new entry after the "# Changelog" header (first 2 lines)
{
head -n 2 CHANGELOG.md
echo ""
cat /tmp/release-changelog.md
tail -n +3 CHANGELOG.md
} > CHANGELOG.tmp
mv CHANGELOG.tmp CHANGELOG.md
else
{
echo "# Changelog"
echo ""
cat /tmp/release-changelog.md
} > CHANGELOG.md
fi
- name: Update README download links
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
BASE="https://github.com/zhom/donutbrowser/releases/download/${TAG}"
# Generate the new install section between markers
cat > /tmp/install-links.md << LINKS
### macOS
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](${BASE}/Donut_${VERSION}_aarch64.dmg) | [Download](${BASE}/Donut_${VERSION}_x64.dmg) |
Or install via Homebrew:
\`\`\`bash
brew install --cask donut
\`\`\`
### Windows
[Download Windows Installer (x64)](${BASE}/Donut_${VERSION}_x64-setup.exe) · [Portable (x64)](${BASE}/Donut_${VERSION}_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](${BASE}/Donut_${VERSION}_amd64.deb) | [Download](${BASE}/Donut_${VERSION}_arm64.deb) |
| **rpm** | [Download](${BASE}/Donut-${VERSION}-1.x86_64.rpm) | [Download](${BASE}/Donut-${VERSION}-1.aarch64.rpm) |
| **AppImage** | [Download](${BASE}/Donut_${VERSION}_amd64.AppImage) | [Download](${BASE}/Donut_${VERSION}_aarch64.AppImage) |
LINKS
# Strip leading whitespace from heredoc
sed -i 's/^ //' /tmp/install-links.md
# Replace content between markers in README
sed -i '/<!-- install-links-start -->/,/<!-- install-links-end -->/{
/<!-- install-links-start -->/{
p
r /tmp/install-links.md
}
/<!-- install-links-end -->/!d
}' README.md
- name: Create release docs PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
BRANCH="docs/release-${VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add CHANGELOG.md README.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "docs: update CHANGELOG.md and README.md for ${TAG} [skip ci]"
git push origin "$BRANCH"
gh pr create \
--title "docs: release notes for ${TAG}" \
--body "Automated update of CHANGELOG.md and README.md download links for ${TAG}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --admin
fi
- name: Update release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release edit "$TAG" --notes-file /tmp/release-changelog.md
notify-discord:
if: github.repository == 'zhom/donutbrowser'
needs: [release, changelog]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
- name: Generate changelog summary
env:
TAG: ${{ github.ref_name }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
fix\(*\):*|fix:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
refactor\(*\):*|refactor:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
perf\(*\):*|perf:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
esac
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
# Truncate to fit Discord embed (max 4096 chars)
if [ ${#CHANGES} -gt 3900 ]; then
CHANGES="${CHANGES:0:3900}\n..."
fi
if [ -z "$CHANGES" ]; then
CHANGES="See the full changelog on GitHub."
fi
printf '%s' "$CHANGES" > /tmp/discord-changes.txt
- name: Send Discord notification
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_STABLE_WEBHOOK_URL }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG}"
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${VERSION}"
CHANGES=$(cat /tmp/discord-changes.txt)
# Build JSON with jq to handle escaping
PAYLOAD=$(jq -n \
--arg title "Donut Browser ${VERSION} Released" \
--arg url "$RELEASE_URL" \
--arg changes "$CHANGES" \
--arg dl_mac_arm "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_aarch64.dmg" \
--arg dl_mac_intel "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64.dmg" \
--arg dl_win "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64-setup.exe" \
--arg dl_linux "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_amd64.AppImage" \
'{
embeds: [{
title: $title,
url: $url,
description: $changes,
color: 5814783,
fields: [
{ name: "Download", value: ("[macOS (Apple Silicon)](" + $dl_mac_arm + ") · [macOS (Intel)](" + $dl_mac_intel + ")\n[Windows x64](" + $dl_win + ") · [Linux x64](" + $dl_linux + ")"), inline: false }
],
footer: { text: "donutbrowser.com" }
}]
}')
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
deploy-website:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
runs-on: ubuntu-latest
steps:
- name: Trigger Cloudflare Pages deployment
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
docker:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
uses: ./.github/workflows/docker-sync.yml
with:
tag: ${{ github.ref_name }}
secrets: inherit
update-flake:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
- name: Compute AppImage hashes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
AMD64_URL="https://github.com/zhom/donutbrowser/releases/download/${TAG}/Donut_${VERSION}_amd64.AppImage"
AARCH64_URL="https://github.com/zhom/donutbrowser/releases/download/${TAG}/Donut_${VERSION}_aarch64.AppImage"
echo "Downloading x86_64 AppImage..."
curl -fsSL -o /tmp/amd64.AppImage "$AMD64_URL" || { echo "x86_64 AppImage not found"; exit 1; }
echo "Downloading aarch64 AppImage..."
curl -fsSL -o /tmp/aarch64.AppImage "$AARCH64_URL" || { echo "aarch64 AppImage not found"; exit 1; }
# Compute SRI hashes (sha256-<base64>)
AMD64_HASH="sha256-$(sha256sum /tmp/amd64.AppImage | awk '{print $1}' | xxd -r -p | base64 | tr -d '\n')"
AARCH64_HASH="sha256-$(sha256sum /tmp/aarch64.AppImage | awk '{print $1}' | xxd -r -p | base64 | tr -d '\n')"
echo "AMD64_HASH=${AMD64_HASH}" >> "$GITHUB_ENV"
echo "AARCH64_HASH=${AARCH64_HASH}" >> "$GITHUB_ENV"
echo "AMD64_URL=${AMD64_URL}" >> "$GITHUB_ENV"
echo "AARCH64_URL=${AARCH64_URL}" >> "$GITHUB_ENV"
echo "x86_64 hash: ${AMD64_HASH}"
echo "aarch64 hash: ${AARCH64_HASH}"
- name: Update flake.nix
run: |
# Update releaseVersion
sed -i "s/releaseVersion = \"[^\"]*\"/releaseVersion = \"${VERSION}\"/" flake.nix
# Update x86_64 URL and hash
sed -i "s|url = \"https://github.com/zhom/donutbrowser/releases/download/v[^\"]*_amd64.AppImage\"|url = \"${AMD64_URL}\"|" flake.nix
sed -i "/amd64.AppImage/{ n; s|hash = \"[^\"]*\"|hash = \"${AMD64_HASH}\"|; }" flake.nix
# Update aarch64 URL and hash
sed -i "s|url = \"https://github.com/zhom/donutbrowser/releases/download/v[^\"]*_aarch64.AppImage\"|url = \"${AARCH64_URL}\"|" flake.nix
sed -i "/aarch64.AppImage/{ n; s|hash = \"[^\"]*\"|hash = \"${AARCH64_HASH}\"|; }" flake.nix
echo "Updated flake.nix:"
grep -n "releaseVersion\|AppImage\|hash = " flake.nix
- name: Create pull request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="chore/update-flake-${VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add flake.nix
if git diff --cached --quiet; then
echo "No flake changes needed"
exit 0
fi
git commit -m "chore: update flake.nix for v${VERSION} [skip ci]"
git push origin "$BRANCH"
gh pr create \
--title "chore: update flake.nix for v${VERSION}" \
--body "Automated update of flake.nix with new AppImage hashes for v${VERSION}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --admin
+190 -5
View File
@@ -5,14 +5,21 @@ 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 }}
jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -26,6 +33,7 @@ jobs:
actions: read
lint-js:
if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
@@ -33,6 +41,7 @@ jobs:
contents: read
lint-rust:
if: github.repository == 'zhom/donutbrowser'
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
@@ -40,6 +49,7 @@ jobs:
contents: read
codeql:
if: github.repository == 'zhom/donutbrowser'
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
@@ -50,6 +60,7 @@ jobs:
actions: read
spellcheck:
if: github.repository == 'zhom/donutbrowser'
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
@@ -57,6 +68,7 @@ jobs:
contents: read
rolling-release:
if: github.repository == 'zhom/donutbrowser'
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
@@ -92,10 +104,10 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- 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 +130,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
@@ -204,7 +216,7 @@ jobs:
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
- name: Build Tauri app
uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa #v0.6.1
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
@@ -223,8 +235,181 @@ jobs:
prerelease: true
args: ${{ matrix.args }}
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
run: |
PORTABLE_DIR="Donut-Portable"
mkdir -p "$PORTABLE_DIR"
cp "src-tauri/target/${{ matrix.target }}/release/Donut.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
fi
touch "$PORTABLE_DIR/.portable"
7z a "Donut_x64-portable.zip" "$PORTABLE_DIR"
- name: Upload portable ZIP to release
if: matrix.platform == 'windows-latest'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NIGHTLY_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
run: |
gh release upload "$NIGHTLY_TAG" "Donut_x64-portable.zip" --clobber
- name: Clean up Apple certificate
if: matrix.platform == 'macos-latest' && always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f $RUNNER_TEMP/build_certificate.p12 || true
update-nightly-release:
if: github.repository == 'zhom/donutbrowser'
needs: [rolling-release]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- 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: Generate nightly changelog
id: nightly-changelog
run: |
LAST_STABLE=$(git tag --sort=-version:refname \
| grep -E "^v[0-9]+\.[0-9]+\.[0-9]+\$" \
| head -n 1)
if [ -z "$LAST_STABLE" ]; then
LAST_STABLE=$(git rev-list --max-parents=0 HEAD)
fi
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
{
echo "**Nightly build from main branch**"
echo ""
echo "Commit: ${GITHUB_SHA}"
echo "Changes since ${LAST_STABLE}:"
echo ""
} > /tmp/nightly-notes.md
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
features=""
fixes=""
refactors=""
other=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*)
features="${features}- $(strip_prefix "$msg")"$'\n' ;;
fix\(*\):*|fix:*)
fixes="${fixes}- $(strip_prefix "$msg")"$'\n' ;;
refactor\(*\):*|refactor:*)
refactors="${refactors}- $(strip_prefix "$msg")"$'\n' ;;
build*|ci*|chore*|test*|docs*|perf*)
;; # skip maintenance commits from nightly notes
*)
other="${other}- ${msg}"$'\n' ;;
esac
done < <(git log --pretty=format:"%s" "${LAST_STABLE}..HEAD" --no-merges)
{
[ -n "$features" ] && printf "### Features\n\n%s\n" "$features"
[ -n "$fixes" ] && printf "### Bug Fixes\n\n%s\n" "$fixes"
[ -n "$refactors" ] && printf "### Refactoring\n\n%s\n" "$refactors"
[ -n "$other" ] && printf "### Other\n\n%s\n" "$other"
true
} >> /tmp/nightly-notes.md
- 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-file /tmp/nightly-notes.md \
--prerelease
deploy-website:
if: github.repository == 'zhom/donutbrowser'
needs: [update-nightly-release]
runs-on: ubuntu-latest
steps:
- name: Trigger Cloudflare Pages deployment
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
notify-discord:
if: github.repository == 'zhom/donutbrowser'
needs: [update-nightly-release]
runs-on: ubuntu-latest
steps:
- name: Send Discord notification
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK_URL }}
run: |
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/nightly"
COMMIT_URL="https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
PAYLOAD=$(jq -n \
--arg title "Donut Browser Nightly (${COMMIT_SHORT})" \
--arg url "$RELEASE_URL" \
--arg commit_url "$COMMIT_URL" \
--arg commit_short "$COMMIT_SHORT" \
'{
embeds: [{
title: $title,
url: $url,
color: 16752128,
fields: [
{ name: "Commit", value: ("[" + $commit_short + "](" + $commit_url + ")"), inline: true },
{ name: "Download", value: ("[Nightly Release](" + $url + ")"), inline: true }
],
footer: { text: "donutbrowser.com" }
}]
}')
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo
uses: crate-ci/typos@78bc6fb2c0d734235d57a2d6b9de923cc325ebdd #v1.43.4
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
+6 -3
View File
@@ -6,16 +6,19 @@ on:
jobs:
stale:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
permissions:
issues: write
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."
stale-pr-message: "This pull request has been inactive for 60 days. Please respond to keep it open."
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
stale-pr-message: "This pull request has been inactive for 30 days. Please respond to keep it open."
stale-issue-label: "stale"
stale-pr-label: "stale"
days-before-stale: 30
days-before-close: 7
+6 -5
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]
@@ -31,10 +32,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v6.0.2
- 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"
@@ -72,7 +73,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v6.0.2
- name: Start MinIO
run: |
@@ -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
+88
View File
@@ -10,11 +10,15 @@
"appindicator",
"applescript",
"asyncio",
"autocheckpoint",
"autoconfig",
"autologin",
"bintools",
"biomejs",
"boringtun",
"breezedark",
"browserforge",
"Buildx",
"busctl",
"CAMOU",
"camoufox",
@@ -31,7 +35,9 @@
"cmdk",
"codegen",
"codesign",
"codesigning",
"commitish",
"coreutils",
"Crashpad",
"CTYPE",
"daijro",
@@ -39,45 +45,68 @@
"datareporting",
"datas",
"DBAPI",
"dbus",
"dconf",
"debuginfo",
"desynced",
"devedition",
"direnv",
"diskutil",
"distro",
"dists",
"DMABUF",
"DOCKERHUB",
"doctest",
"doesn",
"domcontentloaded",
"dont",
"donutbrowser",
"doorhanger",
"dpkg",
"dtolnay",
"dyld",
"elif",
"erasevolume",
"errorlevel",
"esac",
"esbuild",
"etree",
"fetchurl",
"findutils",
"firstrun",
"flate",
"fontconfig",
"freetype",
"fribidi",
"frontmost",
"fsprogs",
"geoip",
"getcwd",
"gettimezone",
"gifs",
"globset",
"gnugrep",
"gnumake",
"gnused",
"GOPATH",
"gsettings",
"harfbuzz",
"healthreport",
"hiddenimports",
"hkcu",
"hooksconfig",
"hookspath",
"hostable",
"Hoverable",
"icns",
"idlelib",
"idletime",
"idna",
"imdisk",
"infobars",
"inkey",
"Inno",
"isps",
"kdeglobals",
"keras",
"KHTML",
@@ -87,17 +116,39 @@
"langpack",
"launchservices",
"letterboxing",
"leveldb",
"libappindicator",
"libatk",
"libayatana",
"libc",
"libcairo",
"libdrm",
"libfuse",
"libgbm",
"libgdk",
"libglib",
"libglvnd",
"libgpg",
"libpango",
"librsvg",
"libsoup",
"libwebkit",
"libx",
"libxcb",
"libxcomposite",
"libxcursor",
"libxdamage",
"libxdo",
"libxext",
"libxfixes",
"libxi",
"libxinerama",
"libxkbcommon",
"libxrandr",
"libxrender",
"libxscrnsaver",
"libxshmfence",
"libxtst",
"localtime",
"lpdw",
"lxml",
@@ -105,6 +156,7 @@
"macchiato",
"Matchalk",
"maxminddb",
"minidumps",
"minioadmin",
"mmdb",
"mountpoint",
@@ -114,34 +166,50 @@
"msys",
"muda",
"mypy",
"nixos",
"nixpkgs",
"noarchive",
"nobrowse",
"noconfirm",
"nodecar",
"NODELAY",
"nodemon",
"nomount",
"norestart",
"NSIS",
"nspr",
"ntfs",
"ntlm",
"numpy",
"numtide",
"objc",
"oneshot",
"opencode",
"OPENROUTER",
"orhun",
"orjson",
"osascript",
"oscpu",
"outpath",
"OVPN",
"pango",
"passout",
"patchelf",
"pathex",
"pathlib",
"peerconnection",
"PHANDLER",
"pids",
"pipefail",
"pixbuf",
"pkexec",
"pkgs",
"pkill",
"plasmohq",
"platformdirs",
"pname",
"prefs",
"presign",
"PRIO",
"propertylist",
"psutil",
@@ -153,13 +221,23 @@
"pytest",
"pyyaml",
"quic",
"ralt",
"ramdisk",
"rawfile",
"repodata",
"repogen",
"reportingpolicy",
"reqwest",
"resvg",
"ridedott",
"rlib",
"rsplit",
"rusqlite",
"rustc",
"rwxr",
"safebrowsing",
"SARIF",
"sarifv",
"scipy",
"screeninfo",
"selectables",
@@ -172,17 +250,21 @@
"shadcn",
"showcursor",
"shutil",
"sighandler",
"signon",
"signum",
"sklearn",
"smoltcp",
"SMTO",
"sonner",
"splitn",
"sspi",
"staticlib",
"stdenv",
"stefanzweifel",
"subdirs",
"subkey",
"subsec",
"SUPPRESSMSGBOXES",
"swatinem",
"sysinfo",
@@ -194,14 +276,19 @@
"TERX",
"testpass",
"testuser",
"thiserror",
"timedatectl",
"titlebar",
"tkinter",
"tmpfs",
"tombstoned",
"tqdm",
"trackingprotection",
"trailhead",
"tungstenite",
"turbopack",
"turtledemo",
"typer",
"udeps",
"unlisten",
"unminimize",
@@ -212,6 +299,7 @@
"venv",
"vercel",
"VERYSILENT",
"vpns",
"wayfern",
"webgl",
"webrtc",
+86 -7
View File
@@ -1,9 +1,88 @@
# 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.
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
> After significant changes (new modules, renamed files, new directories), re-evaluate the Repository Structure below and update it if needed.
## Repository Structure
```
donutbrowser/
├── src/ # Next.js frontend
│ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, pt, ru, zh)
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
│ ├── src/
│ │ ├── lib.rs # Tauri command registration (100+ commands)
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
│ │ ├── browser.rs # Browser trait & launch logic
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
│ │ ├── api_server.rs # REST API (utoipa + axum)
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard & OpenVPN tunnels
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
│ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence
│ │ ├── cookie_manager.rs # Cookie import/export
│ │ ├── extension_manager.rs # Browser extension management
│ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
│ │ └── cloud_auth.rs # Cloud authentication
│ ├── tests/ # Integration tests
│ └── Cargo.toml # Rust dependencies
├── donut-sync/ # NestJS sync server (self-hostable)
│ └── src/ # Controllers, services, auth, S3 sync
├── docs/ # Documentation (self-hosting guide)
├── flake.nix # Nix development environment
└── .github/workflows/ # CI/CD pipelines
```
## 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.
+22
View File
@@ -0,0 +1,22 @@
# Changelog
## v0.18.1 (2026-03-24)
### Refactoring
- run docker workflow on release
### Documentation
- agents.md
### Maintenance
- chore: version bump
- chore: require ai disclosure
- chore: redeploy web on new release
- chore: fix e2e in pr requests
- chore: issues get stale after 30 days
- chore: better issue validation
- chore: update flake.nix for v0.18.0 [skip ci] (#247)
-20
View File
@@ -1,20 +0,0 @@
# Project Guidelines
## 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
## 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
- When modifying the UI, don't add random colors that are not controlled by `src/lib/themes.ts`
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+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.
+62 -156
View File
@@ -1,194 +1,100 @@
# Contributing to Donut Browser
Contributions are welcome and always appreciated! 🍩
To begin working on an issue, simply leave a comment indicating that you're taking it on. There's no need to be officially assigned to the issue before you start.
Contributions are welcome! To start working on an issue, leave a comment indicating you're taking it on.
## Before Starting
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
- Search existing PRs related to that issue
- Confirm no other contributors are working on the same issue
- Check if the feature aligns with the project's 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:
- 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
- You retain all rights to use your contributions for any other purpose
When you submit your first pull request, you acknowledge that you agree to the terms of the Contributor License Agreement.
## Tips & Things to Consider
- 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
By contributing, you agree your contributions will be licensed under the same terms as the project. See [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). This ensures contributions can be used in the open source version (AGPL-3.0) and commercially licensed. You retain all rights to use your contributions elsewhere.
## Development Setup
### Using Nix
If you have [Nix](https://nixos.org/) installed, you can skip the manual setup below and simply run:
### Using Nix (recommended)
```bash
nix develop
# or if you use direnv
direnv allow
nix run .#setup # Install dependencies
nix run .#tauri-dev # Start development server
nix run .#test # Run all checks
```
This will provide Node.js, Rust, and all necessary system libraries.
Or enter the dev shell: `nix develop`
### Manual Setup
Ensure you have the following dependencies installed:
Requirements:
- Node.js (see `.node-version` for exact version)
- pnpm package manager
- Latest Rust and Cargo toolchain
- [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/).
## Run Locally
After having the above dependencies installed, proceed through the following steps to setup the codebase locally:
1. **Fork the project** & [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it locally.
2. **Create a new separate branch.**
```bash
git checkout -b feature/my-feature-name
```
3. **Install frontend dependencies**
```bash
pnpm install
```
4. **Start the development server**
```bash
pnpm tauri dev
```
This will start the app for local development with live reloading.
## Code Style & Quality
We use several tools to maintain code quality:
- **Biome** for JavaScript/TypeScript linting and formatting
- **Clippy** for Rust linting
- **rustfmt** for Rust formatting
### Before Committing
Run these commands to ensure your code meets our standards:
- Node.js (see `.node-version`)
- pnpm
- Rust + Cargo (latest stable)
- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/)
```bash
# Format and lint frontend code
pnpm format:js
# Format and lint Rust code
pnpm format:rust
# Run all linting
pnpm lint
git checkout -b feature/my-feature-name
pnpm install
pnpm tauri dev
```
## Building
## Quality Checks
It is crucial to test your code before submitting a pull request. Please ensure that you can make a complete production build before you submit your code for merging.
Run before every commit:
```bash
# Build the frontend
pnpm build
# Build the backend
cd src-tauri && cargo build
# Build the Tauri application
pnpm tauri build
pnpm format && pnpm lint && pnpm test
```
Make sure the build completes successfully without errors.
This runs:
## Testing
- **Biome** — JS/TS linting and formatting
- **Clippy + rustfmt** — Rust linting and formatting
- **typos** — Spellcheck (allowlist in `_typos.toml`)
- **CodeQL** — Security analysis (JS, Actions, Rust) — runs in CI
- **Unit tests** — 330+ Rust tests
- **Integration tests** — proxy, sync e2e
- Always test your changes on the target platform
- Verify that existing functionality still works
- Add tests for new features when possible
### Running CodeQL locally
```bash
# Install: brew install codeql
codeql pack download codeql/javascript-queries codeql/rust-queries
# JavaScript
codeql database create /tmp/codeql-js --language=javascript --source-root=.
codeql database analyze /tmp/codeql-js --format=sarifv2.1.0 --output=/tmp/js.sarif codeql/javascript-queries
# Rust
codeql database create /tmp/codeql-rust --language=rust --source-root=.
codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust.sarif codeql/rust-queries
```
## Key Rules
- **Translations**: Any UI text changes must be reflected in all 7 locale files (`src/i18n/locales/`)
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
- **AGPL-3.0**: This project is AGPL-licensed. Derivatives must be open source with the same license
## Pull Request Guidelines
🎉 Now that you're ready to submit your code for merging, there are some points to keep in mind:
- Fill the PR description template
- Reference related issues (`Fixes #123` or `Refs #123`)
- Include screenshots/videos for UI changes
- Ensure "Allow edits from maintainers" is checked
### PR Description
## Architecture
- Fill your PR description template accordingly
- Have an appropriate title and description
- Include relevant screenshots for UI changes. If you can include video/gifs, it is even better.
- Reference related issues
### Linking Issues
If your PR fixes an issue, add this line **in the body** of the Pull Request description:
```text
Fixes #00000
```
If your PR is referencing an issue:
```text
Refs #00000
```
### PR Checklist
- [ ] Code follows our 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
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
### Options
- Ensure that "Allow edits from maintainers" option is checked
## Architecture Overview
Donut Browser is built with:
- **Frontend**: Next.js React application
- **Backend**: Tauri (Rust) for native functionality
- **Node.js Sidecar**: `nodecar` binary for access to JavaScript ecosystem
- **Build System**: GitHub Actions for CI/CD
Understanding this architecture will help you contribute more effectively.
- **Frontend**: Next.js (React) — `src/`
- **Backend**: Tauri (Rust) — `src-tauri/src/`
- **Proxy Worker**: Detached process for proxy tunneling — `src-tauri/src/bin/proxy_server.rs`
- **Sync**: Cloud sync via S3-compatible storage — `src-tauri/src/sync/`, `donut-sync/`
- **Browsers**: Camoufox (Firefox-based) and Wayfern (Chromium-based)
## Getting Help
- **Issues**: Use for bug reports and feature requests
- **Discussions**: Use for questions and general discussion
- **Pull Requests**: Use for code contributions
## Code of Conduct
Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
## Recognition
All contributors will be recognized! We use the all-contributors specification to acknowledge everyone who contributes to the project.
---
Thank you for contributing to Donut Browser! 🍩✨
- **Issues**: Bug reports and feature requests
- **Discussions**: Questions and general discussion
+89 -35
View File
@@ -1,7 +1,9 @@
<div align="center">
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
<h1>Donut Browser</h1>
<strong>A powerful anti-detect browser that puts you in control of your browsing experience. 🍩</strong>
<strong>Open Source Anti-Detect Browser</strong>
<br>
<a href="https://donutbrowser.com">donutbrowser.com</a>
</div>
<br>
@@ -15,71 +17,109 @@
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81" alt="Codacy Grade"/>
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
</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
- Create unlimited number of local browser profiles completely isolated from each other
- Safely use multiple accounts on one device by using anti-detect browser profiles, powered by [Camoufox](https://camoufox.com)
- Proxy support with basic auth for all browsers
- Import profiles from your existing browsers
- Automatic updates for browsers
- Set Donut Browser as your default browser to control in which profile to open links
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- **VPN support** — WireGuard and OpenVPN configs per profile
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
- **Profile groups** — organize profiles and apply bulk settings
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
- **Cookie & extension management** — import/export cookies, manage extensions per profile
- **Default browser** — set Donut as your default browser and choose which profile opens each link
- **Cloud sync** — sync profiles, proxies, and groups across devices (self-hostable)
- **E2E encryption** — optional end-to-end encrypted sync with a password only you know
- **Zero telemetry** — no tracking or device fingerprinting
## Download
## Install
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
<!-- install-links-start -->
### macOS
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64.dmg) |
<!-- ## Supported Platforms
Or install via Homebrew:
-**macOS** (Apple Silicon)
-**Linux** (x64)
-**Windows** (x64) -->
```bash
brew install --cask donut
```
## Development
### Windows
### Contributing
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64-setup.exe)
See [CONTRIBUTING.md](CONTRIBUTING.md).
### Linux
## Issues
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage) |
<!-- install-links-end -->
If you face any problems while using the application, please [open an issue](https://github.com/zhom/donutbrowser/issues).
Or install via package manager:
```bash
curl -fsSL https://donutbrowser.com/install.sh | sh
```
<details>
<summary>Troubleshooting AppImage</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>
### Nix
```bash
nix run github:zhom/donutbrowser#release-start
```
## Self-Hosting Sync
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server. See the [Self-Hosting Guide](docs/self-hosting-donut-sync.md) for Docker-based setup instructions.
## Community
## Development
Have questions or want to contribute? We'd love to hear from you!
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Community
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
## Star History
<a href="https://www.star-history.com/#zhom/donutbrowser&Date">
<a href="https://www.star-history.com/?repos=zhom%2Fdonutbrowser&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
</picture>
</a>
@@ -103,6 +143,20 @@ Have questions or want to contribute? We'd love to hear from you!
<sub><b>Hassiy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/yb403">
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
<br />
<sub><b>yb403</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
<br />
<sub><b>drunkod</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/JorySeverijnse">
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
@@ -117,7 +171,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).
## License
+9 -9
View File
@@ -4,13 +4,13 @@
Thanks for helping make Donut Browser safe for everyone! ❤️
We take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to us through coordinated disclosure.
I take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to me through coordinated disclosure.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
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:
Please include as much of the information listed below as you can to help me better understand and resolve the issue:
- The type of issue (e.g., buffer overflow, injection attack, privilege escalation, or cross-site scripting)
- Full paths of source file(s) related to the manifestation of the issue
@@ -21,18 +21,18 @@ Please include as much of the information listed below as you can to help us bet
- Impact of the issue, including how an attacker might exploit the issue
- Your assessment of the severity level
This information will help us triage your report more quickly.
This information will help me triage your report more quickly.
## What to Expect
- **Response Time**: We will acknowledge receipt of your vulnerability report within 72 hours.
- **Investigation**: We will investigate the issue and provide you with updates on our progress.
- **Resolution**: We aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
- **Disclosure**: We will coordinate with you on the timing of any public disclosure.
- **Response Time**: I will acknowledge receipt of your vulnerability report within 72 hours.
- **Investigation**: I will investigate the issue and provide you with updates on my progress.
- **Resolution**: I aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
- **Disclosure**: I will coordinate with you on the timing of any public disclosure.
## Contact
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
For urgent security matters, please contact me 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

+13 -4
View File
@@ -1,12 +1,21 @@
FROM node:22-alpine AS builder
WORKDIR /build
COPY donut-sync/package.json donut-sync/tsconfig.json donut-sync/tsconfig.build.json ./
COPY donut-sync/src/ src/
RUN npm install
RUN npm run build
RUN npm prune --omit=dev
FROM node:22-alpine
WORKDIR /app
COPY package.json .
COPY dist/ dist/
COPY node_modules/ node_modules/
COPY --from=builder /build/package.json .
COPY --from=builder /build/dist/ dist/
COPY --from=builder /build/node_modules/ node_modules/
ENV NODE_ENV=production
EXPOSE 12342
USER node
CMD ["node", "dist/main"]
+9 -11
View File
@@ -2,8 +2,6 @@
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
@@ -28,33 +26,33 @@
## Project setup
```bash
$ pnpm install
pnpm install
```
## Compile and run the project
```bash
# development
$ pnpm run start
pnpm run start
# watch mode
$ pnpm run start:dev
pnpm run start:dev
# production mode
$ pnpm run start:prod
pnpm run start:prod
```
## Run tests
```bash
# unit tests
$ pnpm run test
pnpm run test
# e2e tests
$ pnpm run test:e2e
pnpm run test:e2e
# test coverage
$ pnpm run test:cov
pnpm run test:cov
```
## Deployment
@@ -64,8 +62,8 @@ When you're ready to deploy your NestJS application to production, there are som
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ pnpm install -g @nestjs/mau
$ mau deploy
pnpm install -g @nestjs/mau
mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
+10 -10
View File
@@ -15,15 +15,15 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' 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.1019.0",
"@aws-sdk/s3-request-presigner": "^3.1019.0",
"@nestjs/common": "^11.1.17",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.13",
"@nestjs/platform-express": "^11.1.13",
"@nestjs/core": "^11.1.17",
"@nestjs/platform-express": "^11.1.17",
"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.17",
"@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",
+5 -3
View File
@@ -27,7 +27,7 @@ export class AuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
if (!authHeader?.startsWith("Bearer ")) {
throw new UnauthorizedException(
"Missing or invalid authorization header",
);
@@ -38,11 +38,12 @@ export class AuthGuard implements CanActivate {
// Try SYNC_TOKEN first (self-hosted mode)
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
if (expectedToken && token === expectedToken) {
(request as any).user = {
(request as unknown as Record<string, unknown>).user = {
mode: "self-hosted",
prefix: "",
teamPrefix: null,
profileLimit: 0,
teamProfileLimit: 0,
} satisfies UserContext;
return true;
}
@@ -54,11 +55,12 @@ export class AuthGuard implements CanActivate {
algorithms: ["RS256"],
}) as jwt.JwtPayload;
(request as any).user = {
(request as unknown as Record<string, unknown>).user = {
mode: "cloud",
prefix: decoded.prefix || `users/${decoded.sub}/`,
teamPrefix: decoded.teamPrefix || null,
profileLimit: decoded.profileLimit || 0,
teamProfileLimit: decoded.teamProfileLimit || 0,
} satisfies UserContext;
return true;
} catch (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
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module.js";
function validateEnv() {
@@ -11,7 +12,10 @@ function validateEnv() {
async function bootstrap() {
validateEnv();
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// biome-ignore lint/correctness/useHookAtTopLevel: NestJS method, not a React hook
app.useBodyParser("json", { limit: "50mb" });
app.enableCors({
origin: "*",
@@ -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,
);
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ export class SyncController {
constructor(private readonly syncService: SyncService) {}
private getUserContext(req: Request): UserContext {
return (req as any).user as UserContext;
return (req as unknown as Record<string, unknown>).user as UserContext;
}
@Post("stat")
+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],
})
+204 -12
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,
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.CommonPrefixes?.length || 0;
if (count >= ctx.profileLimit) {
throw new ForbiddenException(
`Profile limit reached (${ctx.profileLimit}). Upgrade your plan for more profiles.`,
@@ -604,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.
*/
@@ -614,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}`),
);
+2 -1
View File
@@ -13,10 +13,11 @@
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"types": ["jest", "node"],
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
Generated
+1 -22
View File
@@ -37,28 +37,7 @@
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1767926800,
"narHash": "sha256-x0n73J6ufD/EhDlVdcoAmF0OQHZ+b0a2cKDc8RZyt+o=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "499e9eed88ff9494b6604205b42847e847dfeb91",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"nixpkgs": "nixpkgs"
}
},
"systems": {
+312 -37
View File
@@ -1,66 +1,341 @@
{
description = "Donut Browser Development Environment";
description = "Donut Browser development environment and quick-start commands";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
inherit system;
config.allowUnfree = true;
};
lib = pkgs.lib;
# Rust toolchain
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
};
nodejs =
if pkgs ? nodejs_23 then
pkgs.nodejs_23
else
pkgs.nodejs_22;
# System dependencies for Tauri on Linux
libraries = with pkgs; [
rustPackages = with pkgs; [
cargo
clippy
rust-analyzer
rustc
rustfmt
];
commonLibs = with pkgs; [
webkitgtk_4_1
libsoup_3
glib
gtk3
cairo
gdk-pixbuf
glib
pango
atk
at-spi2-atk
at-spi2-core
dbus
librsvg
libsoup_3
nss
nspr
libdrm
libgbm
libxkbcommon
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxcb
libxshmfence
libxtst
libxi
xdotool
libxrender
libxinerama
libxcursor
libxscrnsaver
fontconfig
freetype
fribidi
harfbuzz
expat
libglvnd
libgpg-error
e2fsprogs
gmp
zlib
stdenv.cc.cc.lib
];
packages = with pkgs; [
rustToolchain
nodejs_22
pnpm
pkg-config
cargo-tauri
openssl
# App specific tools
biome
] ++ libraries;
runtimeLibPath = lib.makeLibraryPath commonLibs;
nixLd = pkgs.stdenv.cc.bintools.dynamicLinker;
pkgConfigLibs = [
pkgs.at-spi2-atk
pkgs.at-spi2-core
pkgs.cairo
pkgs.dbus
pkgs.gdk-pixbuf
pkgs.glib
pkgs.gtk3
pkgs.libsoup_3
pkgs.libxkbcommon
pkgs.openssl
pkgs.pango
pkgs.harfbuzz
pkgs.webkitgtk_4_1
];
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.18.1";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage";
hash = "sha256-+twOKfcM5qdV3+415/PecdQUgTTe+9xwL7/qu4kCxQI=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage";
hash = "sha256-/Fj2euuxKzP6DxcV7sqShsNr6sy7Ck1iERtYcMt2hZQ=";
}
else
null;
releaseUnpacked =
if releaseAppImage != null then
pkgs.stdenvNoCC.mkDerivation {
pname = "donut-release-unpacked";
version = releaseVersion;
src = releaseAppImage;
dontUnpack = true;
nativeBuildInputs = [ pkgs.xz ];
installPhase = ''
runHook preInstall
cp "$src" ./donut.AppImage
chmod +x ./donut.AppImage
./donut.AppImage --appimage-extract >/dev/null
mkdir -p "$out"
cp -a ./squashfs-root "$out/"
runHook postInstall
'';
}
else
null;
releaseWrapped =
if releaseAppImage != null then
pkgs.appimageTools.wrapType2 {
pname = "donut";
version = releaseVersion;
src = releaseAppImage;
extraPkgs = _: commonLibs;
extraInstallCommands = ''
for bin in "$out"/bin/*; do
if [ -f "$bin" ]; then
mv "$bin" "$out/bin/donut-release"
break
fi
done
'';
}
else
null;
releaseLauncher =
if releaseUnpacked != null then
pkgs.writeShellApplication {
name = "donut-release-start";
runtimeInputs = with pkgs; [
coreutils
xdg-utils
];
text = ''
set -euo pipefail
if [ -x "${releaseWrapped}/bin/donut-release" ]; then
if "${releaseWrapped}/bin/donut-release" "$@"; then
exit 0
fi
echo "Wrapped AppImage failed, retrying with direct AppRun..." >&2
fi
export LD_LIBRARY_PATH="${releaseUnpacked}/squashfs-root/usr/lib:${releaseUnpacked}/squashfs-root/usr/lib64:${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
export NIX_LD_LIBRARY_PATH="$LD_LIBRARY_PATH"
export LIBRARY_PATH="$LD_LIBRARY_PATH"
export XDG_DATA_DIRS="${releaseUnpacked}/squashfs-root/usr/share:''${XDG_DATA_DIRS:-}"
exec "${releaseUnpacked}/squashfs-root/AppRun" "$@"
'';
}
else
pkgs.writeShellApplication {
name = "donut-release-start";
text = ''
echo "Release launcher is supported only on Linux (x86_64/aarch64)."
exit 1
'';
};
mkApp = name: text:
let
app = pkgs.writeShellApplication {
inherit name;
runtimeInputs = with pkgs; [
bash
coreutils
findutils
git
gnugrep
gnused
curl
gcc
pkg-config
openssl
cargo
clippy
rustc
rustfmt
nodejs
pnpm
cargo-tauri
];
text = ''
export NODE_ENV=development
export NIX_LD="${nixLd}"
export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
${text}
'';
};
in
{
type = "app";
program = "${app}/bin/${name}";
};
in
{
devShells.default = pkgs.mkShell {
buildInputs = packages;
packages = with pkgs; [
nodejs
pnpm
cargo-tauri
pkg-config
openssl
git
bashInteractive
gnumake
clang
llvmPackages.bintools
python3
curl
wget
unzip
zip
xz
biome
docker
] ++ rustPackages ++ commonLibs;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
export XDG_DATA_DIRS=${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}:$XDG_DATA_DIRS
echo "🍩 Donut Browser Dev Environment Loaded!"
echo "Node: $(node --version)"
echo "Rust: $(rustc --version)"
echo "Tauri CLI: $(cargo-tauri --version)"
export NODE_ENV=development
export NIX_LD="${nixLd}"
export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share:${pkgs.gtk3}/share:''${XDG_DATA_DIRS:-}"
echo "Donut Browser dev shell ready."
echo "Quick start:"
echo " nix run .#setup"
echo " nix run .#tauri-dev"
echo " nix run .#full-dev"
echo " nix run .#build"
echo " nix run .#test"
echo " nix run .#release-start"
'';
};
}
);
apps.info = mkApp "donut-info" ''
set -euo pipefail
echo "Node: $(node --version)"
echo "pnpm: $(pnpm --version)"
echo "Rust: $(rustc --version)"
echo "Cargo: $(cargo --version)"
echo "Tauri CLI: $(cargo-tauri --version)"
'';
apps.deps = mkApp "donut-deps" ''
set -euo pipefail
pnpm install
'';
apps.dev = mkApp "donut-dev" ''
set -euo pipefail
pnpm dev
'';
apps."tauri-dev" = mkApp "donut-tauri-dev" ''
set -euo pipefail
pnpm tauri dev
'';
apps."full-dev" = mkApp "donut-full-dev" ''
set -euo pipefail
chmod +x ./scripts/dev.sh
./scripts/dev.sh
'';
apps.build = mkApp "donut-build" ''
set -euo pipefail
pnpm build
(cd src-tauri && cargo build)
'';
apps.start = mkApp "donut-start" ''
set -euo pipefail
pnpm start
'';
apps.test = mkApp "donut-test" ''
set -euo pipefail
pnpm format && pnpm lint && pnpm test
'';
apps.setup = mkApp "donut-setup" ''
set -euo pipefail
if [ ! -f "package.json" ]; then
echo "package.json not found. Run this from the donutbrowser repo root."
exit 1
fi
pnpm install
pnpm copy-proxy-binary
echo "Setup complete."
echo "Run the app with:"
echo " nix run .#tauri-dev"
echo "Or run full local stack (sync + minio + tauri):"
echo " nix run .#full-dev"
'';
apps."release-start" = {
type = "app";
program = "${releaseLauncher}/bin/donut-release-start";
};
apps.default = self.apps.${system}.setup;
});
}
+29 -25
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.14.0",
"version": "0.18.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,58 +45,61 @@
"@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",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"ahooks": "^3.9.6",
"ahooks": "^3.9.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"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",
"next": "^16.1.6",
"i18next": "^26.0.0",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"next": "^16.2.1",
"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": "^17.0.0",
"react-icons": "^5.6.0",
"recharts": "3.8.1",
"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",
"@types/color": "^4.2.0",
"@types/node": "^25.2.3",
"@biomejs/biome": "2.4.9",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "~2.10.1",
"@types/color": "^4.2.1",
"@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.4.0",
"tailwindcss": "^4.2.2",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
"typescript": "~6.0.2"
},
"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"
]
}
}
+1897 -1983
View File
File diff suppressed because it is too large Load Diff
+1310 -598
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.14.0"
version = "0.18.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,14 +57,14 @@ 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"
lzma-rs = "0"
msi-extract = "0"
uuid = { version = "1.20", features = ["v4", "serde"] }
uuid = { version = "1.23", features = ["v4", "serde"] }
url = "2.5"
blake3 = "1"
globset = "0.4"
@@ -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"
@@ -89,8 +95,8 @@ async-socks5 = "0.6"
playwright = { git = "https://github.com/sctg-development/playwright-rust", branch = "master" }
# Wayfern CDP integration
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
rusqlite = { version = "0.38", features = ["bundled"] }
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
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.13", 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"
tao = "0.35"
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

+56 -20
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) {
@@ -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)
}
@@ -1131,18 +1138,47 @@ impl ApiClient {
log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json");
let url = "https://donutbrowser.com/wayfern.json";
let response = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
let mut last_err = None;
let mut version_info: Option<WayfernVersionInfo> = None;
if !response.status().is_success() {
return Err(format!("Failed to fetch Wayfern version: {}", response.status()).into());
for attempt in 1..=3 {
match self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await
{
Ok(response) => {
if !response.status().is_success() {
last_err = Some(format!("HTTP {}", response.status()));
} else {
match response.json::<WayfernVersionInfo>().await {
Ok(info) => {
version_info = Some(info);
break;
}
Err(e) => last_err = Some(format!("Failed to parse response: {e}")),
}
}
}
Err(e) => {
log::warn!("Wayfern fetch attempt {attempt}/3 failed: {e}");
last_err = Some(e.to_string());
}
}
if attempt < 3 {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
let version_info: WayfernVersionInfo = response.json().await?;
let version_info = version_info.ok_or_else(|| {
format!(
"Failed to fetch Wayfern version after 3 attempts: {}",
last_err.unwrap_or_default()
)
})?;
log::info!("Fetched Wayfern version: {}", version_info.version);
// Cache the results (unless bypassing cache)
+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 }))
}
+112 -29
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());
@@ -701,7 +704,8 @@ impl AppAutoUpdater {
let total_size = response.content_length().unwrap_or(0);
log::info!("Silent download size: {} bytes", total_size);
let mut file = fs::File::create(&file_path)?;
let raw_file = fs::File::create(&file_path)?;
let mut file = std::io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -709,6 +713,7 @@ impl AppAutoUpdater {
let chunk = chunk?;
file.write_all(&chunk)?;
}
std::io::Write::flush(&mut file)?;
log::info!("Silent download completed: {}", file_path.display());
Ok(file_path)
@@ -741,13 +746,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 +1446,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 +1520,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 +1604,20 @@ rm "{}"
#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
if crate::app_dirs::is_portable() {
log::info!("App auto-updates disabled in portable mode");
return Ok(None);
}
// 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 +1785,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 +2010,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);
}
+226
View File
@@ -0,0 +1,226 @@
use directories::BaseDirs;
use std::path::PathBuf;
use std::sync::OnceLock;
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
static PORTABLE_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
fn base_dirs() -> &'static BaseDirs {
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
}
/// Returns the portable base directory if a `.portable` marker exists next to the executable.
fn portable_dir() -> Option<&'static PathBuf> {
PORTABLE_DIR
.get_or_init(|| {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|p| p.to_path_buf()))
.filter(|dir| dir.join(".portable").exists())
})
.as_ref()
}
/// Returns true if the app is running in portable mode.
pub fn is_portable() -> bool {
portable_dir().is_some()
}
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);
}
if let Some(dir) = portable_dir() {
return dir.join("data");
}
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);
}
if let Some(dir) = portable_dir() {
return dir.join("cache");
}
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);
}
}
+275 -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,80 @@ 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) => {
// Skip if this browser-version pair is already being downloaded
if crate::downloader::is_downloading(&browser, &new_version) {
log::info!(
"Browser {browser} {new_version} is already being downloaded, skipping duplicate"
);
return;
}
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 +204,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 +318,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 +400,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 +486,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 +682,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 +690,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" => {
+213 -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,33 @@ 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)")),
)
.subcommand(
Command::new("mcp-bridge")
.about("Bridge stdio MCP to a local HTTP MCP server")
.arg(
Arg::new("url")
.required(true)
.help("HTTP MCP server URL (e.g. http://127.0.0.1:51080/mcp/TOKEN)"),
),
)
.get_matches();
if let Some(proxy_matches) = matches.subcommand_matches("proxy") {
@@ -199,8 +231,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 +369,182 @@ 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 if let Some(bridge_matches) = matches.subcommand_matches("mcp-bridge") {
let url = bridge_matches
.get_one::<String>("url")
.expect("url is required")
.clone();
// Suppress debug logging for bridge mode — stderr noise confuses MCP clients
log::set_max_level(log::LevelFilter::Warn);
// stdio↔HTTP MCP bridge: translates stdio JSON-RPC to Streamable HTTP transport
let client = reqwest::Client::new();
let stdin = tokio::io::stdin();
let reader = tokio::io::BufReader::new(stdin);
let mut session_id: Option<String> = None;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let mut lines = reader.lines();
let mut stdout = tokio::io::stdout();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
// Check if this is a notification (no "id" field) to handle 202 responses
let is_notification = serde_json::from_str::<serde_json::Value>(&line)
.ok()
.map(|v| v.get("id").is_none() || v["id"].is_null())
.unwrap_or(false);
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept", "application/json");
if let Some(sid) = &session_id {
req = req.header("mcp-session-id", sid);
}
match req.body(line).send().await {
Ok(resp) => {
// Capture session ID from initialize response
if let Some(sid) = resp.headers().get("mcp-session-id") {
if let Ok(s) = sid.to_str() {
session_id = Some(s.to_string());
}
}
// Notifications return 202 with no body — don't write anything
if is_notification {
continue;
}
if let Ok(body) = resp.text().await {
if !body.is_empty() {
let _ = stdout.write_all(body.as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
let _ = stdout.flush().await;
}
}
}
Err(e) => {
if !is_notification {
let err = serde_json::json!({
"jsonrpc": "2.0",
"id": null,
"error": {"code": -32000, "message": format!("HTTP error: {e}")},
});
let _ = stdout.write_all(err.to_string().as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
let _ = stdout.flush().await;
}
}
}
}
} else {
log::error!("No command specified");
process::exit(1);
+156 -557
View File
File diff suppressed because it is too large Load Diff
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;
+104 -36
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;
@@ -22,7 +21,6 @@ pub struct CamoufoxConfig {
pub block_images: Option<bool>,
pub block_webrtc: Option<bool>,
pub block_webgl: Option<bool>,
pub executable_path: Option<String>,
pub fingerprint: Option<String>, // JSON string of the complete fingerprint config
pub randomize_fingerprint_on_launch: Option<bool>, // Generate new fingerprint on every launch
pub os: Option<String>, // Operating system for fingerprint generation: "windows", "macos", or "linux"
@@ -40,7 +38,6 @@ impl Default for CamoufoxConfig {
block_images: None,
block_webrtc: None,
block_webgl: None,
executable_path: None,
fingerprint: None,
randomize_fingerprint_on_launch: None,
os: None,
@@ -57,6 +54,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 +64,7 @@ struct CamoufoxInstance {
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
}
struct CamoufoxManagerInner {
@@ -74,7 +73,6 @@ struct CamoufoxManagerInner {
pub struct CamoufoxManager {
inner: Arc<AsyncMutex<CamoufoxManagerInner>>,
base_dirs: BaseDirs,
}
impl CamoufoxManager {
@@ -83,7 +81,6 @@ impl CamoufoxManager {
inner: Arc::new(AsyncMutex::new(CamoufoxManagerInner {
instances: HashMap::new(),
})),
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
}
}
@@ -91,15 +88,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
@@ -110,13 +127,9 @@ impl CamoufoxManager {
config: &CamoufoxConfig,
) -> 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)
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
};
let executable_path = BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Build the config using CamoufoxConfigBuilder
let mut builder = CamoufoxConfigBuilder::new()
@@ -203,13 +216,9 @@ impl CamoufoxManager {
};
// Get executable path
let executable_path = if let Some(path) = &config.executable_path {
PathBuf::from(path)
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
};
let executable_path = BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Parse the fingerprint config JSON
let fingerprint_config: HashMap<String, serde_json::Value> =
@@ -233,6 +242,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 +300,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 +308,7 @@ impl CamoufoxManager {
processId: process_id,
profilePath: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
{
@@ -360,8 +374,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 +426,7 @@ impl CamoufoxManager {
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
}));
}
}
@@ -419,7 +437,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 +455,7 @@ impl CamoufoxManager {
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
},
);
@@ -443,6 +464,7 @@ impl CamoufoxManager {
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
cdp_port,
}));
}
@@ -453,7 +475,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 +500,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 +515,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 +586,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 +616,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 +636,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,
+350 -35
View File
@@ -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,14 @@ struct SyncTokenResponse {
sync_token: String,
}
#[derive(Debug, Deserialize)]
struct WayfernTokenResponse {
token: String,
#[serde(rename = "expiresIn")]
#[allow(dead_code)]
expires_in: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationItem {
pub code: String,
@@ -97,6 +113,7 @@ pub struct CloudAuthManager {
client: Client,
state: Mutex<Option<CloudAuthState>>,
refresh_lock: tokio::sync::Mutex<()>,
wayfern_token: Mutex<Option<String>>,
}
lazy_static! {
@@ -110,6 +127,7 @@ impl CloudAuthManager {
client: Client::new(),
state: Mutex::new(state),
refresh_lock: tokio::sync::Mutex::new(()),
wayfern_token: Mutex::new(None),
}
}
@@ -247,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")
}
@@ -570,6 +588,12 @@ impl CloudAuthManager {
}
pub async fn logout(&self) -> Result<(), String> {
// Clear wayfern token
self.clear_wayfern_token().await;
// Disconnect profile lock manager
crate::team_lock::PROFILE_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();
@@ -602,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()
@@ -620,7 +679,7 @@ impl CloudAuthManager {
/// API call with 401 retry: if first attempt gets 401, refresh access token and retry once.
/// Uses refresh_lock to prevent concurrent token rotations from racing.
async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
pub async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
where
F: Fn(String) -> Fut + Send,
Fut: std::future::Future<Output = Result<T, String>> + Send,
@@ -651,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),
}
}
@@ -794,13 +854,13 @@ impl CloudAuthManager {
.await
}
/// Fetch state list for a country from the cloud backend
pub async fn fetch_states(&self, country: &str) -> Result<Vec<LocationItem>, String> {
/// Fetch region list for a country from the cloud backend
pub async fn fetch_regions(&self, country: &str) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/states?country={}",
"{CLOUD_API_URL}/api/proxy/locations/regions?country={}",
country
);
let client = reqwest::Client::new();
@@ -810,37 +870,40 @@ impl CloudAuthManager {
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch states: {e}"))?;
.map_err(|e| format!("Failed to fetch regions: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("States fetch failed ({status}): {body}"));
return Err(format!("Regions fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse states: {e}"))
.map_err(|e| format!("Failed to parse regions: {e}"))
}
})
.await
}
/// Fetch city list for a country+state from the cloud backend
/// Fetch city list for a country, optionally filtered by region
pub async fn fetch_cities(
&self,
country: &str,
state: &str,
region: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let state = state.to_string();
let region = region.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}&state={}",
country, state
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
let client = reqwest::Client::new();
async move {
let response = client
@@ -865,8 +928,108 @@ impl CloudAuthManager {
.await
}
/// Fetch ISP list for a country, optionally filtered by region and city
pub async fn fetch_isps(
&self,
country: &str,
region: Option<&str>,
city: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
let city = city.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/isps?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
if let Some(ref c) = city {
url.push_str(&format!("&city={}", c));
}
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch ISPs: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("ISPs fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse ISPs: {e}"))
}
})
.await
}
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.has_active_paid_subscription().await {
self.clear_wayfern_token().await;
return Ok(());
}
let token = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
let client = reqwest::Client::new();
async move {
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to request wayfern token: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Wayfern token request failed ({status}): {body}"));
}
let result: WayfernTokenResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse wayfern token response: {e}"))?;
Ok(result.token)
}
})
.await?;
let mut wt = self.wayfern_token.lock().await;
*wt = Some(token);
log::info!("Wayfern token acquired");
Ok(())
}
/// Get the current wayfern token, if any.
pub async fn get_wayfern_token(&self) -> Option<String> {
let wt = self.wayfern_token.lock().await;
wt.clone()
}
/// Clear the cached wayfern token.
pub async fn clear_wayfern_token(&self) {
let mut wt = self.wayfern_token.lock().await;
*wt = None;
}
/// Background loop that refreshes the sync token periodically
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
let mut wayfern_refresh_counter: u32 = 0;
loop {
tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes
@@ -874,6 +1037,8 @@ impl CloudAuthManager {
continue;
}
wayfern_refresh_counter += 1;
// Proactively refresh the access token if it's expired or expiring soon.
// This runs first so subsequent API calls use a fresh token.
if let Ok(Some(token)) = Self::load_access_token() {
@@ -905,9 +1070,28 @@ impl CloudAuthManager {
log::debug!("Failed to refresh cloud profile: {e}");
}
// Reconnect profile lock manager if needed
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
if auth_state.user.plan != "free" && !crate::team_lock::PROFILE_LOCK.is_connected().await {
crate::team_lock::PROFILE_LOCK.connect().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
}
}
@@ -943,11 +1127,21 @@ pub async fn cloud_verify_otp(
Ok(None) => log::warn!("Sync token not available despite active subscription"),
Err(e) => log::error!("Failed to pre-fetch sync token after login: {e}"),
}
// Request wayfern token for paid users
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token after login: {e}");
}
}
// Sync cloud proxy after login
CLOUD_AUTH.sync_cloud_proxy().await;
// Connect profile lock manager for paid users
if state.user.plan != "free" {
crate::team_lock::PROFILE_LOCK.connect().await;
}
let _ = crate::events::emit_empty("cloud-auth-changed");
let _ = &app_handle;
@@ -977,6 +1171,9 @@ pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
}
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
crate::proxy_manager::PROXY_MANAGER.remove_cloud_proxies();
let _ = crate::events::emit_empty("cloud-auth-changed");
Ok(())
}
@@ -986,33 +1183,59 @@ pub async fn cloud_has_active_subscription() -> Result<bool, String> {
Ok(CLOUD_AUTH.has_active_paid_subscription().await)
}
#[tauri::command]
pub async fn cloud_get_wayfern_token() -> Result<Option<String>, String> {
Ok(CLOUD_AUTH.get_wayfern_token().await)
}
#[tauri::command]
pub async fn cloud_refresh_wayfern_token() -> Result<Option<String>, String> {
CLOUD_AUTH.request_wayfern_token().await?;
Ok(CLOUD_AUTH.get_wayfern_token().await)
}
#[tauri::command]
pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_countries().await
}
#[tauri::command]
pub async fn cloud_get_states(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_states(&country).await
pub async fn cloud_get_regions(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_regions(&country).await
}
#[tauri::command]
pub async fn cloud_get_cities(country: String, state: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, &state).await
pub async fn cloud_get_cities(
country: String,
region: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await
}
#[tauri::command]
pub async fn cloud_get_isps(
country: String,
region: Option<String>,
city: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH
.fetch_isps(&country, region.as_deref(), city.as_deref())
.await
}
#[tauri::command]
pub async fn create_cloud_location_proxy(
name: String,
country: String,
state: Option<String>,
region: Option<String>,
city: Option<String>,
isp: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
// If no cloud proxy exists yet, attempt to sync it first
if !PROXY_MANAGER.has_cloud_proxy() {
CLOUD_AUTH.sync_cloud_proxy().await;
}
PROXY_MANAGER.create_cloud_location_proxy(name, country, state, city)
PROXY_MANAGER.create_cloud_location_proxy(name, country, region, city, isp)
}
#[derive(Debug, Serialize)]
@@ -1020,22 +1243,108 @@ pub struct CloudProxyUsage {
pub used_mb: i64,
pub limit_mb: i64,
pub remaining_mb: i64,
pub recurring_limit_mb: i64,
pub extra_limit_mb: i64,
}
#[derive(Debug, Deserialize)]
struct ProxyUsageResponse {
#[serde(rename = "usedMb")]
used_mb: i64,
#[serde(rename = "limitMb")]
limit_mb: i64,
#[serde(rename = "remainingMb")]
remaining_mb: i64,
#[serde(rename = "recurringLimitMb", default)]
recurring_limit_mb: i64,
#[serde(rename = "extraLimitMb", default)]
extra_limit_mb: i64,
}
#[tauri::command]
pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String> {
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {
let used = auth.user.proxy_bandwidth_used_mb;
let limit = auth.user.proxy_bandwidth_limit_mb;
Ok(Some(CloudProxyUsage {
used_mb: used,
limit_mb: limit,
remaining_mb: (limit - used).max(0),
}))
let (has_proxy, cached_recurring, cached_extra) = {
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth)
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 =>
{
(
true,
auth.user.proxy_bandwidth_limit_mb,
auth.user.proxy_bandwidth_extra_mb,
)
}
_ => return Ok(None),
}
};
if !has_proxy {
return Ok(None);
}
// Fetch live usage from the API
match CLOUD_AUTH
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/proxy/usage");
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch proxy usage: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Proxy usage API returned status {}",
response.status()
));
}
response
.json::<ProxyUsageResponse>()
.await
.map_err(|e| format!("Failed to parse proxy usage: {e}"))
}
})
.await
{
Ok(usage) => Ok(Some(CloudProxyUsage {
used_mb: usage.used_mb,
limit_mb: usage.limit_mb,
remaining_mb: usage.remaining_mb,
recurring_limit_mb: if usage.recurring_limit_mb > 0 {
usage.recurring_limit_mb
} else {
cached_recurring
},
extra_limit_mb: if usage.recurring_limit_mb > 0 {
usage.extra_limit_mb
} else {
cached_extra
},
})),
Err(e) => {
log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}");
// Fallback to cached values
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth) => {
let used = auth.user.proxy_bandwidth_used_mb;
let total = cached_recurring + cached_extra;
Ok(Some(CloudProxyUsage {
used_mb: used,
limit_mb: total,
remaining_mb: (total - used).max(0),
recurring_limit_mb: cached_recurring,
extra_limit_mb: cached_extra,
}))
}
_ => Ok(None),
}
}
_ => Ok(None),
}
}
@@ -1071,9 +1380,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.
pub 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);
}
}
+32 -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");
@@ -319,6 +340,9 @@ pub fn is_autostart_enabled() -> bool {
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
+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();
}
}
+58 -8
View File
@@ -1,4 +1,5 @@
// Daemon Spawn - Start the daemon from the GUI
// Currently disabled; will be re-enabled in the future
use serde::Deserialize;
use std::fs;
@@ -9,6 +10,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 +53,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 +64,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 +129,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 +265,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 +335,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
+94 -48
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,12 @@ 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");
// Never remove a directory if a download is in progress for this browser/version
if crate::downloader::is_downloading(browser, version) {
return Ok(());
}
let binaries_dir = crate::app_dirs::binaries_dir();
let version_dir = binaries_dir.join(browser).join(version);
@@ -599,6 +598,12 @@ impl DownloadedBrowsersRegistry {
continue;
}
// Skip if a download is in progress for this browser/version
if crate::downloader::is_downloading(browser_name, version_name) {
has_non_empty_versions = true;
continue;
}
// Check if version directory is empty
match fs::read_dir(&version_path) {
Ok(mut entries) => {
@@ -806,19 +811,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 +1157,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();
@@ -1203,12 +1248,13 @@ pub async fn ensure_active_browsers_downloaded(
// Check if any version is already downloaded
let existing = registry.get_downloaded_versions(browser);
if !existing.is_empty() {
log::debug!(
"Skipping {browser}: already have {} version(s) downloaded",
log::info!(
"ensure_active: Skipping {browser}: already have {} version(s) downloaded",
existing.len()
);
continue;
}
log::info!("ensure_active: No {browser} versions found, will download");
// Get the latest release type for this browser
let release_types = match version_manager.get_browser_release_types(browser).await {
+239 -378
View File
@@ -42,7 +42,10 @@ pub struct Downloader {
impl Downloader {
fn new() -> Self {
Self {
client: Client::new(),
client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(),
registry: crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(),
version_service: crate::browser_version_manager::BrowserVersionManager::instance(),
@@ -56,7 +59,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 +70,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 +127,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 +152,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 +178,6 @@ impl Downloader {
Ok(download_url)
}
_ => {
// For other browsers, use the provided URL
Ok(download_info.url.clone())
}
}
}
@@ -239,110 +204,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,
@@ -446,13 +307,15 @@ impl Downloader {
let file_path = dest_path.join(&download_info.filename);
// Resolve the actual download URL
log::info!(
"Resolving download URL for {} {}",
browser_type.as_str(),
version
);
let download_url = self
.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");
log::info!("Download URL resolved: {}", download_url);
// Determine if we have a partial file to resume
let mut existing_size: u64 = 0;
@@ -460,9 +323,10 @@ impl Downloader {
existing_size = meta.len();
}
// 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 = {
// Build request with retry logic for transient network errors.
let max_retries = 3u32;
let mut response: Option<reqwest::Response> = None;
for attempt in 0..=max_retries {
let mut request = self
.client
.get(&download_url)
@@ -475,27 +339,43 @@ impl Downloader {
request = request.header("Range", format!("bytes={existing_size}-"));
}
let first = request.send().await?;
if first.status().as_u16() == 416 && existing_size > 0 {
// Partial file on disk is not acceptable to the server — remove it and retry from scratch
let _ = std::fs::remove_file(&file_path);
existing_size = 0;
let retry = self
.client
.get(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
)
.send()
.await?;
retry
} else {
first
log::info!("Sending download request (attempt {})...", attempt + 1);
match request.send().await {
Ok(resp) => {
log::info!(
"Download response received: status={}, content-length={:?}",
resp.status(),
resp.content_length()
);
if resp.status().as_u16() == 416 && existing_size > 0 {
let _ = std::fs::remove_file(&file_path);
existing_size = 0;
log::warn!("Download returned 416, retrying without Range header");
continue;
}
response = Some(resp);
break;
}
Err(e) => {
let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
if is_retryable && attempt < max_retries {
let delay = 2u64.pow(attempt);
log::warn!(
"Download attempt {} failed ({}), retrying in {}s...",
attempt + 1,
e,
delay
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
} else {
return Err(format!("Download failed after {} attempts: {}", attempt + 1, e).into());
}
}
}
};
}
let response = response.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
"Download failed: no response received".into()
})?;
// Check if the response is successful (200 OK or 206 Partial Content)
if !(response.status().is_success() || response.status().as_u16() == 206) {
@@ -533,6 +413,20 @@ impl Downloader {
existing_size = 0;
}
// If the existing file already matches the total size, skip the download
if existing_size > 0 {
if let Some(total) = total_size {
if existing_size >= total {
log::info!(
"Archive {} already complete ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
let mut downloaded = existing_size;
let start_time = std::time::Instant::now();
let mut last_update = start_time;
@@ -548,11 +442,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(),
@@ -567,12 +457,16 @@ impl Downloader {
let _ = events::emit("download-progress", &progress);
// Open file in append mode (resuming) or create new
// Open file in append mode (resuming) or create new.
// Wrap in BufWriter with a large buffer to reduce the number of disk writes,
// which dramatically improves download speed on Windows (NTFS + Defender overhead).
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
use std::io::Write;
let raw_file = OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)?;
let mut file = io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -585,7 +479,7 @@ impl Downloader {
}
}
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
file.write_all(&chunk)?;
downloaded += chunk.len() as u64;
let now = std::time::Instant::now();
@@ -614,11 +508,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(),
@@ -636,6 +526,9 @@ impl Downloader {
}
}
// Flush remaining buffered data to disk
file.flush()?;
Ok(file_path)
}
@@ -652,6 +545,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 +602,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 +730,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 +748,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 +908,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,10 +934,51 @@ 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)
}
}
/// Check if a specific browser-version pair is currently being downloaded
pub fn is_downloading(browser: &str, version: &str) -> bool {
let download_key = format!("{browser}-{version}");
let downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.contains(&download_key)
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -1040,85 +1013,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 +1039,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 +1097,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());
+332
View File
@@ -0,0 +1,332 @@
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() {
// Clear global state to avoid interference from other tests
EPHEMERAL_DIRS.lock().unwrap().clear();
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
+139 -99
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(());
};
@@ -212,6 +207,20 @@ impl Extractor {
match extraction_result {
Ok(path) => {
// Remove quarantine attributes on macOS to prevent
// "app was prevented from modifying data" prompts
#[cfg(target_os = "macos")]
{
let _ = tokio::process::Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
dest_dir.to_str().unwrap_or("."),
])
.output()
.await;
}
log::info!(
"Successfully extracted {} {} to: {}",
browser_type.as_str(),
@@ -237,22 +246,21 @@ 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
// Check file extension first for container formats (DMG, MSI) whose internal
// compression makes magic bytes unreliable
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());
match ext.to_lowercase().as_str() {
"dmg" => return Ok("dmg".to_string()),
"msi" => return Ok("msi".to_string()),
_ => {}
}
}
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
let mut buffer = [0u8; 12];
file.read_exact(&mut buffer)?;
// Check magic numbers for different file types
// Check magic numbers for other file types
match &buffer[0..4] {
[0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => {
return Ok("zip".to_string())
@@ -362,16 +370,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 +399,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 +425,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 +447,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 +611,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 +639,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 +662,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 +699,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 +719,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 +757,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 +934,6 @@ impl Extractor {
"firefox.exe",
"chrome.exe",
"chromium.exe",
"zen.exe",
"brave.exe",
"camoufox.exe",
"wayfern.exe",
];
@@ -937,8 +1001,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 +1051,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 +1089,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);
}
}
+877 -133
View File
File diff suppressed because it is too large Load Diff
+2718 -187
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
}
}
+356 -124
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" {
@@ -94,29 +94,6 @@ impl ProfileManager {
crate::camoufox_manager::CamoufoxConfig::default()
});
// Always ensure executable_path is set to the user's binary location
if config.executable_path.is_none() {
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(browser);
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());
log::info!("Set Camoufox executable path: {:?}", config.executable_path);
}
// Pass upstream proxy information to config for fingerprint generation
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
@@ -163,6 +140,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 +149,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
@@ -211,28 +196,6 @@ impl ProfileManager {
});
// Always ensure executable_path is set to the user's binary location
if config.executable_path.is_none() {
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(browser);
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());
log::info!("Set Wayfern executable path: {:?}", config.executable_path);
}
// Pass upstream proxy information to config for fingerprint generation
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
@@ -276,6 +239,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 +248,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 +292,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 +301,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 +323,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
@@ -398,8 +380,21 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(metadata_file)?;
let profile: BrowserProfile = serde_json::from_str(&content)?;
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
if profile.host_os.is_none() {
let inferred_os = profile.resolved_os().map(str::to_string);
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
}
}
}
profiles.push(profile);
}
}
@@ -466,15 +461,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());
@@ -539,6 +534,29 @@ impl ProfileManager {
Ok(())
}
/// Delete a profile from the local filesystem only, without triggering remote sync deletion.
/// Used when a profile was deleted on another device and the local copy should be cleaned up.
pub fn delete_profile_local_only(
&self,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles_dir = self.get_profiles_dir();
let profile_dir = profiles_dir.join(profile_id);
if profile_dir.exists() {
fs::remove_dir_all(&profile_dir)?;
log::info!("Deleted local profile {} (tombstoned remotely)", profile_id);
}
if let Err(e) = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
.cleanup_unused_binaries()
{
log::warn!("Failed to cleanup binaries after tombstone deletion: {e}");
}
let _ = crate::events::emit_empty("profiles-changed");
Ok(())
}
pub fn update_profile_version(
&self,
_app_handle: &tauri::AppHandle,
@@ -620,7 +638,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 +735,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 +776,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 +788,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 +846,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 +863,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 +884,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 +893,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 +1062,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 +1074,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 +1127,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 +1216,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 +1233,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 +1241,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 +1250,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 +1283,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 +1290,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 +1361,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 +1406,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 +1440,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 +1485,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 +1530,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 +1578,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 +1751,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 +1914,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 +1952,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 +1967,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 +1998,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 +2035,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 +2080,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 +2107,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 +2122,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 +2150,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 +2173,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}"))
}
+66 -2
View File
@@ -13,7 +13,15 @@ pub enum SyncStatus {
Error,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
pub enum SyncMode {
#[default]
Disabled,
Regular,
Encrypted,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct BrowserProfile {
pub id: uuid::Uuid,
pub name: String,
@@ -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,72 @@ 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")
}
/// Resolve the OS this profile was created on. Checks `host_os` first,
/// then falls back to the fingerprint config's `os` field (for profiles
/// created before `host_os` was introduced or synced without it).
pub fn resolved_os(&self) -> Option<&str> {
self
.host_os
.as_deref()
.or_else(|| self.camoufox_config.as_ref().and_then(|c| c.os.as_deref()))
.or_else(|| self.wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
}
/// Returns true when the profile was created on a different OS than the current host.
/// Checks `host_os` first, then falls back to the browser config's `os` field.
pub fn is_cross_os(&self) -> bool {
match self.resolved_os() {
Some(os) => 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
}
}
+262 -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,192 @@ 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 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 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 +722,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 +734,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 +768,6 @@ impl ProfileImporter {
}
}
// Tauri commands
#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
let importer = ProfileImporter::instance();
@@ -627,17 +778,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 +825,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 +833,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 +856,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 +933,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 +955,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 +993,9 @@ 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");
// Use a browser name that is guaranteed to have no downloaded versions,
// since the global registry singleton may contain real data from the system.
let result = importer.get_default_version_for_browser("nonexistent_browser_xyz");
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();
}
+145 -141
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 {
@@ -840,6 +883,87 @@ fn build_reqwest_client_with_proxy(
Ok(client_builder.proxy(proxy).build()?)
}
/// Handle a single proxy connection (used by both the proxy worker and in-process proxy checks).
pub async fn handle_proxy_connection(
mut stream: tokio::net::TcpStream,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) {
let _ = stream.set_nodelay(true);
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {}
Ok(n) => {
let request_start_upper = String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
let is_connect = request_start_upper.starts_with("CONNECT");
if is_connect {
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
let mut remaining = [0u8; 4096];
let mut total_read = n;
let max_reads = 100;
let mut reads = 0;
loop {
if reads >= max_reads {
break;
}
match stream.read(&mut remaining).await {
Ok(0) => {
if full_request.ends_with(b"\r\n\r\n")
|| full_request.ends_with(b"\n\n")
|| total_read > 0
{
break;
}
return;
}
Ok(m) => {
reads += 1;
total_read += m;
full_request.extend_from_slice(&remaining[..m]);
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
}
Err(_) => {
if total_read > 0 {
break;
}
return;
}
}
}
let _ =
handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await;
return;
}
// Non-CONNECT: prepend consumed bytes and pass to hyper
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
prepended_pos: 0,
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service =
service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone()));
let _ = http1::Builder::new().serve_connection(io, service).await;
}
Err(_) => {}
}
}
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
log::error!(
"Proxy worker starting, looking for config id: {}",
@@ -1003,143 +1127,17 @@ 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 {
match listener.accept().await {
Ok((mut stream, peer_addr)) => {
// Enable TCP_NODELAY to ensure small packets are sent immediately
// This is critical for CONNECT responses to be sent before tunneling begins
let _ = stream.set_nodelay(true);
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
Ok((stream, _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
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(n) => {
// Check if this looks like a CONNECT request
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
let request_start_upper =
String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
let is_connect = request_start_upper.starts_with("CONNECT");
log::error!(
"DEBUG: Read {} bytes, starts with: {:?}, is_connect: {}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(20)]),
is_connect
);
if is_connect {
// Handle CONNECT request manually for tunneling
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
// Read the rest of the CONNECT request until we have the full headers
// CONNECT requests end with \r\n\r\n (or \n\n)
let mut remaining = [0u8; 4096];
let mut total_read = n;
let max_reads = 100; // Prevent infinite loop
let mut reads = 0;
loop {
if reads >= max_reads {
log::error!("DEBUG: Max reads reached, breaking");
break;
}
match stream.read(&mut remaining).await {
Ok(0) => {
// Connection closed, but we might have a complete request
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// If we have some data, try to process it anyway
if total_read > 0 {
break;
}
return; // No data at all
}
Ok(m) => {
reads += 1;
total_read += m;
full_request.extend_from_slice(&remaining[..m]);
// Check if we have complete headers
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// Also check if we have enough to parse (at least "CONNECT host:port HTTP/1.x")
if total_read >= 20 {
// Check if we have a newline that might indicate end of request line
if let Some(pos) = full_request.iter().position(|&b| b == b'\n') {
if pos < full_request.len() - 1 {
// We have at least the request line, check if we have headers
let request_str = String::from_utf8_lossy(&full_request);
if request_str.contains("\r\n\r\n") || request_str.contains("\n\n") {
break;
}
}
}
}
}
Err(e) => {
log::error!("DEBUG: Error reading CONNECT request: {:?}", e);
// If we have some data, try to process it
if total_read > 0 {
break;
}
return;
}
}
}
// Handle CONNECT manually
log::error!(
"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 {
log::error!("Error handling CONNECT request: {:?}", e);
} else {
log::error!("DEBUG: CONNECT handled successfully");
}
return;
}
// Not CONNECT (or partial read) - reconstruct stream with consumed bytes prepended
// This is critical: we MUST prepend any bytes we consumed, even if < 7 bytes
log::error!(
"DEBUG: Non-CONNECT request, first {} bytes: {:?}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(50)])
);
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
prepended_pos: 0,
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service = service_fn(move |req| handle_request(req, upstream.clone()));
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
}
}
Err(e) => {
log::error!("Error reading from connection: {:?}", e);
}
}
handle_proxy_connection(stream, upstream, matcher).await;
});
}
Err(e) => {
@@ -1156,6 +1154,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 +1192,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 +1202,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"
);
}
}

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