Compare commits

...
Author SHA1 Message Date
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
zhomandGitHub 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]andGitHub 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
zhomandGitHub 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]andGitHub 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
zhomandGitHub 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
zhomandGitHub 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]andGitHub 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]andGitHub 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
102 changed files with 15292 additions and 4313 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ Hi there! To expedite issue processing please search open and closed issues befo
## Your Environment
<!-- Please provide as much information as you feel comfortable to help us understand the issue better -->
<!-- Please provide as much information as you feel comfortable to help the maintainers understand the issue better -->
## Exception or Error or Screenshot
@@ -0,0 +1,76 @@
messages:
- role: system
content: |-
You are an issue validation assistant for Donut Browser, an anti-detect browser.
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
For Bug Reports, the issue should include:
1. Clear description of the problem
2. Steps to reproduce the issue (numbered list preferred)
3. Expected vs actual behavior
4. Environment information (OS, browser version, etc.)
5. Error messages, stack traces, or screenshots if applicable
For Feature Requests, the issue should include:
1. Clear description of the requested feature
2. Use case or problem it solves
3. Proposed solution or how it should work
4. Priority level or importance
General Requirements for all issues:
1. Descriptive title
2. Sufficient detail to understand and act upon
3. Professional tone and clear communication
Constraints:
- Maximum 3 items in missing_info array
- Maximum 3 items in suggestions array
- Each array item must be under 80 characters
- overall_assessment must be under 100 characters
- role: user
content: |-
## Issue Content to Analyze:
**Title:** {{issue_title}}
**Body:**
{{issue_body}}
**Labels:** {{issue_labels}}
model: openai/gpt-4.1
responseFormat: json_schema
jsonSchema: |-
{
"name": "issue_validation",
"strict": true,
"schema": {
"type": "object",
"properties": {
"is_valid": {
"type": "boolean",
"description": "Whether the issue contains sufficient information"
},
"issue_type": {
"type": "string",
"enum": ["bug_report", "feature_request", "other"]
},
"missing_info": {
"type": "array",
"items": { "type": "string" },
"description": "Missing information items (max 3, each under 80 characters)"
},
"suggestions": {
"type": "array",
"items": { "type": "string" },
"description": "Suggestions for improvement (max 3, each under 80 characters)"
},
"overall_assessment": {
"type": "string",
"description": "One sentence assessment under 100 characters"
}
},
"required": ["is_valid", "issue_type", "missing_info", "suggestions", "overall_assessment"],
"additionalProperties": false
}
}
+67
View File
@@ -0,0 +1,67 @@
messages:
- role: system
content: |-
You are a code review assistant for Donut Browser, an open-source anti-detect browser built with Tauri, Next.js, and Rust.
Review the provided pull request and provide constructive feedback. Focus on:
1. Code quality and best practices
2. Potential bugs or issues
3. Security concerns (especially important for an anti-detect browser)
4. Performance implications
5. Consistency with the project's patterns
Constraints:
- Maximum 4 items in feedback array
- Maximum 3 items in suggestions array
- Maximum 2 items in security_notes array
- Each array item must be under 150 characters
- summary must be under 200 characters
- Be constructive and helpful, not harsh
- role: user
content: |-
## Pull Request to Review:
**Title:** {{pr_title}}
**Description:**
{{pr_body}}
**Diff:**
{{pr_diff}}
model: openai/gpt-4.1
responseFormat: json_schema
jsonSchema: |-
{
"name": "pr_review",
"strict": true,
"schema": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Brief 1-2 sentence summary under 200 characters"
},
"quality_score": {
"type": "string",
"enum": ["good", "needs_work", "critical_issues"]
},
"feedback": {
"type": "array",
"items": { "type": "string" },
"description": "Feedback points (max 4, each under 150 characters)"
},
"suggestions": {
"type": "array",
"items": { "type": "string" },
"description": "Suggestions (max 3, each under 150 characters)"
},
"security_notes": {
"type": "array",
"items": { "type": "string" },
"description": "Security notes if any (max 2, each under 150 characters)"
}
},
"required": ["summary", "quality_score", "feedback", "suggestions", "security_notes"],
"additionalProperties": false
}
}
+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
+29 -120
View File
@@ -26,81 +26,22 @@ jobs:
- 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
- name: Save issue body to file
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
run: printf '%s' "${ISSUE_BODY:-}" > issue_body.txt
- name: Validate issue with AI
id: validate
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
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
prompt-file: .github/prompts/issue-validation.prompt.yml
input: |
issue_title: ${{ github.event.issue.title }}
issue_labels: ${{ join(github.event.issue.labels.*.name, ', ') }}
file_input: |
issue_body: ./issue_body.txt
max-tokens: 1024
- name: Check if first-time contributor
id: check-first-time
@@ -156,21 +97,21 @@ jobs:
{
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 "Thank you for submitting this issue! However, it appears that some required information might be missing to help the maintainers better understand and address your concern.\n\n"
printf "**Issue Type Detected:** \`%s\`\n\n" "$ISSUE_TYPE"
printf "**Assessment:** %s\n\n" "$ASSESSMENT"
printf "### 📋 Missing Information:\n%s\n\n" "$MISSING_INFO"
printf "### 💡 Suggestions for Improvement:\n%s\n\n" "$SUGGESTIONS"
printf "### 📝 How to Provide Additional Information:\n\n"
printf "Please edit your original issue description to include the missing information. Here are our issue templates for reference:\n\n"
printf "Please edit your original issue description to include the missing information. Here are the issue templates for reference:\n\n"
printf -- "- **Bug Report Template:** [View Template](.github/ISSUE_TEMPLATE/01-bug-report.md)\n"
printf -- "- **Feature Request Template:** [View Template](.github/ISSUE_TEMPLATE/02-feature-request.md)\n\n"
printf "### 🔧 Quick Tips:\n"
printf -- "- For **bug reports**: Include step-by-step reproduction instructions, your environment details, and any error messages\n"
printf -- "- For **feature requests**: Describe the use case, expected behavior, and why this feature would be valuable\n"
printf -- "- Add **screenshots** or **logs** when applicable\n\n"
printf "Once you have updated the issue with the missing information, feel free to remove this comment or reply to let 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"
printf "Once you have updated the issue with the missing information, feel free to remove this comment or reply to let the maintainers know the updates have been made.\n\n"
printf -- "---\n*This validation was performed automatically to ensure all the information needed to help effectively is provided.*\n"
} > comment.md
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
@@ -203,14 +144,14 @@ jobs:
fi
- name: Run opencode analysis
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
uses: anomalyco/opencode/github@799b2623cbb1c0f19e045d87c2c8593e83678bc0 #v1.2.15
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
with:
model: zai-coding-plan/glm-4.7
- name: Cleanup
run: rm -f issue_analysis.txt comment.md
run: rm -f issue_body.txt comment.md
handle-pr:
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
@@ -245,54 +186,22 @@ jobs:
gh pr diff ${{ github.event.pull_request.number }} > pr_diff.txt
head -c 10000 pr_diff.txt > pr_diff_truncated.txt
- name: Create PR analysis prompt
- name: Save PR body to file
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
run: printf '%s' "${PR_BODY:-No description provided}" > pr_body.txt
- name: Analyze PR with AI
id: analyze
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
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
prompt-file: .github/prompts/pr-review.prompt.yml
input: |
pr_title: ${{ github.event.pull_request.title }}
file_input: |
pr_body: ./pr_body.txt
pr_diff: ./pr_diff_truncated.txt
max-tokens: 1024
- name: Post PR feedback comment
env:
@@ -364,14 +273,14 @@ jobs:
gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md
- name: Run opencode analysis
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
uses: anomalyco/opencode/github@799b2623cbb1c0f19e045d87c2c8593e83678bc0 #v1.2.15
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
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_body.txt comment.md
opencode-command:
if: |
@@ -386,7 +295,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Run opencode
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
uses: anomalyco/opencode/github@799b2623cbb1c0f19e045d87c2c8593e83678bc0 #v1.2.15
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
with:
+1
View File
@@ -29,6 +29,7 @@ permissions:
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-22.04, windows-latest]
+7 -40
View File
@@ -82,47 +82,14 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
prompt-file: commits.txt
system-prompt: |
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser.
Analyze the provided commit messages and generate well-structured release notes following this format:
## What's New in ${{ steps.get-previous-tag.outputs.current-tag }}
[Brief 1-2 sentence overview of the release]
### ✨ New Features
[List new features with brief descriptions]
### 🐛 Bug Fixes
[List bug fixes]
### 🔧 Improvements
[List improvements and enhancements]
### 📚 Documentation
[List documentation updates if any]
### 🔄 Dependencies
[List dependency updates if any]
### 🛠️ Developer Experience
[List development-related changes if any]
Guidelines:
- Use clear, user-friendly language
- Group related commits logically
- Omit minor commits like formatting, typos unless significant
- Focus on user-facing changes
- Use emojis sparingly and consistently
- Keep descriptions concise but informative
- If commits are unclear, infer the purpose from the context
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
model: gpt-5-mini
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
version: ${{ steps.get-previous-tag.outputs.current-tag }}
file_input: |
commits: ./commits.txt
max-tokens: 4096
- name: Update release with generated notes
if: steps.get-release.outputs.is-prerelease == 'false'
+89 -2
View File
@@ -231,6 +231,93 @@ jobs:
# branch: main
# commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
publish-repos:
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Download Linux packages from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p /tmp/packages
gh release download "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--pattern "*.deb" \
--dir /tmp/packages
gh release download "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--pattern "*.rpm" \
--dir /tmp/packages
echo "Downloaded packages:"
ls -la /tmp/packages/
- name: Setup Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 #v6.3.0
with:
go-version: "1.23"
cache: false
- name: Install repogen
run: |
go install github.com/ralt/repogen/cmd/repogen@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Configure AWS CLI for Cloudflare R2
run: |
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
- name: Sync existing repo metadata from R2
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
mkdir -p /tmp/repo
aws s3 sync "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
- name: Generate repository with repogen
run: |
repogen generate \
--input-dir /tmp/packages \
--output-dir /tmp/repo \
--incremental \
--arch amd64,arm64 \
--origin "Donut Browser" \
--label "Donut Browser" \
--codename stable \
--components main \
--verbose
- name: Upload repository to R2
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
aws s3 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
--endpoint-url "${R2_ENDPOINT}"
aws s3 sync /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
--endpoint-url "${R2_ENDPOINT}"
- name: Verify upload
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "DEB repo:"
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}"
echo "RPM repo:"
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
bump-homebrew-cask:
needs: [release]
runs-on: macos-latest
@@ -241,6 +328,6 @@ jobs:
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
brew tap --force homebrew/cask
brew bump-cask-pr --version "$VERSION" --no-browse donutbrowser
VERSION="${GITHUB_REF_NAME#v}"
brew bump-cask-pr --version "$VERSION" --no-browse donut
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Spell Check Repo
uses: crate-ci/typos@78bc6fb2c0d734235d57a2d6b9de923cc325ebdd #v1.43.4
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
+1
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]
+12
View File
@@ -31,6 +31,7 @@
"cmdk",
"codegen",
"codesign",
"codesigning",
"commitish",
"Crashpad",
"CTYPE",
@@ -44,6 +45,8 @@
"devedition",
"direnv",
"distro",
"dists",
"DMABUF",
"doctest",
"doesn",
"domcontentloaded",
@@ -65,6 +68,7 @@
"gettimezone",
"gifs",
"globset",
"GOPATH",
"gsettings",
"healthreport",
"hiddenimports",
@@ -77,6 +81,7 @@
"idletime",
"idna",
"infobars",
"inkey",
"Inno",
"kdeglobals",
"keras",
@@ -92,6 +97,7 @@
"libayatana",
"libc",
"libcairo",
"libfuse",
"libgdk",
"libglib",
"libpango",
@@ -132,10 +138,12 @@
"oscpu",
"outpath",
"OVPN",
"passout",
"patchelf",
"pathex",
"pathlib",
"peerconnection",
"PHANDLER",
"pids",
"pixbuf",
"pkexec",
@@ -154,6 +162,9 @@
"pytest",
"pyyaml",
"quic",
"ralt",
"repodata",
"repogen",
"reportingpolicy",
"reqwest",
"ridedott",
@@ -173,6 +184,7 @@
"shadcn",
"showcursor",
"shutil",
"sighandler",
"signon",
"signum",
"sklearn",
+3 -3
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
@@ -25,4 +25,4 @@ Examples of unacceptable behavior by participants include:
Violations of the Code of Conduct may be reported to [contact@donutbrowser.com](mailto:contact@donutbrowser.com). All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
The maintainers hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that are deemed inappropriate, threatening, offensive, or harmful.
+7 -7
View File
@@ -10,11 +10,11 @@ Do keep in mind before you start working on an issue / posting a PR:
- Search existing PRs related to that issue which might close them
- Confirm if other contributors are working on the same issue
- Check if the feature aligns with our roadmap and project goals
- Check if the feature aligns with the project's roadmap and goals
## Contributor License Agreement
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to our [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to the [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
- Your contributions can be used in the open source version of Donut Browser (licensed under AGPL-3.0)
- Donut Browser can offer commercial licenses for the software, including your contributions
@@ -27,7 +27,7 @@ When you submit your first pull request, you acknowledge that you agree to the t
- PRs with tests are highly appreciated
- Avoid adding third party libraries, whenever possible
- Unless you are helping out by updating dependencies, you should not be uploading your lock files or updating any dependencies in your PR
- If you are unsure where to start, open a discussion and we will point you to a good first issue
- If you are unsure where to start, open a discussion to get pointed to a good first issue
## Development Setup
@@ -80,7 +80,7 @@ This will start the app for local development with live reloading.
## Code Style & Quality
We use several tools to maintain code quality:
The project uses several tools to maintain code quality:
- **Biome** for JavaScript/TypeScript linting and formatting
- **Clippy** for Rust linting
@@ -88,7 +88,7 @@ We use several tools to maintain code quality:
### Before Committing
Run these commands to ensure your code meets our standards:
Run these commands to ensure your code meets the project's standards:
```bash
# Format and lint frontend code
@@ -151,7 +151,7 @@ Refs #00000
### PR Checklist
- [ ] Code follows our style guidelines
- [ ] Code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
@@ -187,7 +187,7 @@ Please note that this project is released with a [Contributor Code of Conduct](C
## Recognition
All contributors will be recognized! We use the all-contributors specification to acknowledge everyone who contributes to the project.
All contributors will be recognized! The project uses the all-contributors specification to acknowledge everyone who contributes.
---
+15 -2
View File
@@ -42,6 +42,19 @@
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
<details>
<summary>Troubleshooting AppImage on Linux</summary>
If the AppImage segfaults on launch, install **libfuse2** (`sudo apt install libfuse2` / `yay -S libfuse2` / `sudo dnf install fuse-libs`), or bypass FUSE entirely:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./Donut.Browser_x.x.x_amd64.AppImage
```
If that gives an EGL display error, try adding `WEBKIT_DISABLE_DMABUF_RENDERER=1` or `GDK_BACKEND=x11` to the command above. If issues persist, the **.deb** / **.rpm** packages are a more reliable alternative.
</details>
<!-- ## Supported Platforms
- ✅ **macOS** (Apple Silicon)
@@ -64,7 +77,7 @@ Donut Browser supports syncing profiles, proxies, and groups across devices via
## Community
Have questions or want to contribute? We'd love to hear from you!
Have questions or want to contribute? The team would love to hear from you!
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
@@ -113,7 +126,7 @@ Have questions or want to contribute? We'd love to hear from you!
## Contact
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com) and we'll get back to you as fast as possible.
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com) and the team will get back to you as fast as possible.
## License
+3
View File
@@ -6,3 +6,6 @@ extend-exclude = [
"src-tauri/build.rs",
"src-tauri/tests/fixtures/test.ovpn",
]
[default.extend-words]
DBE = "DBE"
+4 -4
View File
@@ -18,8 +18,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.994.0",
"@aws-sdk/s3-request-presigner": "^3.994.0",
"@aws-sdk/client-s3": "^3.1000.0",
"@aws-sdk/s3-request-presigner": "^3.1000.0",
"@nestjs/common": "^11.1.14",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.14",
@@ -35,8 +35,8 @@
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@types/supertest": "^6.0.3",
"@types/node": "^25.3.3",
"@types/supertest": "^7.2.0",
"jest": "^30.2.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./dist/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10 -10
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.14.6",
"version": "0.15.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -56,9 +56,9 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^25.8.11",
"lucide-react": "^0.575.0",
"motion": "^12.34.2",
"i18next": "^25.8.13",
"lucide-react": "^0.576.0",
"motion": "^12.34.3",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -72,22 +72,22 @@
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.3",
"@tailwindcss/postcss": "^4.2.0",
"@biomejs/biome": "2.4.4",
"@tailwindcss/postcss": "^4.2.1",
"@tauri-apps/cli": "~2.10.0",
"@types/color": "^4.2.0",
"@types/node": "^25.3.0",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"tailwindcss": "^4.2.0",
"lint-staged": "^16.3.1",
"tailwindcss": "^4.2.1",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.1",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+1102 -1160
View File
File diff suppressed because it is too large Load Diff
+190 -198
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.14.6"
version = "0.15.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -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"
@@ -99,6 +100,7 @@ maxminddb = "0.27"
quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support
lz4_flex = "0.11"
boringtun = "0.7"
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
@@ -106,15 +108,11 @@ smoltcp = { version = "0.11", default-features = false, features = ["std", "medi
tray-icon = "0.21"
muda = "0.17"
tao = "0.34"
single-instance = "0.3"
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
sys-locale = "0.3"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal", "process"] }
+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",
+1 -8
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -464,13 +463,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)
}
+135 -24
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)]
@@ -295,30 +298,23 @@ 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))
.split_for_parts();
let api = ApiDoc::openapi();
@@ -493,6 +489,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 +544,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 {
@@ -605,6 +603,7 @@ async fn create_profile(
camoufox_config,
wayfern_config,
request.group_id.clone(),
false,
)
.await
{
@@ -644,6 +643,7 @@ async fn create_profile(
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
proxy_bypass_rules: profile.proxy_bypass_rules,
},
}))
}
@@ -747,6 +747,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
}
@@ -1152,6 +1175,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,
+86 -17
View File
@@ -744,13 +744,36 @@ impl AppAutoUpdater {
log::info!("Extracting update...");
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
log::info!("Installing update (overwriting binary)...");
self.install_update(&extracted_app_path).await?;
// On Windows, MSI/EXE installers close the running app, so running them now
// would kill the process before the "Update ready" toast can appear. Instead,
// defer execution to restart_application() when the user clicks "Restart Now".
#[cfg(target_os = "windows")]
{
let ext = extracted_app_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext == "msi" || ext == "exe" {
log::info!("Deferring Windows installer execution until user-initiated restart");
*PENDING_INSTALLER_PATH.lock().unwrap() = Some(extracted_app_path);
} else {
log::info!("Installing update (overwriting binary)...");
self.install_update(&extracted_app_path).await?;
log::info!("Cleaning up temporary files...");
let _ = fs::remove_dir_all(&temp_dir);
}
}
log::info!("Cleaning up temporary files...");
let _ = fs::remove_dir_all(&temp_dir);
#[cfg(not(target_os = "windows"))]
{
log::info!("Installing update (overwriting binary)...");
self.install_update(&extracted_app_path).await?;
log::info!("Cleaning up temporary files...");
let _ = fs::remove_dir_all(&temp_dir);
}
log::info!("Update installed successfully, emitting app-update-ready event");
log::info!("Update ready, emitting app-update-ready event");
let _ = events::emit("app-update-ready", update_info.new_version.clone());
@@ -1421,14 +1444,63 @@ rm "{}"
{
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
// Create a temporary restart batch script
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.bat");
let update_temp_dir = temp_dir.join("donut_app_update");
// Create the restart script content
let script_content = format!(
r#"@echo off
let script_content = if let Some(installer_path) = pending {
let ext = installer_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let install_cmd = match ext.as_str() {
"msi" => format!(
"msiexec /i \"{}\" /quiet /norestart REBOOT=ReallySuppress",
installer_path.to_str().unwrap()
),
"exe" => format!("\"{}\" /S", installer_path.to_str().unwrap()),
_ => String::new(),
};
format!(
r#"@echo off
rem Wait for the current process to exit
:wait_loop
tasklist /fi "PID eq {pid}" >nul 2>&1
if %errorlevel% equ 0 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
rem Wait a bit more to ensure clean exit
timeout /t 2 /nobreak >nul
rem Run the installer
{install_cmd}
rem Wait for installation to complete
timeout /t 3 /nobreak >nul
rem Start the new application
start "" "{app_path}"
rem Clean up installer temp files
rmdir /s /q "{update_temp}"
rem Clean up this script
del "%~f0"
"#,
pid = current_pid,
install_cmd = install_cmd,
app_path = app_path.to_str().unwrap(),
update_temp = update_temp_dir.to_str().unwrap(),
)
} else {
format!(
r#"@echo off
rem Wait for the current process to exit
:wait_loop
tasklist /fi "PID eq {}" >nul 2>&1
@@ -1446,24 +1518,20 @@ start "" "{}"
rem Clean up this script
del "%~f0"
"#,
current_pid,
app_path.to_str().unwrap()
);
current_pid,
app_path.to_str().unwrap()
)
};
// Write the script to file
fs::write(&script_path, script_content)?;
// Execute the restart script in the background
let mut cmd = Command::new("cmd");
cmd.args(["/C", script_path.to_str().unwrap()]);
// Start the process detached
let _child = cmd.spawn()?;
// Give the script a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Exit the current process
std::process::exit(0);
}
@@ -1926,4 +1994,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);
}
+195
View File
@@ -0,0 +1,195 @@
use directories::BaseDirs;
use std::path::PathBuf;
use std::sync::OnceLock;
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
fn base_dirs() -> &'static BaseDirs {
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
}
pub fn app_name() -> &'static str {
if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
}
}
pub fn data_dir() -> PathBuf {
#[cfg(test)]
{
if let Some(dir) = TEST_DATA_DIR.with(|cell| cell.borrow().clone()) {
return dir;
}
}
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
return PathBuf::from(dir);
}
base_dirs().data_local_dir().join(app_name())
}
pub fn cache_dir() -> PathBuf {
#[cfg(test)]
{
if let Some(dir) = TEST_CACHE_DIR.with(|cell| cell.borrow().clone()) {
return dir;
}
}
if let Ok(dir) = std::env::var("DONUTBROWSER_CACHE_DIR") {
return PathBuf::from(dir);
}
base_dirs().cache_dir().join(app_name())
}
pub fn profiles_dir() -> PathBuf {
data_dir().join("profiles")
}
pub fn binaries_dir() -> PathBuf {
data_dir().join("binaries")
}
pub fn data_subdir() -> PathBuf {
data_dir().join("data")
}
pub fn settings_dir() -> PathBuf {
data_dir().join("settings")
}
pub fn proxies_dir() -> PathBuf {
data_dir().join("proxies")
}
pub fn 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!(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);
}
}
+13 -10
View File
@@ -61,6 +61,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
@@ -313,6 +317,10 @@ 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
@@ -362,15 +370,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,
@@ -519,9 +518,13 @@ 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(),
}
}
+111 -23
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,13 +298,37 @@ fn run_daemon() {
// Use swap to only run cleanup once
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
// 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);
}
}
}
_ => {}
@@ -267,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 {
@@ -274,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");
}
@@ -296,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;
+10 -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(
@@ -217,8 +222,12 @@ async fn main() {
let port = start_matches.get_one::<u16>("port").copied();
let profile_id = start_matches.get_one::<String>("profile-id").cloned();
let bypass_rules: Vec<String> = start_matches
.get_one::<String>("bypass-rules")
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
match start_proxy_process_with_profile(upstream_url, port, profile_id).await {
match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await {
Ok(config) => {
// Output the configuration as JSON for the Rust side to parse
// Use println! here because this needs to go to stdout for parsing
+183 -52
View File
@@ -1,18 +1,17 @@
use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use directories::BaseDirs;
use serde::Serialize;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use sysinfo::System;
pub struct BrowserRunner {
base_dirs: BaseDirs,
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
auto_updater: &'static crate::auto_updater::AutoUpdater,
@@ -23,7 +22,6 @@ pub struct BrowserRunner {
impl BrowserRunner {
fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
profile_manager: ProfileManager::instance(),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
auto_updater: crate::auto_updater::AutoUpdater::instance(),
@@ -37,14 +35,18 @@ impl BrowserRunner {
}
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()
}
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
/// then resolve the proxy settings.
async fn resolve_proxy_with_refresh(&self, proxy_id: Option<&String>) -> Option<ProxySettings> {
let proxy_id = proxy_id?;
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
CLOUD_AUTH.sync_cloud_proxy().await;
}
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
}
/// Get the executable path for a browser profile
@@ -90,17 +92,6 @@ impl BrowserRunner {
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Check if browser is disabled due to ongoing update
if self.auto_updater.is_browser_disabled(&profile.browser)? {
return Err(
format!(
"{} is currently being updated. Please wait for the update to complete.",
profile.browser
)
.into(),
);
}
// Handle Camoufox profiles using CamoufoxManager
if profile.browser == "camoufox" {
// Get or create camoufox config
@@ -113,10 +104,10 @@ impl BrowserRunner {
});
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
let mut upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.await;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -159,6 +150,7 @@ impl BrowserRunner {
upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
)
.await
.map_err(|e| {
@@ -231,12 +223,38 @@ impl BrowserRunner {
);
}
// Ensure DuckDuckGo is set as default search engine for Camoufox
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&profile.browser);
browser_dir.push(&profile.version);
if let Err(e) = crate::downloader::configure_camoufox_search_engine(&browser_dir) {
log::warn!("Failed to configure Camoufox search engine: {e}");
// Create ephemeral dir for ephemeral profiles
let override_profile_path = if profile.ephemeral {
let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir)
} else {
None
};
// Install extensions if an extension group is assigned
if updated_profile.extension_group_id.is_some() {
let profiles_dir = self.profile_manager.get_profiles_dir();
let ext_profile_path = if let Some(ref override_path) = override_profile_path {
override_path.clone()
} else {
updated_profile.get_profile_data_path(&profiles_dir)
};
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
match mgr.install_extensions_for_profile(&updated_profile, &ext_profile_path) {
Ok(paths) => {
if !paths.is_empty() {
log::info!(
"Installed {} Firefox extensions for profile: {}",
paths.len(),
updated_profile.name
);
}
}
Err(e) => {
log::warn!("Failed to install extensions for Camoufox profile: {e}");
}
}
}
// Launch Camoufox browser
@@ -248,6 +266,7 @@ impl BrowserRunner {
updated_profile.clone(),
camoufox_config,
url,
override_profile_path,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
@@ -343,10 +362,10 @@ impl BrowserRunner {
});
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
let mut upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.await;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -389,6 +408,7 @@ impl BrowserRunner {
upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
)
.await
.map_err(|e| {
@@ -451,14 +471,42 @@ impl BrowserRunner {
);
}
// Create ephemeral dir for ephemeral profiles
if profile.ephemeral {
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
}
// Launch Wayfern browser
log::info!("Launching Wayfern for profile: {}", profile.name);
// Get profile path for Wayfern
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = updated_profile.get_profile_data_path(&profiles_dir);
let profile_data_path =
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy().to_string();
// Install extensions if an extension group is assigned
let mut extension_paths = Vec::new();
if updated_profile.extension_group_id.is_some() {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
match mgr.install_extensions_for_profile(&updated_profile, &profile_data_path) {
Ok(paths) => {
if !paths.is_empty() {
log::info!(
"Prepared {} Chromium extensions for profile: {}",
paths.len(),
updated_profile.name
);
}
extension_paths = paths;
}
Err(e) => {
log::warn!("Failed to install extensions for Wayfern profile: {e}");
}
}
}
// Get proxy URL from config
let proxy_url = wayfern_config.proxy.as_deref();
@@ -471,6 +519,8 @@ impl BrowserRunner {
&wayfern_config,
url.as_deref(),
proxy_url,
profile.ephemeral,
&extension_paths,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
@@ -570,11 +620,10 @@ impl BrowserRunner {
// Continue anyway, the error might not be critical
}
// Get stored proxy settings for later use (removed as we handle this in proxy startup)
let _stored_proxy_settings = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Refresh cloud proxy credentials if needed before resolving
let _stored_proxy_settings = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.await;
// Use provided local proxy for Chromium-based browsers launch arguments
let proxy_for_launch_args: Option<&ProxySettings> = local_proxy_settings;
@@ -803,7 +852,8 @@ impl BrowserRunner {
if profile.browser == "camoufox" {
// Get the profile path based on the UUID
let profiles_dir = self.profile_manager.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 the process is running
@@ -857,7 +907,8 @@ impl BrowserRunner {
// Handle Wayfern profiles using WayfernManager
if profile.browser == "wayfern" {
let profiles_dir = self.profile_manager.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 the process is running
@@ -1042,6 +1093,7 @@ impl BrowserRunner {
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
)
.await
.map_err(|e| {
@@ -1255,7 +1307,8 @@ impl BrowserRunner {
if profile.browser == "camoufox" {
// Search by profile path to find the running Camoufox instance
let profiles_dir = self.profile_manager.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();
log::info!(
@@ -1348,7 +1401,11 @@ impl BrowserRunner {
#[cfg(target_os = "linux")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await
if let Err(e) = platform_browser::linux::kill_browser_process_impl(
pid,
Some(&profile_path_str),
)
.await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
@@ -1429,7 +1486,12 @@ impl BrowserRunner {
#[cfg(target_os = "linux")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await {
if let Err(e) = platform_browser::linux::kill_browser_process_impl(
pid,
Some(&profile_path_str),
)
.await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
// Verify the process is actually dead after force kill
@@ -1517,7 +1579,8 @@ impl BrowserRunner {
{
use crate::platform_browser;
if let Err(kill_err) =
platform_browser::linux::kill_browser_process_impl(pid).await
platform_browser::linux::kill_browser_process_impl(pid, Some(&profile_path_str))
.await
{
log::error!(
"Failed to force kill Camoufox process {}: {}",
@@ -1673,6 +1736,10 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
log::info!(
"Camoufox process cleanup completed for profile: {} (ID: {})",
profile.name,
@@ -1698,7 +1765,8 @@ impl BrowserRunner {
// Handle Wayfern profiles using WayfernManager
if profile.browser == "wayfern" {
let profiles_dir = self.profile_manager.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();
log::info!(
@@ -1782,7 +1850,12 @@ impl BrowserRunner {
#[cfg(target_os = "linux")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await {
if let Err(e) = platform_browser::linux::kill_browser_process_impl(
pid,
Some(&profile_path_str),
)
.await
{
log::error!("Failed to force kill Wayfern process {}: {}", pid, e);
} else {
sleep(Duration::from_millis(500)).await;
@@ -1853,7 +1926,8 @@ impl BrowserRunner {
{
use crate::platform_browser;
if let Err(kill_err) =
platform_browser::linux::kill_browser_process_impl(pid).await
platform_browser::linux::kill_browser_process_impl(pid, Some(&profile_path_str))
.await
{
log::error!("Failed to force kill Wayfern process {}: {}", pid, kill_err);
} else {
@@ -1991,6 +2065,10 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
log::info!(
"Wayfern process cleanup completed for profile: {} (ID: {})",
profile.name,
@@ -2146,7 +2224,12 @@ impl BrowserRunner {
platform_browser::windows::kill_browser_process_impl(pid).await?;
#[cfg(target_os = "linux")]
platform_browser::linux::kill_browser_process_impl(pid).await?;
{
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy().to_string();
platform_browser::linux::kill_browser_process_impl(pid, Some(&profile_path_str)).await?;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
@@ -2523,6 +2606,7 @@ pub async fn launch_browser_profile(
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile_for_launch.proxy_bypass_rules.clone(),
)
.await
{
@@ -2655,6 +2739,53 @@ pub async fn kill_browser_profile(
profile.name,
profile.id
);
// Auto-update non-running profiles and cleanup unused binaries
let browser_for_update = profile.browser.clone();
let app_handle_for_update = app_handle.clone();
tauri::async_runtime::spawn(async move {
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let mut versions = registry.get_downloaded_versions(&browser_for_update);
if !versions.is_empty() {
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
let latest_version = &versions[0];
let auto_updater = crate::auto_updater::AutoUpdater::instance();
match auto_updater
.auto_update_profile_versions(
&app_handle_for_update,
&browser_for_update,
latest_version,
)
.await
{
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Auto-updated {} profiles after stop: {:?}",
updated.len(),
updated
);
}
}
Err(e) => {
log::error!("Failed to auto-update profile versions after stop: {e}");
}
}
}
match registry.cleanup_unused_binaries() {
Ok(cleaned) => {
if !cleaned.is_empty() {
log::info!("Cleaned up unused binaries after stop: {:?}", cleaned);
}
}
Err(e) => {
log::error!("Failed to cleanup unused binaries after stop: {e}");
}
}
});
Ok(())
}
Err(e) => {
+36 -14
View File
@@ -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()));
}
}
+108 -13
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;
@@ -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"),
}
}
@@ -92,14 +89,7 @@ impl CamoufoxManager {
}
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
@@ -376,8 +366,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 {
@@ -592,10 +585,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
@@ -607,6 +605,32 @@ 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}");
}
}
// Write search.json.mozlz4 with default search engines (DuckDuckGo + Google)
write_default_search_config(&profile_path);
self
.launch_camoufox(
&app_handle,
@@ -620,6 +644,77 @@ impl CamoufoxManager {
}
}
fn write_default_search_config(profile_path: &std::path::Path) {
let search_file = profile_path.join("search.json.mozlz4");
if search_file.exists() {
return;
}
let json = serde_json::json!({
"version": 6,
"engines": [
{
"_name": "DuckDuckGo",
"_isAppProvided": false,
"_metaData": { "order": 1 },
"_urls": [
{
"template": "https://duckduckgo.com/?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://duckduckgo.com/favicon.ico"
},
{
"_name": "Google",
"_isAppProvided": false,
"_metaData": { "order": 2 },
"_urls": [
{
"template": "https://www.google.com/search?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://www.google.com/favicon.ico"
}
],
"metaData": {
"useSavedOrder": false,
"defaultEngineId": "DuckDuckGo"
}
});
let json_bytes = match serde_json::to_vec(&json) {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("Failed to serialize search config: {e}");
return;
}
};
let magic = b"mozLz40\0";
let compressed = lz4_flex::block::compress_prepend_size(&json_bytes);
let mut output = Vec::with_capacity(magic.len() + compressed.len());
output.extend_from_slice(magic);
output.extend_from_slice(&compressed);
if let Err(e) = std::fs::write(&search_file, &output) {
log::warn!("Failed to write search.json.mozlz4: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
+8
View File
@@ -37,6 +37,8 @@ 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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1099,6 +1101,12 @@ 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);
+504
View File
@@ -2,6 +2,7 @@ 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;
@@ -62,6 +63,14 @@ 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 {
@@ -493,4 +502,499 @@ 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" => Self::write_chrome_cookies(&db_path, &cookies),
_ => 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);
}
}
+8 -2
View File
@@ -244,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={} run
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
daemon_path.display()
);
fs::write(&desktop_path, desktop_content)?;
+81 -35
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,41 +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 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 Browser\" to quit"])
.output();
.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"])
.output();
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.output();
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).output();
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
pub fn set_gui_running(running: bool) {
GUI_RUNNING.store(running, Ordering::SeqCst);
}
+51 -7
View File
@@ -9,6 +9,26 @@ use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
@@ -43,12 +63,7 @@ pub fn is_daemon_running() -> bool {
#[cfg(windows)]
{
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid)])
.output();
output
.map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
.unwrap_or(false)
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
@@ -113,7 +128,13 @@ fn get_daemon_path() -> Option<PathBuf> {
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
if let Ok(output) = Command::new("where").arg("donut-daemon").output() {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
@@ -313,3 +334,26 @@ pub fn ensure_daemon_running() -> Result<(), String> {
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
+2 -2
View File
@@ -204,7 +204,7 @@ mod windows {
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
app_key
.set_value("ApplicationIcon", &format!("{},0", exe_path))
.set_value("ApplicationIcon", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
// Create Capabilities key
@@ -273,7 +273,7 @@ mod windows {
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
icon_key
.set_value("", &format!("{},0", exe_path))
.set_value("", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set default icon: {}", e))?;
// Create shell\open\command key
+4 -46
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@@ -71,16 +70,7 @@ impl DownloadedBrowsersRegistry {
}
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("data");
path.push("downloaded_browsers.json");
Ok(path)
Ok(crate::app_dirs::data_subdir().join("downloaded_browsers.json"))
}
pub fn add_browser(&self, info: DownloadedBrowserInfo) {
@@ -128,19 +118,7 @@ impl DownloadedBrowsersRegistry {
};
let browser_instance = create_browser(browser_type.clone());
// Get binaries directory
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
} else {
return false;
};
let binaries_dir = crate::app_dirs::binaries_dir();
let files_exist = browser_instance.is_version_downloaded(version, &binaries_dir);
@@ -535,15 +513,7 @@ impl DownloadedBrowsersRegistry {
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Get binaries directory path
let base_dirs = directories::BaseDirs::new().ok_or("Failed to get base directories")?;
let mut binaries_dir = base_dirs.data_local_dir().to_path_buf();
binaries_dir.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
binaries_dir.push("binaries");
let binaries_dir = crate::app_dirs::binaries_dir();
let version_dir = binaries_dir.join(browser).join(version);
@@ -830,19 +800,7 @@ impl DownloadedBrowsersRegistry {
let browser = create_browser(browser_type.clone());
// Get binaries directory
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("binaries");
path
} else {
return Err("Failed to get base directories".into());
};
let binaries_dir = crate::app_dirs::binaries_dir();
log::info!(
"binaries_dir: {binaries_dir:?} for profile: {}",
+240 -77
View File
@@ -158,7 +158,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 +183,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
);
}
@@ -659,6 +659,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 = {
@@ -681,21 +716,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) {
@@ -1012,6 +1033,51 @@ impl Downloader {
tokens.remove(&download_key);
}
// Auto-update non-running profiles to the new version and cleanup unused binaries
{
let browser_for_update = browser_str.clone();
let version_for_update = version.clone();
let app_handle_for_update = app_handle.clone();
tauri::async_runtime::spawn(async move {
let auto_updater = crate::auto_updater::AutoUpdater::instance();
match auto_updater
.auto_update_profile_versions(
&app_handle_for_update,
&browser_for_update,
&version_for_update,
)
.await
{
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Auto-updated {} profiles to {} {}: {:?}",
updated.len(),
browser_for_update,
version_for_update,
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)
}
}
@@ -1047,68 +1113,165 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
}
}
/// Clean up the fake "None" search engine from Camoufox policies.json so that
/// Camoufox's built-in fallback (DuckDuckGo when nothing else is configured) can work.
/// Called both at download time and at launch time to cover existing installations.
pub fn configure_camoufox_search_engine(
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let policies_path = browser_dir.join("distribution").join("policies.json");
/// Find all candidate `distribution/` directories inside the Camoufox browser dir.
/// On macOS: `<browser_dir>/<app>.app/Contents/Resources/distribution/`
/// On Linux: `<browser_dir>/camoufox/distribution/`
/// On Windows: `<browser_dir>/distribution/`
/// Also includes `<browser_dir>/distribution/` as a fallback for all platforms.
#[allow(clippy::vec_init_then_push)]
fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
if !policies_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&policies_path)?;
let mut policies: serde_json::Value = serde_json::from_str(&content)?;
let current_default = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Default"))
.and_then(|d| d.as_str())
.unwrap_or("");
if current_default != "None" {
return Ok(());
}
let mut changed = false;
if let Some(policies_obj) = policies.get_mut("policies") {
if let Some(se) = policies_obj.get_mut("SearchEngines") {
// Remove the fake "None" default so Camoufox uses its built-in fallback
if let Some(obj) = se.as_object_mut() {
obj.remove("Default");
changed = true;
}
// Remove the fake "None" search engine entry from Add
if let Some(add_arr) = se.get_mut("Add").and_then(|a| a.as_array_mut()) {
let before = add_arr.len();
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
if add_arr.len() != before {
changed = true;
}
}
// Ensure DuckDuckGo is not in the Remove list so it's available as fallback
if let Some(remove_arr) = se.get_mut("Remove").and_then(|r| r.as_array_mut()) {
let before = remove_arr.len();
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
if remove_arr.len() != before {
changed = true;
#[cfg(target_os = "macos")]
{
if let Ok(entries) = std::fs::read_dir(browser_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
dirs.push(
entry
.path()
.join("Contents")
.join("Resources")
.join("distribution"),
);
}
}
}
}
if changed {
let updated = serde_json::to_string_pretty(&policies)?;
std::fs::write(&policies_path, updated)?;
log::info!("Cleaned up fake 'None' search engine from Camoufox policies.json");
#[cfg(target_os = "linux")]
{
dirs.push(browser_dir.join("camoufox").join("distribution"));
}
// Fallback for all platforms
dirs.push(browser_dir.join("distribution"));
dirs
}
/// Set DuckDuckGo as the default search engine in Camoufox.
/// Creates or updates distribution/policies.json with a proper DuckDuckGo engine definition.
/// Called both at download time and at launch time to cover existing installations.
pub fn configure_camoufox_search_engine(
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let distribution_dirs = find_camoufox_distribution_dirs(browser_dir);
// Find an existing policies.json, or pick the first candidate dir to create one
let (policies_path, mut policies) = {
let mut found = None;
for dir in &distribution_dirs {
let path = dir.join("policies.json");
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
found = Some((path, val));
break;
}
}
}
}
match found {
Some(f) => f,
None => {
// Pick the first candidate directory that exists (or can be created)
let target_dir = distribution_dirs
.iter()
.find(|d| d.parent().is_some_and(|p| p.exists()))
.or(distribution_dirs.first())
.ok_or("No suitable distribution directory found")?;
std::fs::create_dir_all(target_dir)?;
(
target_dir.join("policies.json"),
serde_json::json!({"policies": {}}),
)
}
}
};
// Check if already configured
let has_ddg_default = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Default"))
.and_then(|d| d.as_str())
== Some("DuckDuckGo");
let has_ddg_engine = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Add"))
.and_then(|a| a.as_array())
.is_some_and(|arr| {
arr
.iter()
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
});
if has_ddg_default && has_ddg_engine {
return Ok(());
}
let ddg_engine = serde_json::json!({
"Name": "DuckDuckGo",
"URLTemplate": "https://duckduckgo.com/?q={searchTerms}",
"SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"Method": "GET",
"IconURL": "https://duckduckgo.com/favicon.ico",
"Alias": "ddg"
});
// Ensure policies.SearchEngines exists
let policies_obj = policies
.as_object_mut()
.ok_or("Invalid policies.json")?
.entry("policies")
.or_insert(serde_json::json!({}));
let se = policies_obj
.as_object_mut()
.ok_or("Invalid policies object")?
.entry("SearchEngines")
.or_insert(serde_json::json!({}));
if let Some(se_obj) = se.as_object_mut() {
// Set DuckDuckGo as default
se_obj.insert(
"Default".to_string(),
serde_json::Value::String("DuckDuckGo".to_string()),
);
// Add DuckDuckGo engine definition if not present
let add_arr = se_obj
.entry("Add")
.or_insert(serde_json::json!([]))
.as_array_mut()
.ok_or("SearchEngines.Add is not an array")?;
// Remove fake "None" engine
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
// Add DuckDuckGo if not already present
if !add_arr
.iter()
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
{
add_arr.push(ddg_engine);
}
// Ensure DuckDuckGo is not in the Remove list
if let Some(remove_arr) = se_obj.get_mut("Remove").and_then(|r| r.as_array_mut()) {
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
}
}
let updated = serde_json::to_string_pretty(&policies)?;
std::fs::write(&policies_path, updated)?;
log::info!(
"Configured DuckDuckGo search engine in {}",
policies_path.display()
);
Ok(())
}
+326
View File
@@ -0,0 +1,326 @@
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(),
}
}
#[test]
fn test_ephemeral_dir_lifecycle() {
let profile_id = uuid::Uuid::new_v4();
let id_str = profile_id.to_string();
let dir = create_ephemeral_dir(&id_str).unwrap();
assert!(dir.is_dir());
assert_eq!(get_ephemeral_dir(&id_str), Some(dir.clone()));
let ephemeral_profile = make_test_profile(profile_id, true);
let profiles_dir = std::env::temp_dir().join("test_profiles_ephemeral");
assert_eq!(
get_effective_profile_path(&ephemeral_profile, &profiles_dir),
dir
);
remove_ephemeral_dir(&id_str);
assert!(!dir.exists());
assert!(get_ephemeral_dir(&id_str).is_none());
let persistent_profile = make_test_profile(uuid::Uuid::new_v4(), false);
let expected = persistent_profile.get_profile_data_path(&profiles_dir);
assert_eq!(
get_effective_profile_path(&persistent_profile, &profiles_dir),
expected
);
}
#[test]
fn test_recover_ephemeral_dirs() {
let base = get_ephemeral_base_dir().unwrap();
let test_id = uuid::Uuid::new_v4().to_string();
let test_dir = base.join(&test_id);
std::fs::create_dir_all(&test_dir).unwrap();
// Clear the HashMap so recovery has something to find
EPHEMERAL_DIRS.lock().unwrap().remove(&test_id);
assert!(get_ephemeral_dir(&test_id).is_none());
recover_ephemeral_dirs();
assert_eq!(get_ephemeral_dir(&test_id), Some(test_dir.clone()));
// Clean up
remove_ephemeral_dir(&test_id);
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -816,6 +816,15 @@ impl Extractor {
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(),
+59 -5
View File
@@ -76,21 +76,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)
@@ -201,6 +219,17 @@ impl GeoIPDownloader {
file.flush().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(
"geoip-download-progress",
@@ -362,6 +391,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.
+34 -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,7 +84,7 @@ impl GroupManager {
return Err(format!("Group with name '{name}' already exists").into());
}
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let sync_enabled = crate::sync::is_sync_configured();
let group = ProfileGroup {
id: uuid::Uuid::new_v4().to_string(),
name,
@@ -135,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)
}
@@ -171,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)
}
@@ -208,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,
+197 -63
View File
@@ -11,6 +11,7 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
mod api_client;
mod api_server;
mod app_auto_updater;
pub mod app_dirs;
mod auto_updater;
mod browser;
mod browser_runner;
@@ -20,6 +21,8 @@ mod camoufox_manager;
mod default_browser;
mod downloaded_browsers_registry;
mod downloader;
mod ephemeral_dirs;
mod extension_manager;
mod extraction;
mod geoip_downloader;
mod group_manager;
@@ -59,7 +62,8 @@ use browser_runner::{
use profile::manager::{
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note,
update_profile_proxy, update_profile_tags, update_profile_vpn, update_wayfern_config,
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
update_wayfern_config,
};
use browser_version_manager::{
@@ -76,15 +80,18 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, enable_launch_on_login, get_app_settings, get_sync_settings,
get_system_language, get_table_sorting_settings, save_app_settings, save_sync_settings,
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
get_sync_settings, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
save_table_sorting_settings, should_show_launch_on_login_prompt,
};
use sync::{
enable_sync_for_all_entities, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_vpn_in_use_by_synced_profile, request_profile_sync,
set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled,
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password,
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
};
use tag_manager::get_all_tags;
@@ -107,6 +114,12 @@ use app_auto_updater::{
use profile_importer::{detect_existing_profiles, import_browser_profile};
use extension_manager::{
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, list_extension_groups,
list_extensions, remove_extension_from_group, update_extension, update_extension_group,
};
use group_manager::{
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
@@ -283,9 +296,41 @@ async fn copy_profile_cookies(
app_handle: tauri::AppHandle,
request: cookie_manager::CookieCopyRequest,
) -> Result<Vec<cookie_manager::CookieCopyResult>, String> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err("Cookie copying requires an active Pro subscription".to_string());
}
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
}
#[tauri::command]
async fn import_cookies_from_file(
app_handle: tauri::AppHandle,
profile_id: String,
content: String,
) -> Result<cookie_manager::CookieImportResult, String> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err("Cookie import requires an active Pro subscription".to_string());
}
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await
}
#[tauri::command]
async fn export_profile_cookies(profile_id: String, format: String) -> Result<String, String> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err("Cookie export requires an active Pro subscription".to_string());
}
cookie_manager::CookieManager::export_cookies(&profile_id, &format)
}
#[tauri::command]
fn check_wayfern_terms_accepted() -> bool {
wayfern_terms::WayfernTermsManager::instance().is_terms_accepted()
@@ -432,13 +477,23 @@ async fn import_vpn_config(
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
match storage.import_config(&content, &filename, name.clone()) {
Ok(config) => Ok(vpn::VpnImportResult {
success: true,
vpn_id: Some(config.id),
vpn_type: Some(config.vpn_type),
name: config.name,
error: None,
}),
Ok(config) => {
if config.sync_enabled {
if let Some(scheduler) = sync::get_global_scheduler() {
let id = config.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_vpn_sync(id).await;
});
}
}
Ok(vpn::VpnImportResult {
success: true,
vpn_id: Some(config.id),
vpn_type: Some(config.vpn_type),
name: config.name,
error: None,
})
}
Err(e) => Ok(vpn::VpnImportResult {
success: false,
vpn_id: None,
@@ -529,24 +584,50 @@ async fn create_vpn_config_manual(
vpn_type: vpn::VpnType,
config_data: String,
) -> Result<vpn::VpnConfig, String> {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
let config = {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.create_config_manual(&name, vpn_type, &config_data)
.map_err(|e| format!("Failed to create VPN config: {e}"))
storage
.create_config_manual(&name, vpn_type, &config_data)
.map_err(|e| format!("Failed to create VPN config: {e}"))?
};
if config.sync_enabled {
if let Some(scheduler) = sync::get_global_scheduler() {
let id = config.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_vpn_sync(id).await;
});
}
}
Ok(config)
}
#[tauri::command]
async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfig, String> {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
let config = {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.update_config_name(&vpn_id, &name)
.map_err(|e| format!("Failed to update VPN config: {e}"))
storage
.update_config_name(&vpn_id, &name)
.map_err(|e| format!("Failed to update VPN config: {e}"))?
};
if config.sync_enabled {
if let Some(scheduler) = sync::get_global_scheduler() {
let id = config.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_vpn_sync(id).await;
});
}
}
Ok(config)
}
#[tauri::command]
@@ -685,12 +766,7 @@ pub fn run() {
pending.push(url.clone());
}
// Configure logging plugin with separate logs for dev and production
let log_file_name = if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
};
let log_file_name = app_dirs::app_name();
tauri::Builder::default()
.plugin(
@@ -721,9 +797,16 @@ pub fn run() {
})
.build(),
)
.plugin(tauri_plugin_single_instance::init(|_, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
}))
.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
},
))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
@@ -731,18 +814,23 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
// Start the daemon for tray icon
if let Err(e) = daemon_spawn::ensure_daemon_running() {
log::warn!("Failed to start daemon: {e}");
}
// Register this GUI's PID in daemon state so the daemon can kill us directly
daemon_spawn::register_gui_pid();
// Monitor daemon health - quit GUI if daemon dies
let app_handle_daemon = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Give the daemon time to fully start
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
@@ -753,9 +841,11 @@ pub fn run() {
.unwrap_or(false);
if !is_running {
log::warn!("Daemon is no longer running, quitting GUI");
app_handle_daemon.exit(0);
break;
log::warn!("Daemon is no longer running, quitting GUI immediately");
// Use process::exit for immediate termination. Tauri's exit()
// triggers a slow graceful shutdown that can take over a minute
// waiting for async tasks (sync, version updater, etc.) to finish.
std::process::exit(0);
}
}
});
@@ -925,29 +1015,31 @@ pub fn run() {
}
});
let _app_handle_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
log::info!("Starting app update check at startup...");
let updater = app_auto_updater::AppAutoUpdater::instance();
match updater.check_for_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
// Emit update available event to the frontend
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
} else {
log::debug!("App update event emitted successfully");
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
match updater.check_for_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
}
}
Ok(None) => {
log::debug!("No app updates available");
}
Err(e) => {
log::error!("Failed to check for app updates: {e}");
}
}
Ok(None) => {
log::debug!("No app updates available");
}
Err(e) => {
log::error!("Failed to check for app updates: {e}");
}
}
});
@@ -1190,6 +1282,12 @@ pub fn run() {
{
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);
@@ -1242,6 +1340,7 @@ pub fn run() {
update_profile_vpn,
update_profile_tags,
update_profile_note,
update_profile_proxy_bypass_rules,
check_browser_status,
kill_browser_profile,
rename_profile,
@@ -1253,6 +1352,8 @@ pub fn run() {
get_table_sorting_settings,
save_table_sorting_settings,
get_system_language,
dismiss_window_resize_warning,
get_window_resize_warning_dismissed,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
@@ -1291,6 +1392,18 @@ pub fn run() {
delete_profile_group,
assign_profiles_to_group,
delete_selected_profiles,
list_extensions,
add_extension,
update_extension,
delete_extension,
list_extension_groups,
create_extension_group,
update_extension_group,
delete_extension_group,
add_extension_to_group,
remove_extension_from_group,
assign_extension_group_to_profile,
get_extension_group_for_profile,
is_geoip_database_available,
download_geoip_database,
start_api_server,
@@ -1301,7 +1414,7 @@ pub fn run() {
get_traffic_stats_for_period,
get_sync_settings,
save_sync_settings,
set_profile_sync_enabled,
set_profile_sync_mode,
request_profile_sync,
set_proxy_sync_enabled,
set_group_sync_enabled,
@@ -1309,10 +1422,17 @@ pub fn run() {
is_group_in_use_by_synced_profile,
set_vpn_sync_enabled,
is_vpn_in_use_by_synced_profile,
set_extension_sync_enabled,
set_extension_group_sync_enabled,
get_unsynced_entity_counts,
enable_sync_for_all_entities,
set_e2e_password,
check_has_e2e_password,
delete_e2e_password,
read_profile_cookies,
copy_profile_cookies,
import_cookies_from_file,
export_profile_cookies,
check_wayfern_terms_accepted,
check_wayfern_downloaded,
accept_wayfern_terms,
@@ -1348,8 +1468,18 @@ pub fn run() {
cloud_auth::create_cloud_location_proxy,
cloud_auth::restart_sync_service
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app_handle, _event| {
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = _event {
if let Some(window) = _app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
}
});
}
#[cfg(test)]
@@ -1375,6 +1505,10 @@ mod tests {
"get_vpn_status",
"get_vpn_config",
"list_active_vpn_connections",
"export_profile_cookies",
"update_extension",
"set_extension_sync_enabled",
"set_extension_group_sync_enabled",
];
// Extract command names from the generate_handler! macro in this file
+576 -2
View File
@@ -18,6 +18,7 @@ use tokio::net::TcpListener;
use tokio::sync::Mutex as AsyncMutex;
use crate::browser::ProxySettings;
use crate::cloud_auth::CLOUD_AUTH;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
@@ -679,6 +680,135 @@ impl McpServer {
"required": ["vpn_id"]
}),
},
// Fingerprint management tools
McpTool {
name: "get_profile_fingerprint".to_string(),
description: "Get the fingerprint configuration for a Wayfern or Camoufox profile"
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "update_profile_fingerprint".to_string(),
description:
"Update the fingerprint configuration for a Wayfern or Camoufox profile. Requires an active Pro subscription."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to update"
},
"fingerprint": {
"type": "string",
"description": "JSON string of the fingerprint configuration, or null to clear"
},
"os": {
"type": "string",
"enum": ["windows", "macos", "linux"],
"description": "Operating system for fingerprint generation"
},
"randomize_fingerprint_on_launch": {
"type": "boolean",
"description": "Whether to generate a new fingerprint on every launch"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "update_profile_proxy_bypass_rules".to_string(),
description:
"Update proxy bypass rules for a profile. Requests matching these rules will connect directly, bypassing the proxy."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to update"
},
"rules": {
"type": "array",
"items": { "type": "string" },
"description": "Array of bypass rules. Supports hostnames (e.g. 'example.com'), IP addresses, and regex patterns."
}
},
"required": ["profile_id", "rules"]
}),
},
McpTool {
name: "list_extensions".to_string(),
description: "List all managed browser extensions. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "list_extension_groups".to_string(),
description: "List all extension groups. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "create_extension_group".to_string(),
description: "Create a new extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Name for the extension group" }
},
"required": ["name"]
}),
},
McpTool {
name: "delete_extension".to_string(),
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"extension_id": { "type": "string", "description": "The extension ID to delete" }
},
"required": ["extension_id"]
}),
},
McpTool {
name: "delete_extension_group".to_string(),
description: "Delete an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID to delete" }
},
"required": ["group_id"]
}),
},
McpTool {
name: "assign_extension_group_to_profile".to_string(),
description: "Assign an extension group to a profile, or remove the assignment. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": { "type": "string", "description": "The profile ID" },
"extension_group_id": { "type": "string", "description": "The extension group ID, or empty string to remove" }
},
"required": ["profile_id"]
}),
},
]
}
@@ -777,6 +907,25 @@ impl McpServer {
"connect_vpn" => self.handle_connect_vpn(&arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(&arguments).await,
// Fingerprint management
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await,
"update_profile_proxy_bypass_rules" => {
self
.handle_update_profile_proxy_bypass_rules(&arguments)
.await
}
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
"create_extension_group" => self.handle_create_extension_group(&arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(&arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(&arguments).await,
"assign_extension_group_to_profile" => {
self
.handle_assign_extension_group_to_profile(&arguments)
.await
}
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
@@ -1825,6 +1974,420 @@ impl McpServer {
}]
}))
}
// Fingerprint management handlers
async fn handle_get_profile_fingerprint(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
})?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Profile not found: {profile_id}"),
})?;
let fingerprint_info = match profile.browser.as_str() {
"camoufox" => {
let config = profile
.camoufox_config
.as_ref()
.cloned()
.unwrap_or_default();
serde_json::json!({
"browser": "camoufox",
"fingerprint": config.fingerprint,
"os": config.os,
"randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch,
"screen_max_width": config.screen_max_width,
"screen_max_height": config.screen_max_height,
"screen_min_width": config.screen_min_width,
"screen_min_height": config.screen_min_height,
})
}
"wayfern" => {
let config = profile.wayfern_config.as_ref().cloned().unwrap_or_default();
serde_json::json!({
"browser": "wayfern",
"fingerprint": config.fingerprint,
"os": config.os,
"randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch,
"screen_max_width": config.screen_max_width,
"screen_max_height": config.screen_max_height,
"screen_min_width": config.screen_min_width,
"screen_min_height": config.screen_min_height,
})
}
_ => {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox profiles".to_string(),
})
}
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&fingerprint_info).unwrap_or_default()
}]
}))
}
async fn handle_update_profile_fingerprint(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Fingerprint editing requires an active Pro subscription".to_string(),
});
}
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let fingerprint = arguments.get("fingerprint").and_then(|v| v.as_str());
let os = arguments.get("os").and_then(|v| v.as_str());
let randomize = arguments
.get("randomize_fingerprint_on_launch")
.and_then(|v| v.as_bool());
if let Some(os_val) = os {
if !CLOUD_AUTH.is_fingerprint_os_allowed(Some(os_val)).await {
return Err(McpError {
code: -32000,
message: format!(
"OS spoofing to '{}' requires an active Pro subscription",
os_val
),
});
}
}
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
})?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Profile not found: {profile_id}"),
})?;
let inner = self.inner.lock().await;
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?;
match profile.browser.as_str() {
"camoufox" => {
let mut config = profile
.camoufox_config
.as_ref()
.cloned()
.unwrap_or_default();
if let Some(fp) = fingerprint {
config.fingerprint = Some(fp.to_string());
}
if let Some(os_val) = os {
config.os = Some(os_val.to_string());
}
if let Some(r) = randomize {
config.randomize_fingerprint_on_launch = Some(r);
}
ProfileManager::instance()
.update_camoufox_config(app_handle.clone(), profile_id, config)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update camoufox config: {e}"),
})?;
}
"wayfern" => {
let mut config = profile.wayfern_config.as_ref().cloned().unwrap_or_default();
if let Some(fp) = fingerprint {
config.fingerprint = Some(fp.to_string());
}
if let Some(os_val) = os {
config.os = Some(os_val.to_string());
}
if let Some(r) = randomize {
config.randomize_fingerprint_on_launch = Some(r);
}
ProfileManager::instance()
.update_wayfern_config(app_handle.clone(), profile_id, config)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update wayfern config: {e}"),
})?;
}
_ => {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox profiles".to_string(),
})
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Fingerprint configuration updated for profile '{}'", profile.name)
}]
}))
}
async fn handle_update_profile_proxy_bypass_rules(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let rules: Vec<String> = arguments
.get("rules")
.and_then(|v| v.as_array())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing rules array".to_string(),
})?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
let inner = self.inner.lock().await;
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?;
let profile = ProfileManager::instance()
.update_profile_proxy_bypass_rules(app_handle, profile_id, rules.clone())
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update proxy bypass rules: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Proxy bypass rules updated for profile '{}': {} rule(s) configured",
profile.name,
rules.len()
)
}]
}))
}
async fn handle_list_extensions(&self) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extensions = mgr.list_extensions().map_err(|e| McpError {
code: -32000,
message: format!("Failed to list extensions: {e}"),
})?;
Ok(serde_json::to_value(extensions).unwrap())
}
async fn handle_list_extension_groups(&self) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let groups = mgr.list_groups().map_err(|e| McpError {
code: -32000,
message: format!("Failed to list extension groups: {e}"),
})?;
Ok(serde_json::to_value(groups).unwrap())
}
async fn handle_create_extension_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: name".to_string(),
})?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr.create_group(name.to_string()).map_err(|e| McpError {
code: -32000,
message: format!("Failed to create extension group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_delete_extension_mcp(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let extension_id = arguments
.get("extension_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: extension_id".to_string(),
})?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.delete_extension_internal(extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to delete extension: {e}"),
})?;
Ok(serde_json::json!({"success": true}))
}
async fn handle_delete_extension_group_mcp(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: group_id".to_string(),
})?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
// For MCP, we don't have an app_handle, but we need one for sync deletion.
// Use the delete_group_internal which skips sync remote deletion.
mgr.delete_group_internal(group_id).map_err(|e| McpError {
code: -32000,
message: format!("Failed to delete extension group: {e}"),
})?;
if let Err(e) = crate::events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
}
Ok(serde_json::json!({"success": true}))
}
async fn handle_assign_extension_group_to_profile(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: profile_id".to_string(),
})?;
let extension_group_id = arguments
.get("extension_group_id")
.and_then(|v| v.as_str())
.map(|s| {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
})
.unwrap_or(None);
// Validate compatibility if assigning
if let Some(ref gid) = extension_group_id {
let profile_manager = ProfileManager::instance();
let profiles = profile_manager.list_profiles().map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
})?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Profile '{profile_id}' not found"),
})?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.validate_group_compatibility(gid, &profile.browser)
.map_err(|e| McpError {
code: -32000,
message: format!("{e}"),
})?;
}
let profile_manager = ProfileManager::instance();
let profile = profile_manager
.update_profile_extension_group(profile_id, extension_group_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to assign extension group: {e}"),
})?;
Ok(serde_json::to_value(profile).unwrap())
}
}
lazy_static::lazy_static! {
@@ -1840,8 +2403,8 @@ mod tests {
let server = McpServer::new();
let tools = server.get_tools();
// Should have at least 24 tools (18 + 6 VPN tools)
assert!(tools.len() >= 24);
// Should have at least 32 tools (26 + 6 extension tools)
assert!(tools.len() >= 32);
// Check tool names
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
@@ -1874,6 +2437,17 @@ mod tests {
assert!(tool_names.contains(&"connect_vpn"));
assert!(tool_names.contains(&"disconnect_vpn"));
assert!(tool_names.contains(&"get_vpn_status"));
// Fingerprint tools
assert!(tool_names.contains(&"get_profile_fingerprint"));
assert!(tool_names.contains(&"update_profile_fingerprint"));
assert!(tool_names.contains(&"update_profile_proxy_bypass_rules"));
// Extension tools
assert!(tool_names.contains(&"list_extensions"));
assert!(tool_names.contains(&"list_extension_groups"));
assert!(tool_names.contains(&"create_extension_group"));
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
}
#[test]
+151 -8
View File
@@ -651,8 +651,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 {
@@ -871,18 +874,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
}
}
+195 -49
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::{get_host_os, 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)]
@@ -65,10 +49,20 @@ impl ProfileManager {
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)
@@ -89,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" {
@@ -176,9 +172,13 @@ 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(),
};
match self
@@ -291,9 +291,13 @@ 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(),
};
match self
@@ -338,9 +342,13 @@ 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(),
};
// Save profile info
@@ -354,16 +362,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
@@ -483,8 +494,8 @@ impl ProfileManager {
);
}
// 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());
@@ -630,7 +641,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();
@@ -727,6 +738,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,
@@ -755,7 +791,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());
}
@@ -813,6 +849,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}"))?;
@@ -829,7 +866,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());
@@ -856,9 +896,13 @@ 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,
};
self.save_profile(&new_profile)?;
@@ -1031,7 +1075,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() {
@@ -1130,6 +1174,32 @@ impl ProfileManager {
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,
@@ -1147,7 +1217,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;
@@ -1189,8 +1261,6 @@ impl ProfileManager {
// 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 {
@@ -1307,7 +1377,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
@@ -1351,6 +1422,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");
@@ -1381,6 +1456,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");
@@ -1422,7 +1501,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
@@ -1466,6 +1546,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");
@@ -1510,9 +1594,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(),
@@ -1682,9 +1767,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"))?;
@@ -1843,6 +1930,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)]
@@ -1858,6 +1972,7 @@ pub async fn create_browser_profile_with_group(
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
ephemeral: bool,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
@@ -1872,6 +1987,7 @@ pub async fn create_browser_profile_with_group(
camoufox_config,
wayfern_config,
group_id,
ephemeral,
)
.await
.map_err(|e| format!("Failed to create profile: {e}"))
@@ -1935,6 +2051,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,
@@ -1972,6 +2100,7 @@ pub async fn create_browser_profile_new(
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()
@@ -1998,6 +2127,7 @@ pub async fn create_browser_profile_new(
camoufox_config,
wayfern_config,
group_id,
ephemeral.unwrap_or(false),
)
.await
}
@@ -2008,6 +2138,14 @@ 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
@@ -2028,6 +2166,14 @@ 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
@@ -2043,9 +2189,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}"))
}
+27 -1
View File
@@ -13,6 +13,14 @@ pub enum SyncStatus {
Error,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
pub enum SyncMode {
#[default]
Disabled,
Regular,
Encrypted,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrowserProfile {
pub id: uuid::Uuid,
@@ -40,11 +48,19 @@ 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>,
}
pub fn default_release_type() -> String {
@@ -75,4 +91,14 @@ impl BrowserProfile {
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
}
}
+5 -1
View File
@@ -554,9 +554,13 @@ impl ProfileImporter {
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: Some(crate::profile::types::get_host_os()),
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
};
// Save the profile metadata
+42 -22
View File
@@ -1,5 +1,4 @@
use chrono::Utc;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
@@ -117,7 +116,7 @@ pub struct StoredProxy {
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let sync_enabled = crate::sync::is_sync_configured();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
@@ -149,18 +148,15 @@ pub struct ProxyManager {
// Track active proxy IDs by profile name for targeted cleanup
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
base_dirs: BaseDirs,
}
impl ProxyManager {
pub fn new() -> Self {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let manager = Self {
active_proxies: Mutex::new(HashMap::new()),
profile_proxies: Mutex::new(HashMap::new()),
profile_active_proxy_ids: Mutex::new(HashMap::new()),
stored_proxies: Mutex::new(HashMap::new()),
base_dirs,
};
// Load stored proxies on initialization
@@ -171,27 +167,12 @@ impl ProxyManager {
manager
}
// Get the path to the proxies directory
fn get_proxies_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("proxies");
path
crate::app_dirs::proxies_dir()
}
// Get the path to the proxy check cache directory
fn get_proxy_check_cache_dir(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
let mut path = self.base_dirs.cache_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("proxy_checks");
let path = crate::app_dirs::cache_dir().join("proxy_checks");
fs::create_dir_all(&path)?;
Ok(path)
}
@@ -409,6 +390,15 @@ impl ProxyManager {
log::error!("Failed to emit proxies-changed event: {e}");
}
if stored_proxy.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = stored_proxy.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_proxy_sync(id).await;
});
}
}
Ok(stored_proxy)
}
@@ -627,6 +617,11 @@ impl ProxyManager {
}
}
pub fn remove_from_memory(&self, proxy_id: &str) {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.remove(proxy_id);
}
// Get all stored proxies
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -699,6 +694,15 @@ impl ProxyManager {
log::error!("Failed to emit proxies-changed event: {e}");
}
if updated_proxy.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_proxy.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_proxy_sync(id).await;
});
}
}
Ok(updated_proxy)
}
@@ -765,6 +769,14 @@ impl ProxyManager {
Ok(())
}
// Check if a proxy is cloud-managed or cloud-derived (needs fresh credentials)
pub fn is_cloud_or_derived(&self, proxy_id: &str) -> bool {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies
.get(proxy_id)
.is_some_and(|p| p.is_cloud_managed || p.is_cloud_derived)
}
// Get proxy settings for a stored proxy ID
pub fn get_proxy_settings_by_id(&self, proxy_id: &str) -> Option<ProxySettings> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -1180,6 +1192,7 @@ impl ProxyManager {
proxy_settings: Option<&ProxySettings>,
browser_pid: u32,
profile_id: Option<&str>,
bypass_rules: Vec<String>,
) -> Result<ProxySettings, String> {
if let Some(name) = profile_id {
// Check if we have an active proxy recorded for this profile
@@ -1300,6 +1313,13 @@ impl ProxyManager {
proxy_cmd = proxy_cmd.arg("--profile-id").arg(id);
}
// Add bypass rules if any
if !bypass_rules.is_empty() {
let rules_json = serde_json::to_string(&bypass_rules)
.map_err(|e| format!("Failed to serialize bypass rules: {e}"))?;
proxy_cmd = proxy_cmd.arg("--bypass-rules").arg(rules_json);
}
// Execute the command and wait for it to complete
// The donut-proxy binary should start the worker and then exit
let output = proxy_cmd
+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();
}
+67 -12
View File
@@ -6,6 +6,7 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use regex_lite::Regex;
use std::convert::Infallible;
use std::io;
use std::net::SocketAddr;
@@ -18,6 +19,38 @@ use tokio::net::TcpListener;
use tokio::net::TcpStream;
use url::Url;
enum CompiledRule {
Regex(Regex),
Exact(String),
}
#[derive(Clone)]
pub struct BypassMatcher {
rules: Arc<Vec<CompiledRule>>,
}
impl BypassMatcher {
pub fn new(rules: &[String]) -> Self {
let compiled = rules
.iter()
.map(|rule| match Regex::new(rule) {
Ok(re) => CompiledRule::Regex(re),
Err(_) => CompiledRule::Exact(rule.clone()),
})
.collect();
Self {
rules: Arc::new(compiled),
}
}
pub fn should_bypass(&self, host: &str) -> bool {
self.rules.iter().any(|rule| match rule {
CompiledRule::Regex(re) => re.is_match(host),
CompiledRule::Exact(exact) => host == exact,
})
}
}
/// Wrapper stream that counts bytes read and written
struct CountingStream<S> {
inner: S,
@@ -133,19 +166,21 @@ impl AsyncWrite for PrependReader {
async fn handle_request(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Handle CONNECT method for HTTPS tunneling
if req.method() == Method::CONNECT {
return handle_connect(req, upstream_url).await;
return handle_connect(req, upstream_url, bypass_matcher).await;
}
// Handle regular HTTP requests
handle_http(req, upstream_url).await
handle_http(req, upstream_url, bypass_matcher).await
}
async fn handle_connect(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
let authority = req.uri().authority().cloned();
@@ -161,12 +196,13 @@ async fn handle_connect(
(&target_addr[..], 443)
};
// If no upstream proxy, connect directly
// If no upstream proxy, or bypass rule matches, connect directly
if upstream_url.is_none()
|| upstream_url
.as_ref()
.map(|s| s == "DIRECT")
.unwrap_or(false)
|| bypass_matcher.should_bypass(target_host)
{
match TcpStream::connect(&target_addr).await {
Ok(_stream) => {
@@ -674,6 +710,7 @@ async fn handle_http_via_socks4(
async fn handle_http(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Extract domain for traffic tracking
let domain = req
@@ -689,13 +726,17 @@ async fn handle_http(
req.uri().host()
);
let should_bypass = bypass_matcher.should_bypass(&domain);
// Check if we need to handle SOCKS4 manually (reqwest doesn't support it)
if let Some(ref upstream) = upstream_url {
if upstream != "DIRECT" {
if let Ok(url) = Url::parse(upstream) {
if url.scheme() == "socks4" {
// Handle SOCKS4 manually for HTTP requests
return handle_http_via_socks4(req, upstream).await;
if !should_bypass {
if let Some(ref upstream) = upstream_url {
if upstream != "DIRECT" {
if let Ok(url) = Url::parse(upstream) {
if url.scheme() == "socks4" {
// Handle SOCKS4 manually for HTTP requests
return handle_http_via_socks4(req, upstream).await;
}
}
}
}
@@ -705,7 +746,9 @@ async fn handle_http(
use reqwest::Client;
let client_builder = Client::builder();
let client = if let Some(ref upstream) = upstream_url {
let client = if should_bypass {
client_builder.build().unwrap_or_default()
} else if let Some(ref upstream) = upstream_url {
if upstream == "DIRECT" {
client_builder.build().unwrap_or_default()
} else {
@@ -1003,6 +1046,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}
});
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
// Keep the runtime alive with an infinite loop
// This ensures the process doesn't exit even if there are no active connections
loop {
@@ -1014,6 +1059,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
let upstream = upstream_url.clone();
let matcher = bypass_matcher.clone();
tokio::task::spawn(async move {
// Read first bytes to detect CONNECT requests
@@ -1108,7 +1154,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
"DEBUG: Handling CONNECT manually for: {}",
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
);
if let Err(e) = handle_connect_from_buffer(stream, full_request, upstream).await {
if let Err(e) =
handle_connect_from_buffer(stream, full_request, upstream, matcher).await
{
log::error!("Error handling CONNECT request: {:?}", e);
} else {
log::error!("DEBUG: CONNECT handled successfully");
@@ -1130,7 +1178,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service = service_fn(move |req| handle_request(req, upstream.clone()));
let service =
service_fn(move |req| handle_request(req, upstream.clone(), matcher.clone()));
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
@@ -1156,6 +1205,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 +1243,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 +1253,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)?;
+14 -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::proxies_dir()
}
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
@@ -132,7 +131,9 @@ 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()
}
+53 -72
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
@@ -54,6 +53,8 @@ pub struct AppSettings {
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -87,31 +88,16 @@ impl Default for AppSettings {
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
}
}
}
pub struct SettingsManager {
base_dirs: BaseDirs,
data_dir_override: Option<PathBuf>,
}
pub struct SettingsManager;
impl SettingsManager {
fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
.ok()
.map(PathBuf::from),
}
}
#[cfg(test)]
fn with_data_dir_override(dir: &std::path::Path) -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: Some(dir.to_path_buf()),
}
pub(crate) fn new() -> Self {
Self
}
pub fn instance() -> &'static SettingsManager {
@@ -119,18 +105,7 @@ impl SettingsManager {
}
pub fn get_settings_dir(&self) -> PathBuf {
if let Some(dir) = &self.data_dir_override {
return dir.join("settings");
}
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("settings");
path
crate::app_dirs::settings_dir()
}
pub fn get_settings_file(&self) -> PathBuf {
@@ -153,23 +128,10 @@ impl SettingsManager {
// Parse the settings file - serde will use default values for missing fields
match serde_json::from_str::<AppSettings>(&content) {
Ok(settings) => {
// Save the settings back to ensure any missing fields are written with defaults
if let Err(e) = self.save_settings(&settings) {
log::warn!("Warning: Failed to update settings file with defaults: {e}");
}
Ok(settings)
}
Ok(settings) => Ok(settings),
Err(e) => {
log::warn!("Warning: Failed to parse settings file, using defaults: {e}");
let default_settings = AppSettings::default();
// Try to save default settings to fix the corrupted file
if let Err(save_error) = self.save_settings(&default_settings) {
log::warn!("Warning: Failed to save default settings: {save_error}");
}
Ok(default_settings)
Ok(AppSettings::default())
}
}
}
@@ -809,6 +771,15 @@ pub async fn save_app_settings(
settings.mcp_token = None;
}
// Preserve server-managed flags that the frontend may not have up-to-date.
// Read directly from file to avoid load_settings' save-on-load behavior.
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.launch_on_login_declined = current.launch_on_login_declined;
}
}
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
persist_settings.mcp_token = None;
@@ -926,6 +897,27 @@ pub async fn save_sync_settings(
})
}
#[tauri::command]
pub async fn dismiss_window_resize_warning() -> Result<(), String> {
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
settings.window_resize_warning_dismissed = true;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
}
#[tauri::command]
pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
Ok(settings.window_resize_warning_dismissed)
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
@@ -950,16 +942,16 @@ mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_settings_manager() -> (SettingsManager, TempDir) {
fn create_test_settings_manager() -> (SettingsManager, TempDir, crate::app_dirs::TestDirGuard) {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let manager = SettingsManager::with_data_dir_override(temp_dir.path());
(manager, temp_dir)
let guard = crate::app_dirs::set_test_data_dir(temp_dir.path().to_path_buf());
let manager = SettingsManager::new();
(manager, temp_dir, guard)
}
#[test]
fn test_settings_manager_creation() {
let (_manager, _temp_dir) = create_test_settings_manager();
// Test passes if no panic occurs
let (_manager, _temp_dir, _guard) = create_test_settings_manager();
}
#[test]
@@ -992,7 +984,7 @@ mod tests {
#[test]
fn test_load_settings_nonexistent_file() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.load_settings();
assert!(
@@ -1010,7 +1002,7 @@ mod tests {
#[test]
fn test_save_and_load_settings() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let test_settings = AppSettings {
set_as_default_browser: true,
@@ -1027,13 +1019,12 @@ mod tests {
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
};
// Save settings
let save_result = manager.save_settings(&test_settings);
assert!(save_result.is_ok(), "Should save settings successfully");
// Load settings back
let load_result = manager.load_settings();
assert!(load_result.is_ok(), "Should load settings successfully");
@@ -1050,7 +1041,7 @@ mod tests {
#[test]
fn test_load_table_sorting_nonexistent_file() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.load_table_sorting();
assert!(
@@ -1065,18 +1056,16 @@ mod tests {
#[test]
fn test_save_and_load_table_sorting() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let test_sorting = TableSortingSettings {
column: "browser".to_string(),
direction: "desc".to_string(),
};
// Save sorting
let save_result = manager.save_table_sorting(&test_sorting);
assert!(save_result.is_ok(), "Should save sorting successfully");
// Load sorting back
let load_result = manager.load_table_sorting();
assert!(load_result.is_ok(), "Should load sorting successfully");
@@ -1093,45 +1082,37 @@ mod tests {
#[test]
fn test_should_show_launch_on_login_prompt() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.should_show_launch_on_login_prompt();
assert!(result.is_ok(), "Should not fail");
// By default, should show prompt (not declined, autostart not enabled)
let _should_show = result.unwrap();
// Note: The actual value depends on system autostart state, so we just test it doesn't fail
}
#[test]
fn test_decline_launch_on_login() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
// Initially not declined
let settings = manager.load_settings().unwrap();
assert!(!settings.launch_on_login_declined);
// Decline
manager.decline_launch_on_login().unwrap();
// Should be declined now
let settings = manager.load_settings().unwrap();
assert!(settings.launch_on_login_declined);
}
#[test]
fn test_load_corrupted_settings_file() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
// Create settings directory
let settings_dir = manager.get_settings_dir();
fs::create_dir_all(&settings_dir).expect("Should create settings directory");
// Write corrupted JSON
let settings_file = manager.get_settings_file();
fs::write(&settings_file, "{ invalid json }").expect("Should write corrupted file");
// Should handle corrupted file gracefully
let result = manager.load_settings();
assert!(
result.is_ok(),
@@ -1151,7 +1132,7 @@ mod tests {
#[test]
fn test_settings_file_paths() {
let (manager, _temp_dir) = create_test_settings_manager();
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings_dir = manager.get_settings_dir();
let settings_file = manager.get_settings_file();
+351
View File
@@ -0,0 +1,351 @@
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
const E2E_FILE_VERSION: u8 = 1;
fn get_e2e_password_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("e2e_password.dat")
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
pub fn store_e2e_password(password: &str) -> Result<(), String> {
let file_path = get_e2e_password_path();
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
let vault_password = get_vault_password();
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, password.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(E2E_FILE_HEADER);
file_data.push(E2E_FILE_VERSION);
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
file_data.extend_from_slice(&nonce);
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(&file_path, file_data)
.map_err(|e| format!("Failed to write e2e password file: {e}"))?;
Ok(())
}
pub fn load_e2e_password() -> Result<Option<String>, String> {
let file_path = get_e2e_password_path();
if !file_path.exists() {
return Ok(None);
}
let file_data =
std::fs::read(&file_path).map_err(|e| format!("Failed to read e2e password file: {e}"))?;
if file_data.len() < E2E_FILE_HEADER.len() + 1 {
return Ok(None);
}
if &file_data[..E2E_FILE_HEADER.len()] != E2E_FILE_HEADER {
return Ok(None);
}
let version = file_data[E2E_FILE_HEADER.len()];
if version != E2E_FILE_VERSION {
return Ok(None);
}
let mut offset = E2E_FILE_HEADER.len() + 1;
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_str = std::str::from_utf8(&file_data[offset..offset + salt_len])
.map_err(|_| "Invalid salt encoding")?;
offset += salt_len;
let salt = SaltString::from_b64(salt_str).map_err(|e| format!("Invalid salt: {e}"))?;
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce")?;
let nonce = aes_gcm::Nonce::from(nonce_bytes);
offset += 12;
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len =
u32::from_le_bytes(file_data[offset..offset + 4].try_into().unwrap()) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))?;
let password =
String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in password: {e}"))?;
Ok(Some(password))
}
pub fn has_e2e_password() -> bool {
get_e2e_password_path().exists()
}
pub fn remove_e2e_password() -> Result<(), String> {
let file_path = get_e2e_password_path();
if file_path.exists() {
std::fs::remove_file(&file_path)
.map_err(|e| format!("Failed to remove e2e password file: {e}"))?;
}
Ok(())
}
/// Derive a per-profile encryption key using Argon2id
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
let salt_bytes = BASE64
.decode(profile_salt)
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
let salt = SaltString::encode_b64(&salt_bytes)
.map_err(|e| format!("Failed to create salt string: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(user_password.as_bytes(), &salt)
.map_err(|e| format!("Key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes[..32]);
Ok(key)
}
/// Generate a random 16-byte salt, base64-encoded
pub fn generate_salt() -> String {
let mut salt = [0u8; 16];
use aes_gcm::aead::rand_core::RngCore;
OsRng.fill_bytes(&mut salt);
BASE64.encode(salt)
}
/// Encrypt bytes with AES-256-GCM. Output format: [nonce 12B][ciphertext]
pub fn encrypt_bytes(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> {
let aes_key = Key::<Aes256Gcm>::from(*key);
let cipher = Aes256Gcm::new(&aes_key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut output = Vec::with_capacity(12 + ciphertext.len());
output.extend_from_slice(&nonce);
output.extend_from_slice(&ciphertext);
Ok(output)
}
/// Decrypt bytes encrypted with encrypt_bytes. Input format: [nonce 12B][ciphertext]
pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String> {
if encrypted.len() < 12 {
return Err("Encrypted data too short".to_string());
}
let nonce_bytes: [u8; 12] = encrypted[..12].try_into().map_err(|_| "Invalid nonce")?;
let nonce = aes_gcm::Nonce::from(nonce_bytes);
let ciphertext = &encrypted[12..];
let aes_key = Key::<Aes256Gcm>::from(*key);
let cipher = Aes256Gcm::new(&aes_key);
cipher
.decrypt(&nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))
}
// Tauri commands
#[tauri::command]
pub fn set_e2e_password(password: String) -> Result<(), String> {
if password.len() < 8 {
return Err("Password must be at least 8 characters".to_string());
}
store_e2e_password(&password)
}
#[tauri::command]
pub fn check_has_e2e_password() -> bool {
has_e2e_password()
}
#[tauri::command]
pub fn delete_e2e_password() -> Result<(), String> {
remove_e2e_password()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = [42u8; 32];
let plaintext = b"Hello, World!";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_decrypt_empty_data() {
let key = [1u8; 32];
let plaintext = b"";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext.to_vec());
}
#[test]
fn test_encrypt_decrypt_large_data() {
let key = [7u8; 32];
let plaintext = vec![0xABu8; 1_048_576]; // 1MB
let encrypted = encrypt_bytes(&key, &plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_different_keys_different_ciphertext() {
let key1 = [1u8; 32];
let key2 = [2u8; 32];
let plaintext = b"same data";
let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap();
// Nonces are random so ciphertexts will differ regardless,
// but decrypting with wrong key should fail
assert!(decrypt_bytes(&key2, &encrypted1).is_err());
assert!(decrypt_bytes(&key1, &encrypted2).is_err());
}
#[test]
fn test_nonce_uniqueness() {
let key = [5u8; 32];
let plaintext = b"same data encrypted twice";
let encrypted1 = encrypt_bytes(&key, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key, plaintext).unwrap();
// Different nonces should produce different ciphertext
assert_ne!(encrypted1, encrypted2);
// But both should decrypt to the same plaintext
assert_eq!(
decrypt_bytes(&key, &encrypted1).unwrap(),
decrypt_bytes(&key, &encrypted2).unwrap()
);
}
#[test]
fn test_wrong_key_fails() {
let key = [10u8; 32];
let wrong_key = [20u8; 32];
let plaintext = b"secret data";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
assert!(decrypt_bytes(&wrong_key, &encrypted).is_err());
}
#[test]
fn test_key_derivation_deterministic() {
let salt = generate_salt();
let key1 = derive_profile_key("my_password", &salt).unwrap();
let key2 = derive_profile_key("my_password", &salt).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn test_key_derivation_different_salts() {
let salt1 = generate_salt();
let salt2 = generate_salt();
let key1 = derive_profile_key("my_password", &salt1).unwrap();
let key2 = derive_profile_key("my_password", &salt2).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn test_salt_generation_unique() {
let salt1 = generate_salt();
let salt2 = generate_salt();
assert_ne!(salt1, salt2);
}
#[test]
fn test_password_storage_roundtrip() {
let password = "test_password_12345";
store_e2e_password(password).unwrap();
assert!(has_e2e_password());
let loaded = load_e2e_password().unwrap();
assert_eq!(loaded, Some(password.to_string()));
remove_e2e_password().unwrap();
assert!(!has_e2e_password());
}
#[test]
fn test_decrypt_too_short_data() {
let key = [1u8; 32];
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
}
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -52,6 +52,8 @@ pub struct SyncManifest {
#[serde(rename = "excludeGlobs")]
pub exclude_globs: Vec<String>,
pub files: Vec<ManifestFileEntry>,
#[serde(default)]
pub encrypted: bool,
}
impl SyncManifest {
@@ -64,6 +66,7 @@ impl SyncManifest {
updated_at: now,
exclude_globs,
files: Vec::new(),
encrypted: false,
}
}
@@ -547,6 +550,7 @@ mod tests {
hash: "def".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, None);
@@ -588,6 +592,7 @@ mod tests {
hash: "new".to_string(),
},
],
encrypted: false,
};
let remote = SyncManifest {
@@ -616,6 +621,7 @@ mod tests {
hash: "gone".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, Some(&remote));
@@ -634,4 +640,22 @@ mod tests {
.files_to_delete_remote
.contains(&"deleted.txt".to_string()));
}
#[test]
fn test_manifest_encrypted_flag_default() {
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[]}"#;
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
assert!(!manifest.encrypted);
}
#[test]
fn test_manifest_with_encrypted_flag() {
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[],"encrypted":true}"#;
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
assert!(manifest.encrypted);
let serialized = serde_json::to_string(&manifest).unwrap();
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
assert!(deserialized.encrypted);
}
}
+6 -3
View File
@@ -1,4 +1,5 @@
mod client;
pub mod encryption;
mod engine;
pub mod manifest;
pub mod scheduler;
@@ -6,13 +7,15 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use engine::{
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities,
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_proxy_used_by_synced_profile, is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
request_profile_sync, set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled,
set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile,
is_vpn_used_by_synced_profile, request_profile_sync, set_extension_group_sync_enabled,
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
};
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
+185 -47
View File
@@ -35,6 +35,8 @@ pub struct SyncScheduler {
pending_proxies: Arc<Mutex<HashSet<String>>>,
pending_groups: Arc<Mutex<HashSet<String>>>,
pending_vpns: Arc<Mutex<HashSet<String>>>,
pending_extensions: Arc<Mutex<HashSet<String>>>,
pending_extension_groups: Arc<Mutex<HashSet<String>>>,
pending_tombstones: Arc<Mutex<Vec<(String, String)>>>,
running_profiles: Arc<Mutex<HashSet<String>>>,
in_flight_profiles: Arc<Mutex<HashSet<String>>>,
@@ -54,6 +56,8 @@ impl SyncScheduler {
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
pending_groups: Arc::new(Mutex::new(HashSet::new())),
pending_vpns: Arc::new(Mutex::new(HashSet::new())),
pending_extensions: Arc::new(Mutex::new(HashSet::new())),
pending_extension_groups: Arc::new(Mutex::new(HashSet::new())),
pending_tombstones: Arc::new(Mutex::new(Vec::new())),
running_profiles: Arc::new(Mutex::new(HashSet::new())),
in_flight_profiles: Arc::new(Mutex::new(HashSet::new())),
@@ -100,6 +104,18 @@ impl SyncScheduler {
}
drop(pending_vpns);
let pending_extensions = self.pending_extensions.lock().await;
if !pending_extensions.is_empty() {
return true;
}
drop(pending_extensions);
let pending_extension_groups = self.pending_extension_groups.lock().await;
if !pending_extension_groups.is_empty() {
return true;
}
drop(pending_extension_groups);
let pending_tombstones = self.pending_tombstones.lock().await;
if !pending_tombstones.is_empty() {
return true;
@@ -208,6 +224,16 @@ impl SyncScheduler {
pending.insert(group_id);
}
pub async fn queue_extension_sync(&self, extension_id: String) {
let mut pending = self.pending_extensions.lock().await;
pending.insert(extension_id);
}
pub async fn queue_extension_group_sync(&self, extension_group_id: String) {
let mut pending = self.pending_extension_groups.lock().await;
pending.insert(extension_group_id);
}
pub async fn queue_tombstone(&self, entity_type: String, entity_id: String) {
let mut pending = self.pending_tombstones.lock().await;
if !pending
@@ -232,7 +258,10 @@ impl SyncScheduler {
}
};
let sync_enabled_profiles: Vec<_> = profiles.into_iter().filter(|p| p.sync_enabled).collect();
let sync_enabled_profiles: Vec<_> = profiles
.into_iter()
.filter(|p| p.is_sync_enabled() && !p.is_cross_os())
.collect();
if sync_enabled_profiles.is_empty() {
log::debug!("No sync-enabled profiles found");
@@ -283,6 +312,8 @@ impl SyncScheduler {
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
SyncWorkItem::Tombstone(entity_type, entity_id) => {
scheduler.queue_tombstone(entity_type, entity_id).await
}
@@ -303,6 +334,8 @@ impl SyncScheduler {
self.process_pending_proxies(app_handle).await;
self.process_pending_groups(app_handle).await;
self.process_pending_vpns(app_handle).await;
self.process_pending_extensions(app_handle).await;
self.process_pending_extension_groups(app_handle).await;
self.process_pending_tombstones(app_handle).await;
}
@@ -353,7 +386,7 @@ impl SyncScheduler {
profile_manager.list_profiles().ok().and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id && p.sync_enabled)
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
})
};
@@ -382,6 +415,8 @@ impl SyncScheduler {
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
match result {
@@ -615,7 +650,83 @@ impl SyncScheduler {
}
}
async fn process_pending_tombstones(&self, app_handle: &tauri::AppHandle) {
async fn process_pending_extensions(&self, app_handle: &tauri::AppHandle) {
let extensions_to_sync: Vec<String> = {
let mut pending = self.pending_extensions.lock().await;
let list: Vec<String> = pending.drain().collect();
list
};
if extensions_to_sync.is_empty() {
return;
}
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
for ext_id in extensions_to_sync {
log::info!("Syncing extension {}", ext_id);
if let Err(e) = engine
.sync_extension_by_id_with_handle(&ext_id, app_handle)
.await
{
log::error!("Failed to sync extension {}: {}", ext_id, e);
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after extension sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
}
}
async fn process_pending_extension_groups(&self, app_handle: &tauri::AppHandle) {
let groups_to_sync: Vec<String> = {
let mut pending = self.pending_extension_groups.lock().await;
let list: Vec<String> = pending.drain().collect();
list
};
if groups_to_sync.is_empty() {
return;
}
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
for group_id in groups_to_sync {
log::info!("Syncing extension group {}", group_id);
if let Err(e) = engine
.sync_extension_group_by_id_with_handle(&group_id, app_handle)
.await
{
log::error!("Failed to sync extension group {}: {}", group_id, e);
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after extension group sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
}
}
async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) {
let tombstones: Vec<(String, String)> = {
let mut pending = self.pending_tombstones.lock().await;
std::mem::take(&mut *pending)
@@ -629,67 +740,94 @@ impl SyncScheduler {
log::info!("Processing tombstone for {} {}", entity_type, entity_id);
match entity_type.as_str() {
"profile" => {
let exists_locally = {
let profile_manager = ProfileManager::instance();
let profile_manager = ProfileManager::instance();
let profile_to_delete = {
if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid
.as_ref()
.map(|uuid| profiles.iter().any(|p| p.id == *uuid))
.unwrap_or(false)
profile_uuid.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
} else {
false
None
}
};
if exists_locally {
// Profile exists locally but was deleted remotely - delete locally
if let Some(mut profile) = profile_to_delete {
log::info!(
"Profile {} exists locally, deleting due to remote tombstone",
"Profile {} was deleted remotely, disabling sync locally",
entity_id
);
// Note: We don't actually delete here to avoid data loss.
// The user should be notified or we could add a confirmation step.
// For now, just log it.
} else {
// Profile doesn't exist locally - check if it still exists remotely
// (tombstone might have been created but profile files still exist)
// Try to download it
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
if let Ok(true) = engine
.download_profile_if_missing(app_handle, &entity_id)
.await
{
log::info!(
"Downloaded missing profile {} from remote storage",
entity_id
);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping profile download: {}", e);
}
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
if let Err(e) = profile_manager.save_profile(&profile) {
log::warn!("Failed to disable sync for profile {}: {}", entity_id, e);
} else {
log::info!(
"Profile {} sync disabled due to remote tombstone (local copy kept)",
entity_id
);
let _ = events::emit("profiles-changed", ());
}
}
}
"proxy" => {
log::debug!(
"Proxy tombstone for {} - local deletion not implemented",
entity_id
);
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxies = proxy_manager.get_stored_proxies();
if let Some(proxy) = proxies.iter().find(|p| p.id == entity_id) {
if proxy.sync_enabled {
log::info!("Proxy {} was deleted remotely, deleting locally", entity_id);
let proxy_file = proxy_manager.get_proxy_file_path(&entity_id);
if proxy_file.exists() {
let _ = std::fs::remove_file(&proxy_file);
}
proxy_manager.remove_from_memory(&entity_id);
let _ = events::emit("stored-proxies-changed", ());
}
}
}
"group" => {
log::debug!(
"Group tombstone for {} - local deletion not implemented",
entity_id
);
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
let groups = group_manager.get_all_groups().unwrap_or_default();
if let Some(group) = groups.iter().find(|g| g.id == entity_id) {
if group.sync_enabled {
log::info!("Group {} was deleted remotely, deleting locally", entity_id);
let _ = group_manager.delete_group_internal(&entity_id);
let _ = events::emit("groups-changed", ());
}
}
}
"vpn" => {
log::debug!(
"VPN tombstone for {} - local deletion not implemented",
entity_id
);
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
if let Ok(vpn) = storage.load_config(&entity_id) {
if vpn.sync_enabled {
log::info!("VPN {} was deleted remotely, deleting locally", entity_id);
let _ = storage.delete_config(&entity_id);
let _ = events::emit("vpn-configs-changed", ());
}
}
}
"extension" => {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
if let Ok(ext) = manager.get_extension(&entity_id) {
if ext.sync_enabled {
log::info!(
"Extension {} was deleted remotely, deleting locally",
entity_id
);
let _ = manager.delete_extension_internal(&entity_id);
let _ = events::emit("extensions-changed", ());
}
}
}
"extension_group" => {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
if let Ok(group) = manager.get_group(&entity_id) {
if group.sync_enabled {
log::info!(
"Extension group {} was deleted remotely, deleting locally",
entity_id
);
let _ = manager.delete_group_internal(&entity_id);
let _ = events::emit("extensions-changed", ());
}
}
}
_ => {}
}
+22
View File
@@ -24,6 +24,8 @@ pub enum SyncWorkItem {
Proxy(String),
Group(String),
Vpn(String),
Extension(String),
ExtensionGroup(String),
Tombstone(String, String),
}
@@ -235,6 +237,16 @@ impl SyncSubscription {
.strip_prefix("vpns/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::Vpn(s.to_string()))
} else if key.starts_with("extensions/") {
key
.strip_prefix("extensions/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::Extension(s.to_string()))
} else if key.starts_with("extension_groups/") {
key
.strip_prefix("extension_groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::ExtensionGroup(s.to_string()))
} else if key.starts_with("tombstones/") {
key.strip_prefix("tombstones/").and_then(|rest| {
if rest.starts_with("profiles/") {
@@ -257,6 +269,16 @@ impl SyncSubscription {
.strip_prefix("vpns/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("vpn".to_string(), id.to_string()))
} else if rest.starts_with("extensions/") {
rest
.strip_prefix("extensions/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("extension".to_string(), id.to_string()))
} else if rest.starts_with("extension_groups/") {
rest
.strip_prefix("extension_groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("extension_group".to_string(), id.to_string()))
} else {
None
}
+4 -38
View File
@@ -1,56 +1,22 @@
use crate::profile::BrowserProfile;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct TagsData {
tags: Vec<String>,
}
pub struct TagManager {
base_dirs: BaseDirs,
data_dir_override: Option<PathBuf>,
}
pub struct TagManager;
impl TagManager {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
.ok()
.map(PathBuf::from),
}
Self
}
// Helper for tests to override data directory without global env var
#[allow(dead_code)]
pub fn with_data_dir_override(dir: &Path) -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
data_dir_override: Some(dir.to_path_buf()),
}
}
fn get_tags_file_path(&self) -> PathBuf {
if let Some(dir) = &self.data_dir_override {
let mut override_path = dir.clone();
let _ = fs::create_dir_all(&override_path);
override_path.push("tags.json");
return override_path;
}
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("data");
path.push("tags.json");
path
fn get_tags_file_path(&self) -> std::path::PathBuf {
crate::app_dirs::data_subdir().join("tags.json")
}
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
+1 -11
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@@ -328,17 +327,8 @@ fn acquire_file_lock(lock_path: &PathBuf) -> Result<FileLockGuard, Box<dyn std::
Ok(FileLockGuard { _file: file })
}
/// Get the traffic stats storage directory
pub fn get_traffic_stats_dir() -> PathBuf {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let mut path = base_dirs.cache_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("traffic_stats");
path
crate::app_dirs::cache_dir().join("traffic_stats")
}
/// Get the storage key for traffic stats (profile_id if available, otherwise proxy_id)
+10 -22
View File
@@ -1,4 +1,3 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
@@ -67,13 +66,7 @@ impl VersionUpdater {
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let app_name = if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
};
let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache");
let cache_dir = crate::app_dirs::cache_dir().join("version_cache");
fs::create_dir_all(&cache_dir)?;
Ok(cache_dir)
}
@@ -369,20 +362,15 @@ impl VersionUpdater {
eprintln!("Failed to emit completion progress: {e}");
}
// After all version updates are complete, trigger auto-update check
if total_new_versions > 0 {
println!(
"Found {total_new_versions} new versions across all browsers. Checking for auto-updates..."
);
// Trigger auto-update check which will automatically download browsers
self
.auto_updater
.check_for_updates_with_progress(app_handle)
.await;
} else {
println!("No new versions found, skipping auto-update check");
}
// Always check for auto-updates — profiles may still be on older versions
// even if no new versions were found in the cache this cycle
println!(
"Checking for browser auto-updates (found {total_new_versions} new versions in cache)..."
);
self
.auto_updater
.check_for_updates_with_progress(app_handle)
.await;
Ok(results)
}
+7 -1
View File
@@ -73,7 +73,13 @@ impl OpenVpnTunnel {
#[cfg(windows)]
{
if let Ok(output) = Command::new("where").arg("openvpn").output() {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("openvpn")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
+7 -1
View File
@@ -45,7 +45,13 @@ impl OpenVpnSocks5Server {
#[cfg(windows)]
{
if let Ok(output) = Command::new("where").arg("openvpn").output() {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("openvpn")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
+25 -15
View File
@@ -97,22 +97,17 @@ impl VpnStorage {
/// Get the storage file path
fn get_storage_path() -> PathBuf {
let data_dir = directories::ProjectDirs::from("com", "donut", "donutbrowser")
.map(|dirs| dirs.data_local_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
if !data_dir.exists() {
let _ = fs::create_dir_all(&data_dir);
let vpn_dir = crate::app_dirs::vpn_dir();
if !vpn_dir.exists() {
let _ = fs::create_dir_all(&vpn_dir);
}
data_dir.join("vpn_configs.json")
Self::migrate_from_old_location(&vpn_dir);
vpn_dir.join("vpn_configs.json")
}
/// Get or create the encryption key
fn get_or_create_key() -> [u8; 32] {
let key_path = directories::ProjectDirs::from("com", "donut", "donutbrowser")
.map(|dirs| dirs.data_local_dir().join(".vpn_key"))
.unwrap_or_else(|| PathBuf::from(".vpn_key"));
let key_path = crate::app_dirs::vpn_dir().join(".vpn_key");
if key_path.exists() {
if let Ok(key_data) = fs::read(&key_path) {
@@ -138,6 +133,22 @@ impl VpnStorage {
key
}
/// Migrate VPN configs from the old ProjectDirs location to the new app_dirs location.
fn migrate_from_old_location(new_dir: &std::path::Path) {
let old_dir = match directories::ProjectDirs::from("com", "donut", "donutbrowser") {
Some(dirs) => dirs.data_local_dir().to_path_buf(),
None => return,
};
for filename in &["vpn_configs.json", ".vpn_key"] {
let old_path = old_dir.join(filename);
let new_path = new_dir.join(filename);
if old_path.exists() && !new_path.exists() {
let _ = fs::copy(&old_path, &new_path);
}
}
}
/// Load storage data from disk
fn load_storage(&self) -> Result<VpnStorageData, VpnError> {
if !self.storage_path.exists() {
@@ -328,7 +339,7 @@ impl VpnStorage {
}
let id = Uuid::new_v4().to_string();
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let sync_enabled = crate::sync::is_sync_configured();
let config = VpnConfig {
id,
@@ -397,7 +408,7 @@ impl VpnStorage {
let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn");
format!("{} ({})", base, vpn_type)
});
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let sync_enabled = crate::sync::is_sync_configured();
let config = VpnConfig {
id,
@@ -423,8 +434,7 @@ mod tests {
fn create_test_storage() -> (VpnStorage, TempDir) {
let temp_dir = TempDir::new().unwrap();
let mut storage = VpnStorage::new();
storage.storage_path = temp_dir.path().join("test_vpn_configs.json");
let storage = VpnStorage::with_dir(temp_dir.path());
(storage, temp_dir)
}
+3
View File
@@ -210,9 +210,12 @@ pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error
}
#[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();
}
+132 -23
View File
@@ -1,6 +1,5 @@
use crate::browser_runner::BrowserRunner;
use crate::profile::BrowserProfile;
use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -72,8 +71,6 @@ struct WayfernManagerInner {
pub struct WayfernManager {
inner: Arc<AsyncMutex<WayfernManagerInner>>,
#[allow(dead_code)]
base_dirs: BaseDirs,
http_client: Client,
}
@@ -91,7 +88,6 @@ impl WayfernManager {
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
instances: HashMap::new(),
})),
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
http_client: Client::new(),
}
}
@@ -102,26 +98,12 @@ impl WayfernManager {
#[allow(dead_code)]
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()
}
#[allow(dead_code)]
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()
}
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
@@ -263,6 +245,9 @@ impl WayfernManager {
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-background-mode")
.arg("--use-mock-keychain")
.arg("--password-store=basic")
.arg("--disable-features=DialMediaRouteProvider")
.stdout(Stdio::null())
.stderr(Stdio::null());
@@ -279,8 +264,11 @@ impl WayfernManager {
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/PID", &id.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
@@ -398,6 +386,7 @@ impl WayfernManager {
Ok(fingerprint_json)
}
#[allow(clippy::too_many_arguments)]
pub async fn launch_wayfern(
&self,
_app_handle: &AppHandle,
@@ -406,6 +395,8 @@ impl WayfernManager {
config: &WayfernConfig,
url: Option<&str>,
proxy_url: Option<&str>,
ephemeral: bool,
extension_paths: &[String],
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let executable_path = if let Some(path) = &config.executable_path {
let p = PathBuf::from(path);
@@ -450,6 +441,18 @@ impl WayfernManager {
args.push(format!("--proxy-server={proxy}"));
}
if ephemeral {
args.push("--disk-cache-size=1".to_string());
args.push("--disable-breakpad".to_string());
args.push("--disable-crash-reporter".to_string());
args.push("--no-service-autorun".to_string());
args.push("--disable-sync".to_string());
}
if !extension_paths.is_empty() {
args.push(format!("--load-extension={}", extension_paths.join(",")));
}
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
// This ensures fingerprint is applied at navigation commit time
@@ -602,8 +605,11 @@ impl WayfernManager {
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
log::info!("Stopped Wayfern instance {id} (PID: {pid})");
@@ -657,11 +663,19 @@ impl WayfernManager {
let mut inner = self.inner.lock().await;
// Canonicalize the target path for comparison
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
// Find the instance with the matching profile path
let mut found_id: Option<String> = None;
for (id, instance) in &inner.instances {
if let Some(path) = &instance.profile_path {
if path == 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 {
found_id = Some(id.clone());
break;
}
@@ -678,7 +692,6 @@ impl WayfernManager {
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
if system.process(sysinfo_pid).is_some() {
// Process is still running
return Some(WayfernLaunchResult {
id: id.clone(),
processId: instance.process_id,
@@ -687,7 +700,6 @@ impl WayfernManager {
cdp_port: instance.cdp_port,
});
} else {
// Process has died (e.g., Cmd+Q), remove from instances
log::info!(
"Wayfern process {} for profile {} is no longer running, cleaning up",
pid,
@@ -700,6 +712,101 @@ impl WayfernManager {
}
}
// If not found in in-memory instances, scan system processes.
// This handles the case where the GUI was restarted but Wayfern is still running.
if let Some((pid, found_profile_path, cdp_port)) =
Self::find_wayfern_process_by_profile(&target_path)
{
log::info!(
"Found running Wayfern process (PID: {}) for profile path via system scan",
pid
);
let instance_id = format!("recovered_{}", pid);
inner.instances.insert(
instance_id.clone(),
WayfernInstance {
id: instance_id.clone(),
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
},
);
return Some(WayfernLaunchResult {
id: instance_id,
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
cdp_port,
});
}
None
}
/// Scan system processes to find a Wayfern/Chromium process using a specific profile path
fn find_wayfern_process_by_profile(
target_path: &std::path::Path,
) -> Option<(u32, String, Option<u16>)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let target_path_str = target_path.to_string_lossy();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_chromium_like = exe_name.contains("wayfern")
|| exe_name.contains("chromium")
|| exe_name.contains("chrome");
if !is_chromium_like {
continue;
}
// Skip child processes (renderer, GPU, utility, zygote, etc.)
// Only the main browser process lacks a --type= argument
let is_child = cmd
.iter()
.any(|a| a.to_str().is_some_and(|s| s.starts_with("--type=")));
if is_child {
continue;
}
let mut matched = false;
let mut cdp_port: Option<u16> = None;
for arg in cmd.iter() {
if let Some(arg_str) = arg.to_str() {
if let Some(dir_val) = arg_str.strip_prefix("--user-data-dir=") {
let cmd_path = std::path::Path::new(dir_val)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(dir_val).to_path_buf());
if cmd_path == target_path {
matched = true;
}
}
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
cdp_port = port_val.parse().ok();
}
}
}
if matched {
return Some((pid.as_u32(), target_path_str.to_string(), cdp_port));
}
}
None
}
@@ -731,6 +838,8 @@ impl WayfernManager {
config,
url,
proxy_url,
profile.ephemeral,
&[],
)
.await
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.14.6",
"version": "0.15.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+485 -7
View File
@@ -7,6 +7,45 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::sleep;
/// Start a simple HTTP server that returns a specific body for any request.
/// Returns the (port, JoinHandle).
async fn start_mock_http_server(response_body: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Response, StatusCode};
use hyper_util::rt::TokioIo;
while let Ok((stream, _)) = listener.accept().await {
let io = TokioIo::new(stream);
tokio::task::spawn(async move {
let service = service_fn(move |_req| {
let body = response_body;
async move {
Ok::<_, hyper::Error>(
Response::builder()
.status(StatusCode::OK)
.body(Full::new(Bytes::from(body)))
.unwrap(),
)
}
});
let _ = http1::Builder::new().serve_connection(io, service).await;
});
}
});
// Wait for listener to be ready
sleep(Duration::from_millis(100)).await;
(port, handle)
}
/// Setup function to ensure donut-proxy binary exists and cleanup stale proxies
async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
@@ -516,13 +555,7 @@ async fn test_traffic_tracking() -> Result<(), Box<dyn std::error::Error + Send
// Wait for traffic stats to be flushed (happens every second)
sleep(Duration::from_secs(2)).await;
// Verify traffic was tracked by checking traffic stats file exists
// Note: Traffic stats are stored in the cache directory
let cache_dir = directories::BaseDirs::new()
.expect("Failed to get base directories")
.cache_dir()
.to_path_buf();
let traffic_stats_dir = cache_dir.join("DonutBrowserDev").join("traffic_stats");
let traffic_stats_dir = donutbrowser_lib::app_dirs::cache_dir().join("traffic_stats");
let stats_file = traffic_stats_dir.join(format!("{}.json", proxy_id));
if stats_file.exists() {
@@ -643,3 +676,448 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
Ok(())
}
/// Test that bypass rules cause requests to bypass the upstream proxy.
/// Requests to bypassed hosts go directly to the target, while
/// requests to non-bypassed hosts are routed through the upstream.
#[tokio::test]
#[serial]
async fn test_bypass_rules_http_direct() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// Start a target HTTP server (this is where bypassed requests should arrive)
let (target_port, target_handle) = start_mock_http_server("DIRECT-TARGET-RESPONSE").await;
println!("Target server listening on port {target_port}");
// Start a mock upstream proxy (non-bypassed requests go here)
let (upstream_port, upstream_handle) = start_mock_http_server("UPSTREAM-PROXY-RESPONSE").await;
println!("Mock upstream proxy listening on port {upstream_port}");
// Start donut-proxy with upstream + bypass rules for "127.0.0.1"
let bypass_rules = serde_json::json!(["127.0.0.1"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
target_handle.abort();
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
println!("Donut-proxy started on port {local_port} with bypass rules for 127.0.0.1");
sleep(Duration::from_millis(500)).await;
// Test 1: Request to 127.0.0.1 should be BYPASSED (direct connection to target)
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
println!(
"Bypass response: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
response_str.contains("DIRECT-TARGET-RESPONSE"),
"Bypassed request should reach target directly, got: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
!response_str.contains("UPSTREAM-PROXY-RESPONSE"),
"Bypassed request should NOT go through upstream"
);
println!("Bypass test passed: request to 127.0.0.1 went directly to target");
}
// Test 2: Request to non-bypassed host should go through upstream
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://non-bypass-host.test/ HTTP/1.1\r\nHost: non-bypass-host.test\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
println!(
"Non-bypass response: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
response_str.contains("UPSTREAM-PROXY-RESPONSE"),
"Non-bypassed request should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
!response_str.contains("DIRECT-TARGET-RESPONSE"),
"Non-bypassed request should NOT reach target directly"
);
println!("Non-bypass test passed: request to non-bypass-host.test went through upstream");
}
// Cleanup
tracker.cleanup_all().await;
target_handle.abort();
upstream_handle.abort();
Ok(())
}
/// Test bypass rules with regex patterns.
/// Verifies that regex-based rules match hosts correctly.
#[tokio::test]
#[serial]
async fn test_bypass_rules_regex_pattern() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let (target_port, target_handle) = start_mock_http_server("REGEX-DIRECT-RESPONSE").await;
let (upstream_port, upstream_handle) = start_mock_http_server("REGEX-UPSTREAM-RESPONSE").await;
// Use regex bypass rule: ^127\.0\.0\.\d+ (matches any 127.0.0.x address)
let bypass_rules = serde_json::json!([r"^127\.0\.0\.\d+"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
target_handle.abort();
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// Request to 127.0.0.1 should match regex and be bypassed
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("REGEX-DIRECT-RESPONSE"),
"Regex-bypassed request should reach target directly, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Regex bypass test passed: 127.0.0.1 matched regex rule");
}
// Request to non-matching host should go through upstream
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("REGEX-UPSTREAM-RESPONSE"),
"Non-matching request should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Regex non-bypass test passed: example.com did not match regex rule");
}
tracker.cleanup_all().await;
target_handle.abort();
upstream_handle.abort();
Ok(())
}
/// Test that bypass rules are persisted in the proxy config on disk.
#[tokio::test]
#[serial]
async fn test_bypass_rules_in_config() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let bypass_rules =
serde_json::json!(["example.com", "192.168.0.0/16", r".*\.internal\.net"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&["proxy", "start", "--bypass-rules", &bypass_rules],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// Read the proxy config file from disk to verify bypass rules are persisted
let proxies_dir = donutbrowser_lib::app_dirs::proxies_dir();
let config_file = proxies_dir.join(format!("{proxy_id}.json"));
assert!(
config_file.exists(),
"Proxy config file should exist at {:?}",
config_file
);
let config_content = std::fs::read_to_string(&config_file)?;
let disk_config: Value = serde_json::from_str(&config_content)?;
let rules = disk_config["bypass_rules"]
.as_array()
.expect("bypass_rules should be an array in the config");
assert_eq!(rules.len(), 3, "Should have 3 bypass rules");
assert_eq!(rules[0], "example.com");
assert_eq!(rules[1], "192.168.0.0/16");
assert_eq!(rules[2], r".*\.internal\.net");
println!(
"Config persistence test passed: {} bypass rules found in config",
rules.len()
);
tracker.cleanup_all().await;
Ok(())
}
/// Test bypass rules with multiple rule types combined (exact + regex).
#[tokio::test]
#[serial]
async fn test_bypass_rules_multiple_rules() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let (target_port, target_handle) = start_mock_http_server("MULTI-DIRECT-RESPONSE").await;
let (upstream_port, upstream_handle) = start_mock_http_server("MULTI-UPSTREAM-RESPONSE").await;
// Multiple bypass rules: exact match + regex
let bypass_rules = serde_json::json!(["127.0.0.1", r"^localhost$"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
target_handle.abort();
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// Request via 127.0.0.1 (exact match rule) → bypass
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("MULTI-DIRECT-RESPONSE"),
"Exact-match bypassed request should reach target, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Multi-rule test: exact match bypass works");
}
// Request via localhost (regex match rule) → bypass
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://localhost:{target_port}/ HTTP/1.1\r\nHost: localhost:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("MULTI-DIRECT-RESPONSE"),
"Regex-match bypassed request should reach target, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Multi-rule test: regex match bypass works");
}
// Request to non-matching host → upstream
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://other-host.test/ HTTP/1.1\r\nHost: other-host.test\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("MULTI-UPSTREAM-RESPONSE"),
"Non-matching request should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Multi-rule test: non-matching host goes through upstream");
}
tracker.cleanup_all().await;
target_handle.abort();
upstream_handle.abort();
Ok(())
}
/// Test that an empty bypass rules list means everything goes through upstream.
#[tokio::test]
#[serial]
async fn test_no_bypass_rules_all_through_upstream(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let (upstream_port, upstream_handle) = start_mock_http_server("ALL-UPSTREAM-RESPONSE").await;
// Start proxy with empty bypass rules
let bypass_rules = serde_json::json!([]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// All requests should go through upstream when bypass rules are empty
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://any-host.test/ HTTP/1.1\r\nHost: any-host.test\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("ALL-UPSTREAM-RESPONSE"),
"With no bypass rules, all requests should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Empty bypass rules test passed: all traffic goes through upstream");
tracker.cleanup_all().await;
upstream_handle.abort();
Ok(())
}
+114
View File
@@ -238,6 +238,46 @@ fn create_test_profile_bundle(temp_dir: &Path) -> Vec<u8> {
encoder.finish().unwrap()
}
fn create_test_profile_bundle_with_bypass_rules(temp_dir: &Path, bypass_rules: &[&str]) -> Vec<u8> {
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
let metadata = json!({
"id": "test-bypass-profile-id",
"name": "Bypass Rules Profile",
"browser": "camoufox",
"version": "120.0.0",
"release_type": "stable",
"sync_enabled": true,
"tags": [],
"proxy_bypass_rules": bypass_rules
});
let profile_dir = temp_dir.join("bypass_profile");
fs::create_dir_all(&profile_dir).unwrap();
fs::write(profile_dir.join("test_file.txt"), "bypass test content").unwrap();
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
{
let mut tar = Builder::new(&mut encoder);
let metadata_json = serde_json::to_string_pretty(&metadata).unwrap();
let mut header = tar::Header::new_gnu();
header.set_size(metadata_json.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar
.append_data(&mut header, "metadata.json", metadata_json.as_bytes())
.unwrap();
tar.append_dir_all("profile", &profile_dir).unwrap();
tar.finish().unwrap();
}
encoder.finish().unwrap()
}
fn extract_bundle(data: &[u8], target_dir: &Path) -> serde_json::Value {
use flate2::read::GzDecoder;
use tar::Archive;
@@ -727,3 +767,77 @@ async fn test_delta_sync_only_changed_files() {
client.delete(&file2_key, None).await.unwrap();
client.delete(&file3_key, None).await.unwrap();
}
#[tokio::test]
async fn test_profile_bypass_rules_sync() {
ensure_sync_server_available().await;
let client = TestClient::new();
let temp_dir = TempDir::new().unwrap();
let profile_id = uuid::Uuid::new_v4().to_string();
let test_key = format!("profiles/{}.tar.gz", profile_id);
let bypass_rules = vec!["example.com", "192.168.1.0/24", ".*\\.internal\\.net"];
let bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &bypass_rules);
let presign = client
.presign_upload(&test_key, "application/gzip")
.await
.unwrap();
client
.upload_bytes(&presign.url, &bundle, "application/gzip")
.await
.unwrap();
let stat = client.stat(&test_key).await.unwrap();
assert!(stat.exists);
// Download and verify bypass rules survive the round-trip
let download_presign = client.presign_download(&test_key).await.unwrap();
let downloaded = client.download_bytes(&download_presign.url).await.unwrap();
assert_eq!(downloaded.len(), bundle.len());
let extract_dir = temp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir).unwrap();
let metadata = extract_bundle(&downloaded, &extract_dir);
assert_eq!(metadata["name"], "Bypass Rules Profile");
assert_eq!(metadata["browser"], "camoufox");
let synced_rules = metadata["proxy_bypass_rules"]
.as_array()
.expect("proxy_bypass_rules should be an array");
assert_eq!(synced_rules.len(), 3);
assert_eq!(synced_rules[0], "example.com");
assert_eq!(synced_rules[1], "192.168.1.0/24");
assert_eq!(synced_rules[2], ".*\\.internal\\.net");
// Also verify empty bypass rules are handled correctly
let empty_bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &[]);
let empty_key = format!("profiles/{}.tar.gz", uuid::Uuid::new_v4());
let presign2 = client
.presign_upload(&empty_key, "application/gzip")
.await
.unwrap();
client
.upload_bytes(&presign2.url, &empty_bundle, "application/gzip")
.await
.unwrap();
let download_presign2 = client.presign_download(&empty_key).await.unwrap();
let downloaded2 = client.download_bytes(&download_presign2.url).await.unwrap();
let extract_dir2 = temp_dir.path().join("extracted2");
fs::create_dir_all(&extract_dir2).unwrap();
let metadata2 = extract_bundle(&downloaded2, &extract_dir2);
let empty_rules = metadata2["proxy_bypass_rules"]
.as_array()
.expect("proxy_bypass_rules should be an array");
assert!(empty_rules.is_empty());
// Cleanup
client.delete(&test_key, None).await.unwrap();
client.delete(&empty_key, None).await.unwrap();
}
+172 -55
View File
@@ -5,10 +5,13 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
import { GroupBadges } from "@/components/group-badges";
import { GroupManagementDialog } from "@/components/group-management-dialog";
@@ -26,6 +29,7 @@ import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
@@ -44,7 +48,12 @@ import {
showSuccessToast,
showToast,
} from "@/lib/toast-utils";
import type { BrowserProfile, CamoufoxConfig, WayfernConfig } from "@/types";
import type {
BrowserProfile,
CamoufoxConfig,
SyncSettings,
WayfernConfig,
} from "@/types";
type BrowserTypeString =
| "firefox"
@@ -105,6 +114,23 @@ export default function Home() {
(cloudUser?.subscriptionStatus === "active" ||
cloudUser?.planPeriod === "lifetime");
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
useState(false);
const checkSelfHostedSync = useCallback(async () => {
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
const hasConfig = Boolean(
settings.sync_server_url && settings.sync_token,
);
setSelfHostedSyncConfigured(hasConfig && !cloudUser);
} catch {
setSelfHostedSyncConfigured(false);
}
}, [cloudUser]);
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -115,11 +141,19 @@ export default function Home() {
useState(false);
const [groupManagementDialogOpen, setGroupManagementDialogOpen] =
useState(false);
const [extensionManagementDialogOpen, setExtensionManagementDialogOpen] =
useState(false);
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
useState(false);
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
useState(false);
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
const [cookieManagementDialogOpen, setCookieManagementDialogOpen] =
useState(false);
const [
currentProfileForCookieManagement,
setCurrentProfileForCookieManagement,
] = useState<BrowserProfile | null>(null);
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
string[]
>([]);
@@ -135,8 +169,15 @@ export default function Home() {
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
useState<BrowserProfile | null>(null);
const [cloneProfile, setCloneProfile] = useState<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
useState<string | undefined>(undefined);
const windowResizeWarningResolver = useRef<
((proceed: boolean) => void) | null
>(null);
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [currentPermissionType, setCurrentPermissionType] =
useState<PermissionType>("microphone");
@@ -464,21 +505,38 @@ export default function Home() {
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
}) => {
try {
await invoke<BrowserProfile>("create_browser_profile_new", {
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
vpnId: profileData.vpnId,
camoufoxConfig: profileData.camoufoxConfig,
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
});
const profile = await invoke<BrowserProfile>(
"create_browser_profile_new",
{
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
vpnId: profileData.vpnId,
camoufoxConfig: profileData.camoufoxConfig,
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
ephemeral: profileData.ephemeral,
},
);
if (profileData.extensionGroupId) {
try {
await invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: profileData.extensionGroupId,
});
} catch (err) {
console.error("Failed to assign extension group:", err);
}
}
// No need to manually reload - useProfileEvents will handle the update
} catch (error) {
@@ -487,7 +545,6 @@ export default function Home() {
error instanceof Error ? error.message : String(error)
}`,
);
throw error;
}
},
[selectedGroupId],
@@ -496,6 +553,27 @@ export default function Home() {
const launchProfile = useCallback(async (profile: BrowserProfile) => {
console.log("Starting launch for profile:", profile.name);
// Show one-time warning about window resizing for fingerprinted browsers
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
try {
const dismissed = await invoke<boolean>(
"get_window_resize_warning_dismissed",
);
if (!dismissed) {
const proceed = await new Promise<boolean>((resolve) => {
windowResizeWarningResolver.current = resolve;
setWindowResizeWarningBrowserType(profile.browser);
setWindowResizeWarningOpen(true);
});
if (!proceed) {
return;
}
}
} catch (error) {
console.error("Failed to check window resize warning:", error);
}
}
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
@@ -505,21 +583,12 @@ export default function Home() {
console.error("Failed to launch browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to launch browser: ${errorMessage}`);
// Re-throw the error so the table component can handle loading state cleanup
throw err;
}
}, []);
const handleCloneProfile = useCallback(async (profile: BrowserProfile) => {
try {
await invoke<BrowserProfile>("clone_profile", {
profileId: profile.id,
});
} catch (err: unknown) {
console.error("Failed to clone profile:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to clone profile: ${errorMessage}`);
}
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
setCloneProfile(profile);
}, []);
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
@@ -666,6 +735,11 @@ export default function Home() {
setCookieCopyDialogOpen(true);
}, []);
const handleOpenCookieManagement = useCallback((profile: BrowserProfile) => {
setCurrentProfileForCookieManagement(profile);
setCookieManagementDialogOpen(true);
}, []);
const handleGroupAssignmentComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
setGroupAssignmentDialogOpen(false);
@@ -690,10 +764,10 @@ export default function Home() {
const handleToggleProfileSync = useCallback(
async (profile: BrowserProfile) => {
try {
const enabling = !profile.sync_enabled;
await invoke("set_profile_sync_enabled", {
const enabling = !profile.sync_mode || profile.sync_mode === "Disabled";
await invoke("set_profile_sync_mode", {
profileId: profile.id,
enabled: enabling,
syncMode: enabling ? "Regular" : "Disabled",
});
if (enabling) {
userInitiatedSyncIds.current.add(profile.id);
@@ -715,34 +789,37 @@ export default function Home() {
let unlisten: (() => void) | undefined;
(async () => {
try {
unlisten = await listen<{ profile_id: string; status: string }>(
"profile-sync-status",
(event) => {
const { profile_id, status } = event.payload;
if (!userInitiatedSyncIds.current.has(profile_id)) return;
unlisten = await listen<{
profile_id: string;
status: string;
error?: string;
}>("profile-sync-status", (event) => {
const { profile_id, status, error } = event.payload;
if (!userInitiatedSyncIds.current.has(profile_id)) return;
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
if (status === "syncing") {
showToast({
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: 30000,
});
} else if (status === "synced") {
dismissToast(toastId);
showSuccessToast(`Profile '${name}' synced successfully`);
userInitiatedSyncIds.current.delete(profile_id);
} else if (status === "error") {
dismissToast(toastId);
showErrorToast(`Failed to sync profile '${name}'`);
userInitiatedSyncIds.current.delete(profile_id);
}
},
);
if (status === "syncing") {
showToast({
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: 30000,
});
} else if (status === "synced") {
dismissToast(toastId);
showSuccessToast(`Profile '${name}' synced successfully`);
userInitiatedSyncIds.current.delete(profile_id);
} else if (status === "error") {
dismissToast(toastId);
showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
);
userInitiatedSyncIds.current.delete(profile_id);
}
});
} catch (error) {
console.error("Failed to listen for sync status events:", error);
}
@@ -895,6 +972,11 @@ export default function Home() {
}
}, [isInitialized, checkAllPermissions]);
// Check self-hosted sync config on mount and when cloud user changes
useEffect(() => {
void checkSelfHostedSync();
}, [checkSelfHostedSync]);
// Filter data by selected group and search query
const filteredProfiles = useMemo(() => {
let filtered = profiles;
@@ -944,6 +1026,7 @@ export default function Home() {
onSettingsDialogOpen={setSettingsDialogOpen}
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
@@ -964,6 +1047,7 @@ export default function Home() {
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
onCopyCookiesToProfile={handleCopyCookiesToProfile}
onOpenCookieManagement={handleOpenCookieManagement}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
@@ -978,6 +1062,7 @@ export default function Home() {
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
/>
</div>
</main>
@@ -1048,6 +1133,12 @@ export default function Home() {
onPermissionGranted={checkNextPermission}
/>
<CloneProfileDialog
isOpen={!!cloneProfile}
onClose={() => setCloneProfile(null)}
profile={cloneProfile}
/>
<CamoufoxConfigDialog
isOpen={camoufoxConfigDialogOpen}
onClose={() => {
@@ -1072,6 +1163,12 @@ export default function Home() {
onGroupManagementComplete={handleGroupManagementComplete}
/>
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => setExtensionManagementDialogOpen(false)}
limitedMode={!crossOsUnlocked}
/>
<GroupAssignmentDialog
isOpen={groupAssignmentDialogOpen}
onClose={() => {
@@ -1106,6 +1203,15 @@ export default function Home() {
onCopyComplete={() => setSelectedProfilesForCookies([])}
/>
<CookieManagementDialog
isOpen={cookieManagementDialogOpen}
onClose={() => {
setCookieManagementDialogOpen(false);
setCurrentProfileForCookieManagement(null);
}}
profile={currentProfileForCookieManagement}
/>
<DeleteConfirmationDialog
isOpen={showBulkDeleteConfirmation}
onClose={() => setShowBulkDeleteConfirmation(false)}
@@ -1122,6 +1228,7 @@ export default function Home() {
isOpen={syncConfigDialogOpen}
onClose={(loginOccurred) => {
setSyncConfigDialogOpen(false);
void checkSelfHostedSync();
if (loginOccurred) {
setSyncAllDialogOpen(true);
}
@@ -1165,6 +1272,16 @@ export default function Home() {
isOpen={launchOnLoginDialogOpen}
onClose={() => setLaunchOnLoginDialogOpen(false)}
/>
<WindowResizeWarningDialog
isOpen={windowResizeWarningOpen}
browserType={windowResizeWarningBrowserType}
onResult={(proceed) => {
setWindowResizeWarningOpen(false);
windowResizeWarningResolver.current?.(proceed);
windowResizeWarningResolver.current = null;
}}
/>
</div>
);
}
@@ -163,6 +163,7 @@ export function CamoufoxConfigDialog({
forceAdvanced={true}
readOnly={isRunning}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
) : (
<SharedCamoufoxConfigForm
@@ -172,6 +173,7 @@ export function CamoufoxConfigDialog({
readOnly={isRunning}
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
)}
</div>
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile } from "@/types";
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
interface CloneProfileDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
onCloneComplete?: () => void;
}
export function CloneProfileDialog({
isOpen,
onClose,
profile,
onCloneComplete,
}: CloneProfileDialogProps) {
const { t } = useTranslation();
const [name, setName] = React.useState("");
const [isLoading, setIsLoading] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen && profile) {
const defaultName = `${profile.name} (Copy)`;
setName(defaultName);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
} else {
setIsLoading(false);
}
}, [isOpen, profile]);
if (!profile) return null;
const handleClone = async () => {
if (!name.trim() || isLoading) return;
setIsLoading(true);
try {
await invoke<BrowserProfile>("clone_profile", {
profileId: profile.id,
name: name.trim(),
});
onClose();
onCloneComplete?.();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to clone profile: ${errorMessage}`);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
<DialogDescription>
{t("profileInfo.clone.description")}
</DialogDescription>
</DialogHeader>
<Input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleClone();
}}
placeholder={t("profileInfo.clone.namePlaceholder")}
disabled={isLoading}
/>
<DialogFooter>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
onClick={() => void handleClone()}
isLoading={isLoading}
disabled={!name.trim()}
>
{t("profileInfo.clone.button")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+649
View File
@@ -0,0 +1,649 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useCallback, useEffect, useMemo, useState } from "react";
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
BrowserProfile,
CookieReadResult,
DomainCookies,
UnifiedCookie,
} from "@/types";
interface CookieImportResult {
cookies_imported: number;
cookies_replaced: number;
errors: string[];
}
interface CookieManagementDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
initialTab?: "import" | "export";
}
type SelectionState = {
[domain: string]: {
allSelected: boolean;
cookies: Set<string>;
};
};
const countCookies = (content: string): number => {
const trimmed = content.trim();
if (trimmed.startsWith("[")) {
try {
const arr = JSON.parse(trimmed);
if (Array.isArray(arr)) return arr.length;
} catch {
// Fall through to Netscape counting
}
}
return content.split("\n").filter((line) => {
const l = line.trim();
return l && !l.startsWith("#");
}).length;
};
function formatJsonCookies(cookies: UnifiedCookie[]): string {
const arr = cookies.map((c) => {
const sameSite =
c.same_site === 1
? "lax"
: c.same_site === 2
? "strict"
: "no_restriction";
return {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.is_secure,
httpOnly: c.is_http_only,
sameSite,
expirationDate: c.expires,
session: c.expires === 0,
hostOnly: !c.domain.startsWith("."),
};
});
return JSON.stringify(arr, null, 2);
}
function formatNetscapeCookies(cookies: UnifiedCookie[]): string {
const lines = ["# Netscape HTTP Cookie File"];
for (const c of cookies) {
const flag = c.domain.startsWith(".") ? "TRUE" : "FALSE";
const secure = c.is_secure ? "TRUE" : "FALSE";
lines.push(
`${c.domain}\t${flag}\t${c.path}\t${secure}\t${c.expires}\t${c.name}\t${c.value}`,
);
}
return lines.join("\n");
}
function initSelectionFromCookieData(data: CookieReadResult): SelectionState {
const sel: SelectionState = {};
for (const d of data.domains) {
sel[d.domain] = {
allSelected: true,
cookies: new Set(d.cookies.map((c) => c.name)),
};
}
return sel;
}
export function CookieManagementDialog({
isOpen,
onClose,
profile,
initialTab = "import",
}: CookieManagementDialogProps) {
// Import state
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [cookieCount, setCookieCount] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<CookieImportResult | null>(
null,
);
// Export state
const [format, setFormat] = useState<"netscape" | "json">("json");
const [isExporting, setIsExporting] = useState(false);
const [exportCookieData, setExportCookieData] =
useState<CookieReadResult | null>(null);
const [isLoadingExportCookies, setIsLoadingExportCookies] = useState(false);
const [exportSelection, setExportSelection] = useState<SelectionState>({});
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
new Set(),
);
const [activeTab, setActiveTab] = useState<string>(initialTab);
const selectedExportCount = useMemo(() => {
let count = 0;
for (const domain of Object.keys(exportSelection)) {
const ds = exportSelection[domain];
if (ds.allSelected) {
const domainData = exportCookieData?.domains.find(
(d) => d.domain === domain,
);
count += domainData?.cookie_count || 0;
} else {
count += ds.cookies.size;
}
}
return count;
}, [exportSelection, exportCookieData]);
const loadExportCookies = useCallback(
async (profileId: string) => {
if (exportCookieData) return;
setIsLoadingExportCookies(true);
try {
const result = await invoke<CookieReadResult>("read_profile_cookies", {
profileId,
});
setExportCookieData(result);
setExportSelection(initSelectionFromCookieData(result));
} catch (err) {
toast.error(
`Failed to load cookies: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setIsLoadingExportCookies(false);
}
},
[exportCookieData],
);
useEffect(() => {
if (activeTab === "export" && profile && !exportCookieData) {
void loadExportCookies(profile.id);
}
}, [activeTab, profile, exportCookieData, loadExportCookies]);
const resetImportState = useCallback(() => {
setFileContent(null);
setFileName(null);
setCookieCount(0);
setIsImporting(false);
setImportResult(null);
}, []);
const resetExportState = useCallback(() => {
setFormat("json");
setIsExporting(false);
setExportCookieData(null);
setExportSelection({});
setExpandedDomains(new Set());
}, []);
const handleClose = useCallback(() => {
resetImportState();
resetExportState();
setActiveTab(initialTab);
onClose();
}, [resetImportState, resetExportState, onClose, initialTab]);
const handleTabChange = useCallback(
(tab: string) => {
setActiveTab(tab);
resetImportState();
if (tab !== "export") {
resetExportState();
}
},
[resetImportState, resetExportState],
);
const handleFileRead = useCallback((file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
setFileContent(content);
setFileName(file.name);
setCookieCount(countCookies(content));
};
reader.onerror = () => {
toast.error("Failed to read file");
};
reader.readAsText(file);
}, []);
const handleImport = useCallback(async () => {
if (!fileContent || !profile) return;
setIsImporting(true);
try {
const result = await invoke<CookieImportResult>(
"import_cookies_from_file",
{
profileId: profile.id,
content: fileContent,
},
);
setImportResult(result);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setIsImporting(false);
}
}, [fileContent, profile]);
const getSelectedCookies = useCallback((): UnifiedCookie[] => {
if (!exportCookieData) return [];
const result: UnifiedCookie[] = [];
for (const domain of exportCookieData.domains) {
const ds = exportSelection[domain.domain];
if (!ds) continue;
if (ds.allSelected) {
result.push(...domain.cookies);
} else {
result.push(...domain.cookies.filter((c) => ds.cookies.has(c.name)));
}
}
return result;
}, [exportCookieData, exportSelection]);
const handleExport = useCallback(async () => {
if (!profile) return;
setIsExporting(true);
try {
const cookies = getSelectedCookies();
const content =
format === "json"
? formatJsonCookies(cookies)
: formatNetscapeCookies(cookies);
const ext = format === "json" ? "json" : "txt";
const defaultName = `${profile.name}_cookies.${ext}`;
const filePath = await save({
defaultPath: defaultName,
filters: [
{
name: format === "json" ? "JSON" : "Text",
extensions: [ext],
},
],
});
if (!filePath) {
setIsExporting(false);
return;
}
await writeTextFile(filePath, content);
toast.success("Cookies exported successfully");
handleClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setIsExporting(false);
}
}, [profile, format, getSelectedCookies, handleClose]);
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setExportSelection((prev) => {
const current = prev[domain];
if (current?.allSelected) {
const next = { ...prev };
delete next[domain];
return next;
}
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
});
},
[],
);
const toggleCookie = useCallback(
(domain: string, cookieName: string, totalCookies: number) => {
setExportSelection((prev) => {
const current = prev[domain] || {
allSelected: false,
cookies: new Set<string>(),
};
const newCookies = new Set(current.cookies);
if (newCookies.has(cookieName)) {
newCookies.delete(cookieName);
} else {
newCookies.add(cookieName);
}
if (newCookies.size === 0) {
const next = { ...prev };
delete next[domain];
return next;
}
return {
...prev,
[domain]: {
allSelected: newCookies.size === totalCookies,
cookies: newCookies,
},
};
});
},
[],
);
const toggleExpand = useCallback((domain: string) => {
setExpandedDomains((prev) => {
const next = new Set(prev);
if (next.has(domain)) {
next.delete(domain);
} else {
next.add(domain);
}
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
if (!exportCookieData) return;
if (selectedExportCount === exportCookieData.total_count) {
setExportSelection({});
} else {
setExportSelection(initSelectionFromCookieData(exportCookieData));
}
}, [exportCookieData, selectedExportCount]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Cookie Management</DialogTitle>
</DialogHeader>
<Tabs
defaultValue={initialTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="import">Import</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<TabsContent value="import" className="space-y-4 mt-4">
{!fileContent && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Import cookies from a Netscape or JSON format file.
</p>
<div
role="button"
tabIndex={0}
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
onClick={() =>
document.getElementById("cookie-file-input")?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("cookie-file-input")?.click();
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Click to choose a cookie file
<br />
<span className="text-xs">(.txt, .cookies, or .json)</span>
</p>
<input
id="cookie-file-input"
type="file"
accept=".txt,.cookies,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
</div>
)}
{fileContent && !importResult && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<div>
<div className="font-medium">{fileName}</div>
<div className="text-sm text-muted-foreground">
{cookieCount} cookies found
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={resetImportState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={cookieCount === 0}
>
Import
</LoadingButton>
</div>
</div>
)}
{importResult && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10">
<div className="font-medium text-green-600 dark:text-green-400">
Successfully imported {importResult.cookies_imported}{" "}
cookies ({importResult.cookies_replaced} replaced)
</div>
{importResult.errors.length > 0 && (
<div className="mt-2 text-sm text-muted-foreground">
{importResult.errors.length} line(s) skipped
</div>
)}
</div>
<div className="flex justify-end">
<RippleButton onClick={handleClose}>Done</RippleButton>
</div>
</div>
)}
</TabsContent>
<TabsContent value="export" className="space-y-3 mt-4">
<div className="space-y-2">
<Label>Format</Label>
<Select
value={format}
onValueChange={(v) => setFormat(v as "netscape" | "json")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="netscape">Netscape TXT</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Cookies{" "}
{exportCookieData && (
<span className="text-muted-foreground font-normal">
({selectedExportCount} of {exportCookieData.total_count}{" "}
selected)
</span>
)}
</Label>
{exportCookieData && exportCookieData.total_count > 0 && (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleSelectAll}
>
{selectedExportCount === exportCookieData.total_count
? "Deselect all"
: "Select all"}
</button>
)}
</div>
{isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
No cookies found in this profile
</div>
) : (
<ScrollArea className="h-[200px] border rounded-md">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
key={domain.domain}
domain={domain}
selection={exportSelection}
isExpanded={expandedDomains.has(domain.domain)}
onToggleDomain={toggleDomain}
onToggleCookie={toggleCookie}
onToggleExpand={toggleExpand}
/>
))}
</div>
</ScrollArea>
)}
</div>
<div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<LoadingButton
isLoading={isExporting}
onClick={() => void handleExport()}
disabled={selectedExportCount === 0}
>
Export
</LoadingButton>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
interface ExportDomainRowProps {
domain: DomainCookies;
selection: SelectionState;
isExpanded: boolean;
onToggleDomain: (domain: string, cookies: UnifiedCookie[]) => void;
onToggleCookie: (
domain: string,
cookieName: string,
totalCookies: number,
) => void;
onToggleExpand: (domain: string) => void;
}
function ExportDomainRow({
domain,
selection,
isExpanded,
onToggleDomain,
onToggleCookie,
onToggleExpand,
}: ExportDomainRowProps) {
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection?.allSelected || false;
const selectedCount = domainSelection?.cookies.size || 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
return (
<div>
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
className={isPartial ? "opacity-70" : ""}
/>
<button
type="button"
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
onClick={() => onToggleExpand(domain.domain)}
>
{isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" />
) : (
<LuChevronRight className="w-3.5 h-3.5" />
)}
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
({domain.cookie_count})
</span>
</button>
</div>
{isExpanded && (
<div className="ml-7 pl-2 border-l space-y-0.5">
{domain.cookies.map((cookie) => {
const isSelected =
domainSelection?.cookies.has(cookie.name) || false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() =>
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
)
}
/>
<span className="truncate">{cookie.name}</span>
</div>
);
})}
</div>
)}
</div>
);
}
+108 -6
View File
@@ -2,11 +2,13 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -28,7 +30,6 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
@@ -73,6 +74,8 @@ interface CreateProfileDialogProps {
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
}) => Promise<void>;
selectedGroupId?: string;
crossOsUnlocked?: boolean;
@@ -113,6 +116,7 @@ export function CreateProfileDialog({
selectedGroupId,
crossOsUnlocked = false,
}: CreateProfileDialogProps) {
const { t } = useTranslation();
const [profileName, setProfileName] = useState("");
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
@@ -162,6 +166,22 @@ export function CreateProfileDialog({
const { vpnConfigs } = useVpnEvents();
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
useState<string>();
const [extensionGroups, setExtensionGroups] = useState<
{ id: string; name: string; extension_ids: string[] }[]
>([]);
useEffect(() => {
if (isOpen) {
invoke<{ id: string; name: string; extension_ids: string[] }[]>(
"list_extension_groups",
)
.then(setExtensionGroups)
.catch(() => setExtensionGroups([]));
}
}, [isOpen]);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
@@ -175,6 +195,7 @@ export function CreateProfileDialog({
downloadBrowser,
loadDownloadedVersions,
isVersionDownloaded,
downloadedVersionsMap,
} = useBrowserDownload();
const loadSupportedBrowsers = useCallback(async () => {
@@ -333,6 +354,27 @@ export function CreateProfileDialog({
[releaseTypes],
);
const getCreatableVersion = useCallback(
(browserType?: string) => {
const bestVersion = getBestAvailableVersion(browserType);
if (bestVersion && isVersionDownloaded(bestVersion.version)) {
return bestVersion;
}
const browserDownloaded = downloadedVersionsMap[browserType ?? ""] ?? [];
if (browserDownloaded.length > 0) {
const fallbackVersion = browserDownloaded[0];
const releaseType =
browserType === "firefox-developer" ? "nightly" : "stable";
return {
version: fallbackVersion,
releaseType: releaseType as "stable" | "nightly",
};
}
return null;
},
[getBestAvailableVersion, isVersionDownloaded, downloadedVersionsMap],
);
const handleDownload = async (browserStr: string) => {
const bestVersion = getBestAvailableVersion(browserStr);
@@ -361,7 +403,7 @@ export function CreateProfileDialog({
if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected
if (selectedBrowser === "wayfern") {
const bestWayfernVersion = getBestAvailableVersion("wayfern");
const bestWayfernVersion = getCreatableVersion("wayfern");
if (!bestWayfernVersion) {
console.error("No Wayfern version available");
return;
@@ -380,10 +422,12 @@ export function CreateProfileDialog({
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
});
} else {
// Default to Camoufox
const bestCamoufoxVersion = getBestAvailableVersion("camoufox");
const bestCamoufoxVersion = getCreatableVersion("camoufox");
if (!bestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
@@ -403,6 +447,8 @@ export function CreateProfileDialog({
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
});
}
} else {
@@ -413,7 +459,7 @@ export function CreateProfileDialog({
}
// Use the best available version (stable preferred, nightly as fallback)
const bestVersion = getBestAvailableVersion(selectedBrowser);
const bestVersion = getCreatableVersion(selectedBrowser);
if (!bestVersion) {
console.error("No version available");
return;
@@ -457,6 +503,7 @@ export function CreateProfileDialog({
setWayfernConfig({
os: getCurrentOS() as WayfernOS, // Reset to current OS
});
setEphemeral(false);
onClose();
};
@@ -489,14 +536,14 @@ export function CreateProfileDialog({
if (!profileName.trim()) return true;
if (!selectedBrowser) return true;
if (isBrowserCurrentlyDownloading(selectedBrowser)) return true;
if (!isBrowserVersionAvailable(selectedBrowser)) return true;
if (!getCreatableVersion(selectedBrowser)) return true;
return false;
}, [
profileName,
selectedBrowser,
isBrowserCurrentlyDownloading,
isBrowserVersionAvailable,
getCreatableVersion,
]);
// Filter supported browsers for regular browsers
@@ -658,6 +705,28 @@ export function CreateProfileDialog({
/>
</div>
{/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
onCheckedChange={(checked) =>
setEphemeral(checked === true)
}
/>
<Label htmlFor="ephemeral" className="font-medium">
{t("profiles.ephemeral")}
</Label>
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
{t("profiles.ephemeralAlpha")}
</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("profiles.ephemeralDescription")}
</p>
</div>
{selectedBrowser === "wayfern" ? (
// Wayfern Configuration
<div className="space-y-6">
@@ -753,6 +822,7 @@ export function CreateProfileDialog({
onConfigChange={updateWayfernConfig}
isCreating
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</div>
) : selectedBrowser === "camoufox" ? (
@@ -851,6 +921,7 @@ export function CreateProfileDialog({
isCreating
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</div>
) : (
@@ -1021,6 +1092,37 @@ export function CreateProfileDialog({
</div>
)}
</div>
{/* Extension Group */}
{extensionGroups.length > 0 && (
<div className="space-y-2">
<Label>{t("extensions.extensionGroup")}</Label>
<Select
value={selectedExtensionGroupId || "none"}
onValueChange={(val) =>
setSelectedExtensionGroupId(
val === "none" ? undefined : val,
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t("profileInfo.values.none")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("profileInfo.values.none")}
</SelectItem>
{extensionGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.extension_ids.length})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</TabsContent>
@@ -0,0 +1,716 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuPuzzle, LuTrash2, LuUpload } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProBadge } from "@/components/ui/pro-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { Extension, ExtensionGroup } from "@/types";
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
import { RippleButton } from "./ui/ripple";
interface ExtensionManagementDialogProps {
isOpen: boolean;
onClose: () => void;
limitedMode: boolean;
}
export function ExtensionManagementDialog({
isOpen,
onClose,
limitedMode,
}: ExtensionManagementDialogProps) {
const { t } = useTranslation();
const [extensions, setExtensions] = useState<Extension[]>([]);
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Extension upload state
const [isUploading, setIsUploading] = useState(false);
const [extensionName, setExtensionName] = useState("");
const [showUploadForm, setShowUploadForm] = useState(false);
const [pendingFile, setPendingFile] = useState<{
name: string;
data: number[];
} | null>(null);
// Group state
const [showCreateGroup, setShowCreateGroup] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [editingGroup, setEditingGroup] = useState<ExtensionGroup | null>(null);
const [editGroupName, setEditGroupName] = useState("");
// Delete state
const [extensionToDelete, setExtensionToDelete] = useState<Extension | null>(
null,
);
const [groupToDelete, setGroupToDelete] = useState<ExtensionGroup | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false);
// Tab
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
"extensions",
);
const loadData = useCallback(async () => {
if (limitedMode) return;
setIsLoading(true);
try {
const [exts, groups] = await Promise.all([
invoke<Extension[]>("list_extensions"),
invoke<ExtensionGroup[]>("list_extension_groups"),
]);
setExtensions(exts);
setExtensionGroups(groups);
} catch {
// User may not have pro subscription
setExtensions([]);
setExtensionGroups([]);
} finally {
setIsLoading(false);
}
}, [limitedMode]);
useEffect(() => {
if (isOpen) {
void loadData();
}
}, [isOpen, loadData]);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const validExtensions = [".xpi", ".crx", ".zip"];
const isValid = validExtensions.some((ext) =>
file.name.toLowerCase().endsWith(ext),
);
if (!isValid) {
showErrorToast(t("extensions.invalidFileType"));
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
const data = Array.from(new Uint8Array(arrayBuffer));
const baseName = file.name
.replace(/\.(xpi|crx|zip)$/i, "")
.replace(/[-_]/g, " ");
setExtensionName(baseName);
setPendingFile({ name: file.name, data });
setShowUploadForm(true);
};
reader.onerror = () => {
showErrorToast(t("extensions.readError"));
};
reader.readAsArrayBuffer(file);
// Reset input
e.target.value = "";
},
[t],
);
const handleUpload = useCallback(async () => {
if (!pendingFile || !extensionName.trim()) return;
setIsUploading(true);
try {
await invoke("add_extension", {
name: extensionName.trim(),
fileName: pendingFile.name,
fileData: pendingFile.data,
});
showSuccessToast(t("extensions.uploadSuccess"));
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
} finally {
setIsUploading(false);
}
}, [pendingFile, extensionName, loadData, t]);
const handleDeleteExtension = useCallback(async () => {
if (!extensionToDelete) return;
setIsDeleting(true);
try {
await invoke("delete_extension", { extensionId: extensionToDelete.id });
showSuccessToast(t("extensions.deleteSuccess"));
setExtensionToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
} finally {
setIsDeleting(false);
}
}, [extensionToDelete, loadData, t]);
const handleCreateGroup = useCallback(async () => {
if (!newGroupName.trim()) return;
try {
await invoke("create_extension_group", { name: newGroupName.trim() });
showSuccessToast(t("extensions.groupCreateSuccess"));
setShowCreateGroup(false);
setNewGroupName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
}, [newGroupName, loadData, t]);
const handleUpdateGroup = useCallback(async () => {
if (!editingGroup || !editGroupName.trim()) return;
try {
await invoke("update_extension_group", {
groupId: editingGroup.id,
name: editGroupName.trim(),
});
showSuccessToast(t("extensions.groupUpdateSuccess"));
setEditingGroup(null);
setEditGroupName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
}, [editingGroup, editGroupName, loadData, t]);
const handleDeleteGroup = useCallback(async () => {
if (!groupToDelete) return;
setIsDeleting(true);
try {
await invoke("delete_extension_group", { groupId: groupToDelete.id });
showSuccessToast(t("extensions.groupDeleteSuccess"));
setGroupToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
} finally {
setIsDeleting(false);
}
}, [groupToDelete, loadData, t]);
const handleAddToGroup = useCallback(
async (groupId: string, extensionId: string) => {
try {
await invoke("add_extension_to_group", { groupId, extensionId });
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
},
[loadData],
);
const handleRemoveFromGroup = useCallback(
async (groupId: string, extensionId: string) => {
try {
await invoke("remove_extension_from_group", { groupId, extensionId });
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
},
[loadData],
);
const getCompatibilityBadge = (compat: string[]) => {
if (compat.includes("chromium") && compat.includes("firefox")) {
return (
<Badge variant="secondary">{t("extensions.compatibility.both")}</Badge>
);
}
if (compat.includes("chromium")) {
return (
<Badge variant="secondary">
{t("extensions.compatibility.chromium")}
</Badge>
);
}
if (compat.includes("firefox")) {
return (
<Badge variant="secondary">
{t("extensions.compatibility.firefox")}
</Badge>
);
}
return null;
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuPuzzle className="w-5 h-5" />
{t("extensions.title")}
{limitedMode && <ProBadge />}
</DialogTitle>
<DialogDescription>{t("extensions.description")}</DialogDescription>
</DialogHeader>
<div className="relative">
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
<span className="text-sm font-medium text-muted-foreground">
{t("extensions.proRequired")}
</span>
</div>
</div>
</>
)}
<div className="space-y-4">
{/* Tab selector */}
<div className="flex gap-2 border-b">
<button
type="button"
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "extensions"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("extensions")}
disabled={limitedMode}
>
{t("extensions.extensionsTab")}
</button>
<button
type="button"
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "groups"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("groups")}
disabled={limitedMode}
>
{t("extensions.groupsTab")}
</button>
</div>
{/* Notice */}
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
{t("extensions.managedNotice")}
</div>
{activeTab === "extensions" && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>{t("extensions.extensionsTab")}</Label>
<div>
<label htmlFor="ext-file-input">
<RippleButton
size="sm"
className="flex gap-2 items-center"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
>
<LuUpload className="w-4 h-4" />
{t("extensions.upload")}
</RippleButton>
</label>
<input
id="ext-file-input"
type="file"
accept=".xpi,.crx,.zip"
className="hidden"
onChange={handleFileSelect}
disabled={limitedMode}
/>
</div>
</div>
{/* Upload form */}
{showUploadForm && pendingFile && (
<div className="space-y-3 rounded-md border p-3">
<div className="text-sm text-muted-foreground">
{t("extensions.selectedFile")}:{" "}
<span className="font-medium text-foreground">
{pendingFile.name}
</span>
</div>
<div className="flex gap-2">
<Input
value={extensionName}
onChange={(e) => setExtensionName(e.target.value)}
placeholder={t("extensions.namePlaceholder")}
className="flex-1"
/>
<RippleButton
size="sm"
onClick={handleUpload}
disabled={isUploading || !extensionName.trim()}
>
{isUploading
? t("common.buttons.loading")
: t("common.buttons.add")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
</div>
)}
{/* Extensions list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("common.buttons.loading")}
</div>
) : extensions.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("extensions.empty")}
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[200px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-24">
{t("common.labels.type")}
</TableHead>
<TableHead className="w-32">
{t("extensions.compatibility.label")}
</TableHead>
<TableHead className="w-20">
{t("common.labels.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extensions.map((ext) => (
<TableRow key={ext.id}>
<TableCell className="font-medium">
{ext.name}
</TableCell>
<TableCell>
<Badge variant="outline">
.{ext.file_type}
</Badge>
</TableCell>
<TableCell>
{getCompatibilityBadge(
ext.browser_compatibility,
)}
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
setExtensionToDelete(ext)
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("extensions.delete")}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
)}
{activeTab === "groups" && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>{t("extensions.groupsTab")}</Label>
<RippleButton
size="sm"
onClick={() => setShowCreateGroup(true)}
className="flex gap-2 items-center"
disabled={limitedMode}
>
<GoPlus className="w-4 h-4" />
{t("extensions.createGroup")}
</RippleButton>
</div>
{/* Create group form */}
{showCreateGroup && (
<div className="flex gap-2 items-center">
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t("extensions.groupNamePlaceholder")}
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateGroup();
}}
/>
<RippleButton
size="sm"
onClick={handleCreateGroup}
disabled={!newGroupName.trim()}
>
{t("common.buttons.create")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowCreateGroup(false);
setNewGroupName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
)}
{/* Groups list */}
{extensionGroups.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("extensions.noGroups")}
</div>
) : (
<div className="space-y-3">
{extensionGroups.map((group) => (
<div
key={group.id}
className="rounded-md border p-3 space-y-2"
>
<div className="flex justify-between items-center">
{editingGroup?.id === group.id ? (
<div className="flex gap-2 items-center flex-1">
<Input
value={editGroupName}
onChange={(e) =>
setEditGroupName(e.target.value)
}
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter")
void handleUpdateGroup();
}}
/>
<RippleButton
size="sm"
onClick={handleUpdateGroup}
>
{t("common.buttons.save")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => setEditingGroup(null)}
>
{t("common.buttons.cancel")}
</Button>
</div>
) : (
<>
<span className="font-medium">
{group.name}
</span>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingGroup(group);
setEditGroupName(group.name);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("common.buttons.edit")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setGroupToDelete(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("extensions.deleteGroup")}
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
{/* Extension assignment */}
<div className="space-y-1">
{group.extension_ids.length > 0 && (
<div className="flex flex-wrap gap-1">
{group.extension_ids.map((extId) => {
const ext = extensions.find(
(e) => e.id === extId,
);
if (!ext) return null;
return (
<Badge
key={extId}
variant="secondary"
className="flex items-center gap-1"
>
{ext.name}
<button
type="button"
className="ml-1 hover:text-destructive"
onClick={() =>
handleRemoveFromGroup(group.id, extId)
}
>
×
</button>
</Badge>
);
})}
</div>
)}
{extensions.filter(
(e) => !group.extension_ids.includes(e.id),
).length > 0 && (
<Select
value=""
onValueChange={(extId) =>
handleAddToGroup(group.id, extId)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={t("extensions.addToGroup")}
/>
</SelectTrigger>
<SelectContent>
{extensions
.filter(
(e) =>
!group.extension_ids.includes(e.id),
)
.map((ext) => (
<SelectItem key={ext.id} value={ext.id}>
{ext.name} (.{ext.file_type})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete extension confirmation */}
<DeleteConfirmationDialog
isOpen={extensionToDelete !== null}
onClose={() => setExtensionToDelete(null)}
onConfirm={handleDeleteExtension}
title={t("extensions.deleteConfirmTitle")}
description={t("extensions.deleteConfirmDescription", {
name: extensionToDelete?.name ?? "",
})}
isLoading={isDeleting}
/>
{/* Delete group confirmation */}
<DeleteConfirmationDialog
isOpen={groupToDelete !== null}
onClose={() => setGroupToDelete(null)}
onConfirm={handleDeleteGroup}
title={t("extensions.deleteGroupConfirmTitle")}
description={t("extensions.deleteGroupConfirmDescription", {
name: groupToDelete?.name ?? "",
})}
isLoading={isDeleting}
/>
</>
);
}
+18 -1
View File
@@ -2,7 +2,14 @@ import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { LuCloud, LuPlug, LuSearch, LuUsers, LuX } from "react-icons/lu";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuSearch,
LuUsers,
LuX,
} from "react-icons/lu";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -23,6 +30,7 @@ type Props = {
onCreateProfileDialogOpen: (open: boolean) => void;
onSyncConfigDialogOpen: (open: boolean) => void;
onIntegrationsDialogOpen: (open: boolean) => void;
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
};
@@ -35,6 +43,7 @@ const HomeHeader = ({
onCreateProfileDialogOpen,
onSyncConfigDialogOpen,
onIntegrationsDialogOpen,
onExtensionManagementDialogOpen,
searchQuery,
onSearchQueryChange,
}: Props) => {
@@ -124,6 +133,14 @@ const HomeHeader = ({
<LuUsers className="mr-2 w-4 h-4" />
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onSyncConfigDialogOpen(true);
+161 -198
View File
@@ -13,19 +13,20 @@ import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { IoEllipsisHorizontal } from "react-icons/io5";
import {
LuCheck,
LuChevronDown,
LuChevronUp,
LuCookie,
LuLock,
LuInfo,
LuTrash2,
LuUsers,
} from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProfileInfoDialog } from "@/components/profile-info-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -37,12 +38,6 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
@@ -68,9 +63,9 @@ import { useTableSorting } from "@/hooks/use-table-sorting";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import {
getBrowserDisplayName,
getBrowserIcon,
getCurrentOS,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
@@ -99,6 +94,7 @@ import { RippleButton } from "./ui/ripple";
// Stable table meta type to pass volatile state/handlers into TanStack Table without
// causing column definitions to be recreated on every render.
type TableMeta = {
t: (key: string, options?: Record<string, unknown>) => string;
selectedProfiles: string[];
selectableCount: number;
showCheckboxes: boolean;
@@ -176,16 +172,18 @@ type TableMeta = {
onConfigureCamoufox?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
// Traffic snapshots (lightweight real-time data)
trafficSnapshots: Record<string, TrafficSnapshot>;
onOpenTrafficDialog?: (profileId: string) => void;
// Sync
syncStatuses: Record<string, string>;
syncStatuses: Record<string, { status: string; error?: string }>;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
syncUnlocked?: boolean;
// Country proxy creation (inline in proxy dropdown)
countries: LocationItem[];
@@ -208,8 +206,13 @@ function getProfileSyncStatusDot(
| "error"
| "disabled"
| undefined,
errorMessage?: string,
): SyncStatusDot | null {
const status = liveStatus ?? (profile.sync_enabled ? "synced" : "disabled");
const status =
liveStatus ??
(profile.sync_mode && profile.sync_mode !== "Disabled"
? "synced"
: "disabled");
switch (status) {
case "syncing":
@@ -229,7 +232,11 @@ function getProfileSyncStatusDot(
animate: false,
};
case "error":
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
return {
color: "bg-red-500",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
};
case "disabled":
if (profile.last_sync) {
return {
@@ -750,6 +757,7 @@ interface ProfilesDataTableProps {
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
onConfigureCamoufox: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
@@ -764,6 +772,7 @@ interface ProfilesDataTableProps {
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
syncUnlocked?: boolean;
}
export function ProfilesDataTable({
@@ -775,6 +784,7 @@ export function ProfilesDataTable({
onRenameProfile,
onConfigureCamoufox,
onCopyCookiesToProfile,
onOpenCookieManagement,
runningProfiles,
isUpdating,
onAssignProfilesToGroup,
@@ -787,7 +797,9 @@ export function ProfilesDataTable({
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked = false,
syncUnlocked = false,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]);
@@ -850,6 +862,8 @@ export function ProfilesDataTable({
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [profileForInfoDialog, setProfileForInfoDialog] =
React.useState<BrowserProfile | null>(null);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
@@ -897,7 +911,7 @@ export function ProfilesDataTable({
name?: string;
} | null>(null);
const [syncStatuses, setSyncStatuses] = React.useState<
Record<string, string>
Record<string, { status: string; error?: string }>
>({});
// Country proxy creation state (for inline proxy creation in dropdown)
@@ -1038,13 +1052,17 @@ export function ProfilesDataTable({
let unlisten: (() => void) | undefined;
(async () => {
try {
unlisten = await listen<{ profile_id: string; status: string }>(
"profile-sync-status",
(event) => {
const { profile_id, status } = event.payload;
setSyncStatuses((prev) => ({ ...prev, [profile_id]: status }));
},
);
unlisten = await listen<{
profile_id: string;
status: string;
error?: string;
}>("profile-sync-status", (event) => {
const { profile_id, status, error } = event.payload;
setSyncStatuses((prev) => ({
...prev,
[profile_id]: { status, error },
}));
});
} catch (error) {
console.error("Failed to listen for sync status events:", error);
}
@@ -1183,9 +1201,8 @@ export function ProfilesDataTable({
browserState.isClient && runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
if (isRunning || isLaunching || isStopping) {
newSet.delete(profileId);
hasChanges = true;
}
@@ -1200,7 +1217,6 @@ export function ProfilesDataTable({
runningProfiles,
launchingProfiles,
stoppingProfiles,
isUpdating,
browserState.isClient,
onSelectedProfilesChange,
selectedProfiles,
@@ -1346,13 +1362,7 @@ export function ProfilesDataTable({
browserState.isClient && runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
return (
!isRunning &&
!isLaunching &&
!isStopping &&
!isBrowserUpdating
);
return !isRunning && !isLaunching && !isStopping;
})
.map((profile) => profile.id),
)
@@ -1368,7 +1378,6 @@ export function ProfilesDataTable({
runningProfiles,
launchingProfiles,
stoppingProfiles,
isUpdating,
],
);
@@ -1379,8 +1388,7 @@ export function ProfilesDataTable({
browserState.isClient && runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
return !isRunning && !isLaunching && !isStopping;
});
}, [
profiles,
@@ -1388,12 +1396,12 @@ export function ProfilesDataTable({
runningProfiles,
launchingProfiles,
stoppingProfiles,
isUpdating,
]);
// Build table meta from volatile state so columns can stay stable
const tableMeta = React.useMemo<TableMeta>(
() => ({
t,
selectedProfiles,
selectableCount: selectableProfiles.length,
showCheckboxes,
@@ -1459,6 +1467,7 @@ export function ProfilesDataTable({
onCloneProfile,
onConfigureCamoufox,
onCopyCookiesToProfile,
onOpenCookieManagement,
// Traffic snapshots (lightweight real-time data)
trafficSnapshots,
@@ -1472,6 +1481,7 @@ export function ProfilesDataTable({
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked,
syncUnlocked,
// Country proxy creation
countries,
@@ -1480,6 +1490,7 @@ export function ProfilesDataTable({
handleCreateCountryProxy,
}),
[
t,
selectedProfiles,
selectableProfiles.length,
showCheckboxes,
@@ -1519,10 +1530,12 @@ export function ProfilesDataTable({
onCloneProfile,
onConfigureCamoufox,
onCopyCookiesToProfile,
onOpenCookieManagement,
syncStatuses,
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked,
syncUnlocked,
countries,
canCreateLocationProxy,
loadCountries,
@@ -1554,7 +1567,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const browser = profile.browser;
const IconComponent = getBrowserIcon(browser);
const IconComponent = getProfileIcon(profile);
const isCrossOs = isCrossOsProfile(profile);
const isSelected = meta.isProfileSelected(profile.id);
@@ -1562,15 +1575,14 @@ export function ProfilesDataTable({
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
const isDisabled = isRunning || isLaunching || isStopping;
// Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are
if (isCrossOs && !meta.showCheckboxes && !isSelected) {
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
const OsIcon =
profile.host_os === "macos"
? FaApple
@@ -1595,10 +1607,7 @@ export function ProfilesDataTable({
</span>
</TooltipTrigger>
<TooltipContent>
<p>
This profile was created on {osName} and is not supported on
this system
</p>
<p>{crossOsTooltip}</p>
</TooltipContent>
</Tooltip>
);
@@ -1609,14 +1618,10 @@ export function ProfilesDataTable({
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
return (
<NonHoverableTooltip
content={
<p>
This profile was created on {osName} and is not supported on
this system
</p>
}
content={<p>{crossOsTooltip}</p>}
sideOffset={4}
horizontalOffset={8}
>
@@ -1883,13 +1888,8 @@ export function ProfilesDataTable({
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
isRunning || isLaunching || isStopping || isCrossOs;
return (
<button
@@ -1932,13 +1932,8 @@ export function ProfilesDataTable({
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
isRunning || isLaunching || isStopping || isCrossOs;
return (
<TagsCell
@@ -1965,13 +1960,8 @@ export function ProfilesDataTable({
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
isRunning || isLaunching || isStopping || isCrossOs;
return (
<NoteCell
@@ -1996,13 +1986,8 @@ export function ProfilesDataTable({
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
isRunning || isLaunching || isStopping || isCrossOs;
const hasProxyOverride = Object.hasOwn(
meta.proxyOverrides,
@@ -2262,7 +2247,8 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const profile = row.original;
const meta = table.options.meta as TableMeta;
const liveStatus = meta.syncStatuses[profile.id] as
const syncEntry = meta.syncStatuses[profile.id];
const liveStatus = syncEntry?.status as
| "syncing"
| "waiting"
| "synced"
@@ -2270,7 +2256,11 @@ export function ProfilesDataTable({
| "disabled"
| undefined;
const dot = getProfileSyncStatusDot(profile, liveStatus);
const dot = getProfileSyncStatusDot(
profile,
liveStatus,
syncEntry?.error,
);
if (!dot) return null;
return (
@@ -2289,118 +2279,28 @@ export function ProfilesDataTable({
},
{
id: "settings",
size: 40,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isBrowserUpdating =
meta.isClient && meta.isUpdating(profile.browser);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
const isDeleteDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
<div className="flex justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="p-0 w-8 h-8"
disabled={!meta.isClient}
>
<span className="sr-only">Open menu</span>
<IoEllipsisHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
meta.onOpenTrafficDialog?.(profile.id);
}}
disabled={isCrossOs}
>
View Network
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (meta.crossOsUnlocked) {
meta.onToggleProfileSync?.(profile);
}
}}
disabled={!meta.crossOsUnlocked || isCrossOs}
>
<span className="flex items-center gap-2">
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
{!meta.crossOsUnlocked && (
<LuLock className="w-3 h-3 text-muted-foreground" />
)}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
meta.onAssignProfilesToGroup?.([profile.id]);
}}
disabled={isDisabled}
>
Assign to Group
</DropdownMenuItem>
{(profile.browser === "camoufox" ||
profile.browser === "wayfern") &&
meta.onConfigureCamoufox && (
<DropdownMenuItem
onClick={() => {
meta.onConfigureCamoufox?.(profile);
}}
disabled={isDisabled}
>
Change Fingerprint
</DropdownMenuItem>
)}
{(profile.browser === "camoufox" ||
profile.browser === "wayfern") &&
meta.onCopyCookiesToProfile && (
<DropdownMenuItem
onClick={() => {
meta.onCopyCookiesToProfile?.(profile);
}}
disabled={isDisabled}
>
Copy Cookies to Profile
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
meta.onCloneProfile?.(profile);
}}
disabled={isDisabled}
>
Clone Profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProfileToDelete(profile);
}}
disabled={isDeleteDisabled}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
className="p-0 w-8 h-8"
disabled={!meta.isClient}
onClick={() => setProfileForInfoDialog(profile)}
>
<span className="sr-only">Profile info</span>
<LuInfo className="w-4 h-4" />
</Button>
</div>
);
},
},
],
[],
[t],
);
const table = useReactTable({
@@ -2418,8 +2318,7 @@ export function ProfilesDataTable({
browserState.isClient && runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
return !isRunning && !isLaunching && !isStopping;
},
getSortedRowModel: getSortedRowModel(),
getCoreRowModel: getCoreRowModel(),
@@ -2443,7 +2342,14 @@ export function ProfilesDataTable({
<TableRow key={headerGroup.id} className="overflow-visible">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{header.isPlaceholder
? null
: flexRender(
@@ -2458,25 +2364,42 @@ export function ProfilesDataTable({
</TableHeader>
<TableBody className="overflow-visible">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(
"overflow-visible hover:bg-accent/50",
isCrossOsProfile(row.original) && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-visible">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
table.getRowModel().rows.map((row) => {
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(row.original.host_os ?? ""),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
className={cn(
"overflow-visible hover:bg-accent/50",
rowIsCrossOs && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-visible"
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell
@@ -2499,6 +2422,45 @@ export function ProfilesDataTable({
confirmButtonText="Delete Profile"
isLoading={isDeleting}
/>
{profileForInfoDialog &&
(() => {
const infoProfile = profileForInfoDialog;
const infoIsRunning =
browserState.isClient && runningProfiles.has(infoProfile.id);
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
const infoIsStopping = stoppingProfiles.has(infoProfile.id);
const infoIsCrossOs = isCrossOsProfile(infoProfile);
const infoIsDisabled =
infoIsRunning || infoIsLaunching || infoIsStopping || infoIsCrossOs;
return (
<ProfileInfoDialog
isOpen={profileForInfoDialog !== null}
onClose={() => setProfileForInfoDialog(null)}
profile={infoProfile}
storedProxies={storedProxies}
vpnConfigs={vpnConfigs}
onOpenTrafficDialog={(profileId) => {
const profile = profiles.find((p) => p.id === profileId);
setTrafficDialogProfile({ id: profileId, name: profile?.name });
}}
onOpenProfileSyncDialog={onOpenProfileSyncDialog}
onAssignProfilesToGroup={onAssignProfilesToGroup}
onConfigureCamoufox={onConfigureCamoufox}
onCopyCookiesToProfile={onCopyCookiesToProfile}
onOpenCookieManagement={onOpenCookieManagement}
onCloneProfile={onCloneProfile}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
setProfileToDelete(profile);
}}
crossOsUnlocked={crossOsUnlocked}
isRunning={infoIsRunning}
isDisabled={infoIsDisabled}
isCrossOs={infoIsCrossOs}
syncStatuses={syncStatuses}
/>
);
})()}
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} />
{onBulkGroupAssignment && (
@@ -2521,9 +2483,10 @@ export function ProfilesDataTable({
)}
{onBulkCopyCookies && (
<DataTableActionBarAction
tooltip="Copy Cookies"
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
onClick={onBulkCopyCookies}
size="icon"
disabled={!crossOsUnlocked}
>
<LuCookie />
</DataTableActionBarAction>
+523
View File
@@ -0,0 +1,523 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
import {
LuChevronRight,
LuClipboard,
LuClipboardCheck,
LuCookie,
LuCopy,
LuFingerprint,
LuGlobe,
LuGroup,
LuPlus,
LuRefreshCw,
LuSettings,
LuTrash2,
LuX,
} from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ProBadge } from "@/components/ui/pro-badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
ProfileGroup,
StoredProxy,
VpnConfig,
} from "@/types";
interface ProfileInfoDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
storedProxies: StoredProxy[];
vpnConfigs: VpnConfig[];
onOpenTrafficDialog?: (profileId: string) => void;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onAssignProfilesToGroup?: (profileIds: string[]) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
isRunning?: boolean;
isDisabled?: boolean;
isCrossOs?: boolean;
syncStatuses: Record<string, { status: string; error?: string }>;
}
function OSIcon({ os }: { os: string }) {
switch (os) {
case "macos":
return <FaApple className="w-3.5 h-3.5" />;
case "windows":
return <FaWindows className="w-3.5 h-3.5" />;
case "linux":
return <FaLinux className="w-3.5 h-3.5" />;
default:
return null;
}
}
export function ProfileInfoDialog({
isOpen,
onClose,
profile,
storedProxies,
vpnConfigs,
onOpenTrafficDialog,
onOpenProfileSyncDialog,
onAssignProfilesToGroup,
onConfigureCamoufox,
onCopyCookiesToProfile,
onOpenCookieManagement,
onCloneProfile,
onDeleteProfile,
crossOsUnlocked = false,
isRunning = false,
isDisabled = false,
isCrossOs = false,
syncStatuses,
}: ProfileInfoDialogProps) {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const [groupName, setGroupName] = React.useState<string | null>(null);
const [extensionGroupName, setExtensionGroupName] = React.useState<
string | null
>(null);
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
const [newRule, setNewRule] = React.useState("");
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
setGroupName(null);
return;
}
(async () => {
try {
const groups = await invoke<ProfileGroup[]>("get_groups");
const group = groups.find((g) => g.id === profile.group_id);
setGroupName(group?.name ?? null);
} catch {
setGroupName(null);
}
})();
}, [isOpen, profile?.group_id]);
React.useEffect(() => {
if (!isOpen || !profile?.extension_group_id) {
setExtensionGroupName(null);
return;
}
(async () => {
try {
const group = await invoke<{ name: string } | null>(
"get_extension_group_for_profile",
{ profileId: profile.id },
);
setExtensionGroupName(group?.name ?? null);
} catch {
setExtensionGroupName(null);
}
})();
}, [isOpen, profile?.extension_group_id, profile?.id]);
React.useEffect(() => {
if (!isOpen) {
setCopied(false);
setNewRule("");
}
if (isOpen && profile) {
setBypassRules(profile.proxy_bypass_rules ?? []);
}
}, [isOpen, profile]);
if (!profile) return null;
const ProfileIcon = getProfileIcon(profile);
const isCamoufoxOrWayfern =
profile.browser === "camoufox" || profile.browser === "wayfern";
const isDeleteDisabled = isRunning;
const proxyName = profile.proxy_id
? storedProxies.find((p) => p.id === profile.proxy_id)?.name
: null;
const vpnName = profile.vpn_id
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
: null;
const networkLabel = vpnName
? `VPN: ${vpnName}`
: proxyName
? `Proxy: ${proxyName}`
: t("profileInfo.values.none");
const syncStatus = syncStatuses[profile.id];
const syncMode = profile.sync_mode ?? "Disabled";
const syncLabel = syncStatus
? `${syncMode} (${syncStatus.status})`
: syncMode;
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(profile.id);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
const handleAction = (action: () => void) => {
onClose();
action();
};
const updateBypassRules = async (rules: string[]) => {
if (!profile) return;
try {
await invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules,
});
setBypassRules(rules);
} catch {
// ignore
}
};
const handleAddRule = () => {
const trimmed = newRule.trim();
if (!trimmed || bypassRules.includes(trimmed)) return;
const updated = [...bypassRules, trimmed];
setNewRule("");
void updateBypassRules(updated);
};
const handleRemoveRule = (rule: string) => {
void updateBypassRules(bypassRules.filter((r) => r !== rule));
};
const infoFields: { label: string; value: React.ReactNode }[] = [
{
label: t("profileInfo.fields.profileId"),
value: (
<span className="flex items-center gap-1.5">
<span className="font-mono text-xs truncate max-w-[180px]">
{profile.id}
</span>
<button
type="button"
onClick={() => void handleCopyId()}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{copied ? (
<LuClipboardCheck className="w-3.5 h-3.5" />
) : (
<LuClipboard className="w-3.5 h-3.5" />
)}
</button>
</span>
),
},
{
label: t("profileInfo.fields.browser"),
value: `${getBrowserDisplayName(profile.browser)} ${profile.version}`,
},
{
label: t("profileInfo.fields.releaseType"),
value:
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1),
},
{
label: t("profileInfo.fields.proxyVpn"),
value: networkLabel,
},
{
label: t("profileInfo.fields.group"),
value: groupName ?? t("profileInfo.values.none"),
},
{
label: t("profileInfo.fields.extensionGroup"),
value: extensionGroupName ?? t("profileInfo.values.none"),
},
{
label: t("profileInfo.fields.tags"),
value:
profile.tags && profile.tags.length > 0 ? (
<span className="flex flex-wrap gap-1">
{profile.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</span>
) : (
t("profileInfo.values.none")
),
},
{
label: t("profileInfo.fields.note"),
value: profile.note || t("profileInfo.values.none"),
},
{
label: t("profileInfo.fields.syncStatus"),
value: syncLabel,
},
{
label: t("profileInfo.fields.lastLaunched"),
value: profile.last_launch
? formatRelativeTime(profile.last_launch)
: t("profileInfo.values.never"),
},
];
if (profile.host_os && isCrossOsProfile(profile)) {
infoFields.push({
label: t("profileInfo.fields.hostOs"),
value: (
<span className="flex items-center gap-1.5">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_os)}
</span>
),
});
}
if (profile.ephemeral) {
infoFields.push({
label: t("profileInfo.fields.ephemeral"),
value: (
<Badge variant="secondary" className="text-xs">
{t("profileInfo.values.yes")}
</Badge>
),
});
}
type ActionItem = {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
proBadge?: boolean;
hidden?: boolean;
};
const actions: ActionItem[] = [
{
icon: <LuGlobe className="w-4 h-4" />,
label: t("profiles.actions.viewNetwork"),
onClick: () => handleAction(() => onOpenTrafficDialog?.(profile.id)),
disabled: isCrossOs,
},
{
icon: <LuRefreshCw className="w-4 h-4" />,
label: t("profiles.actions.syncSettings"),
onClick: () => handleAction(() => onOpenProfileSyncDialog?.(profile)),
disabled: isCrossOs,
hidden: profile.ephemeral === true,
},
{
icon: <LuGroup className="w-4 h-4" />,
label: t("profiles.actions.assignToGroup"),
onClick: () =>
handleAction(() => onAssignProfilesToGroup?.([profile.id])),
disabled: isDisabled,
},
{
icon: <LuFingerprint className="w-4 h-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => handleAction(() => onConfigureCamoufox?.(profile)),
disabled: isDisabled,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
icon: <LuCopy className="w-4 h-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
!onCopyCookiesToProfile,
},
{
icon: <LuCookie className="w-4 h-4" />,
label: t("profileInfo.actions.manageCookies"),
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
!onOpenCookieManagement,
},
{
icon: <LuSettings className="w-4 h-4" />,
label: t("profiles.actions.clone"),
onClick: () => handleAction(() => onCloneProfile?.(profile)),
disabled: isDisabled,
hidden: profile.ephemeral === true,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
onClick: () => handleAction(() => onDeleteProfile?.(profile)),
disabled: isDeleteDisabled,
destructive: true,
},
];
const visibleActions = actions.filter((a) => !a.hidden);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="info">
<TabsList className="w-full">
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
</TabsList>
<TabsContent value="info">
<div className="flex flex-col items-center gap-1 py-3">
<ProfileIcon className="w-12 h-12 text-muted-foreground" />
<h3 className="text-lg font-semibold">{profile.name}</h3>
<p className="text-sm text-muted-foreground">
{getBrowserDisplayName(profile.browser)} {profile.version}
</p>
</div>
<div className="max-h-[300px] overflow-y-auto">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
{infoFields.map((field) => (
<React.Fragment key={field.label}>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{field.label}
</span>
<span className="text-sm">{field.value}</span>
</React.Fragment>
))}
</div>
</div>
</TabsContent>
<TabsContent value="settings">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.proBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
<div className="border-t my-2" />
<div className="flex flex-col gap-3 py-2">
<div>
<h4 className="text-sm font-medium">
{t("profileInfo.network.bypassRules")}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t("profileInfo.network.bypassRulesDescription")}
</p>
</div>
<div className="flex gap-2">
<Input
value={newRule}
onChange={(e) => setNewRule(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddRule();
}}
placeholder={t("profileInfo.network.rulePlaceholder")}
className="flex-1 text-sm"
/>
<Button
size="sm"
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
</div>
{bypassRules.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("profileInfo.network.noRules")}
</p>
) : (
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
{bypassRules.map((rule) => (
<div
key={rule}
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
>
<span className="font-mono text-xs truncate">{rule}</span>
<button
type="button"
onClick={() => handleRemoveRule(rule)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("profileInfo.network.ruleTypes")}
</p>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+141 -61
View File
@@ -2,10 +2,10 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -15,8 +15,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { BrowserProfile, SyncSettings } from "@/types";
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
import { isSyncEnabled } from "@/types";
interface ProfileSyncDialogProps {
isOpen: boolean;
@@ -31,21 +34,35 @@ export function ProfileSyncDialog({
profile,
onSyncConfigOpen,
}: ProfileSyncDialogProps) {
const { t } = useTranslation();
const { user: cloudUser } = useCloudAuth();
const isCloudSyncEligible =
cloudUser != null &&
cloudUser.plan !== "free" &&
(cloudUser.subscriptionStatus === "active" ||
cloudUser.planPeriod === "lifetime");
const [isSaving, setIsSaving] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [syncEnabled, setSyncEnabled] = useState(
profile?.sync_enabled ?? false,
const [syncMode, setSyncMode] = useState<SyncMode>(
profile?.sync_mode ?? "Disabled",
);
const [hasConfig, setHasConfig] = useState(false);
const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
const hasConfig = isCloudSyncEligible || hasSelfHostedConfig;
const checkSyncConfig = useCallback(async () => {
setIsCheckingConfig(true);
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
setHasConfig(Boolean(settings.sync_server_url && settings.sync_token));
setHasSelfHostedConfig(
Boolean(settings.sync_server_url && settings.sync_token),
);
const hasPassword = await invoke<boolean>("check_has_e2e_password");
setHasE2ePassword(hasPassword);
} catch {
setHasConfig(false);
setHasSelfHostedConfig(false);
} finally {
setIsCheckingConfig(false);
}
@@ -54,7 +71,7 @@ export function ProfileSyncDialog({
const handleOpenChange = useCallback(
(open: boolean) => {
if (open && profile) {
setSyncEnabled(profile.sync_enabled ?? false);
setSyncMode(profile.sync_mode ?? "Disabled");
void checkSyncConfig();
}
if (!open) {
@@ -64,39 +81,49 @@ export function ProfileSyncDialog({
[profile, onClose, checkSyncConfig],
);
const handleToggleSync = useCallback(async () => {
if (!profile) return;
const handleModeChange = useCallback(
async (newMode: string) => {
if (!profile) return;
if (!hasConfig) {
showErrorToast("Please configure sync service first");
onSyncConfigOpen();
onClose();
return;
}
if (!hasConfig) {
showErrorToast(t("sync.mode.noPasswordWarning"));
onSyncConfigOpen();
onClose();
return;
}
setIsSaving(true);
try {
await invoke("set_profile_sync_enabled", {
profileId: profile.id,
enabled: !syncEnabled,
});
setSyncEnabled(!syncEnabled);
showSuccessToast(
!syncEnabled ? "Sync enabled - syncing now..." : "Sync disabled",
);
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast("Failed to update sync settings");
} finally {
setIsSaving(false);
}
}, [profile, syncEnabled, hasConfig, onSyncConfigOpen, onClose]);
if (newMode === "Encrypted" && !hasE2ePassword) {
showErrorToast(t("sync.mode.passwordRequired"));
return;
}
setIsSaving(true);
try {
await invoke("set_profile_sync_mode", {
profileId: profile.id,
syncMode: newMode,
});
setSyncMode(newMode as SyncMode);
showSuccessToast(
newMode !== "Disabled"
? t("sync.mode.enabledToast")
: t("sync.mode.disabledToast"),
);
} catch (error) {
console.error("Failed to set sync mode:", error);
showErrorToast(String(error));
} finally {
setIsSaving(false);
}
},
[profile, hasConfig, hasE2ePassword, onSyncConfigOpen, onClose, t],
);
const handleSyncNow = useCallback(async () => {
if (!profile) return;
if (!hasConfig) {
showErrorToast("Please configure sync service first");
showErrorToast(t("sync.mode.noPasswordWarning"));
onSyncConfigOpen();
onClose();
return;
@@ -105,17 +132,17 @@ export function ProfileSyncDialog({
setIsSyncing(true);
try {
await invoke("request_profile_sync", { profileId: profile.id });
showSuccessToast("Sync queued");
showSuccessToast(t("sync.mode.syncQueued"));
} catch (error) {
console.error("Failed to queue sync:", error);
showErrorToast("Failed to queue sync");
showErrorToast(String(error));
} finally {
setIsSyncing(false);
}
}, [profile, hasConfig, onSyncConfigOpen, onClose]);
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
const formatLastSync = (timestamp?: number) => {
if (!timestamp) return "Never";
if (!timestamp) return t("common.labels.never", "Never");
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
@@ -126,9 +153,12 @@ export function ProfileSyncDialog({
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Profile Sync</DialogTitle>
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle>
<DialogDescription>
Manage sync settings for &quot;{profile.name}&quot;
{t("sync.mode.description", {
name: profile.name,
defaultValue: `Manage sync settings for "${profile.name}"`,
})}
</DialogDescription>
</DialogHeader>
@@ -140,7 +170,9 @@ export function ProfileSyncDialog({
<div className="grid gap-4 py-4">
{!hasConfig && (
<div className="p-3 text-sm rounded-md bg-muted">
<p className="mb-2">Sync service not configured.</p>
<p className="mb-2">
{t("sync.mode.notConfigured", "Sync service not configured.")}
</p>
<Button
variant="outline"
size="sm"
@@ -149,39 +181,87 @@ export function ProfileSyncDialog({
onClose();
}}
>
Configure Sync Service
{t("sync.mode.configureService", "Configure Sync Service")}
</Button>
</div>
)}
{hasConfig && (
<>
<div className="flex justify-between items-center">
<div className="space-y-0.5">
<Label htmlFor="sync-enabled">Sync Enabled</Label>
<p className="text-sm text-muted-foreground">
Sync this profile across devices
</p>
<RadioGroup
value={syncMode}
onValueChange={handleModeChange}
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start space-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.disabled", "Disabled")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.disabledDescription",
"No sync for this profile",
)}
</p>
</Label>
</div>
<Checkbox
id="sync-enabled"
checked={syncEnabled}
onCheckedChange={handleToggleSync}
disabled={isSaving}
/>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.regular", "Regular Sync")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.regularDescription",
"Fast sync, unencrypted",
)}
</p>
</Label>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="Encrypted" id="sync-encrypted" />
<Label htmlFor="sync-encrypted" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.encryptedDescription",
"Encrypted before upload. Server never sees plaintext data.",
)}
</p>
</Label>
</div>
</RadioGroup>
{syncMode === "Encrypted" && !hasE2ePassword && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t(
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
</div>
)}
<div className="space-y-2">
<Label>Last Synced</Label>
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
<div className="flex gap-2 items-center">
<Badge variant="outline">
{formatLastSync(profile.last_sync)}
</Badge>
{syncEnabled && (
{isSyncEnabled(profile) && (
<Badge
variant={profile.last_sync ? "default" : "secondary"}
>
{profile.last_sync ? "Synced" : "Pending"}
{profile.last_sync
? t("common.status.synced")
: t("common.status.pending")}
</Badge>
)}
</div>
@@ -193,11 +273,11 @@ export function ProfileSyncDialog({
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
{t("common.buttons.close")}
</Button>
{hasConfig && syncEnabled && (
{hasConfig && isSyncEnabled(profile) && (
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
Sync Now
{t("sync.mode.syncNow", "Sync Now")}
</LoadingButton>
)}
</DialogFooter>
+153
View File
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
ColorPicker,
ColorPickerAlpha,
@@ -24,6 +25,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
@@ -113,6 +115,11 @@ export function SettingsDialog({
const [requestingPermission, setRequestingPermission] =
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [e2ePassword, setE2ePassword] = useState("");
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
const [e2eError, setE2eError] = useState("");
const [isSavingE2e, setIsSavingE2e] = useState(false);
const { t } = useTranslation();
const { setTheme } = useTheme();
@@ -202,6 +209,13 @@ export function SettingsDialog({
colors: tokyoNightTheme.colors,
});
}
// Check E2E password status
try {
const hasPassword = await invoke<boolean>("check_has_e2e_password");
setHasE2ePassword(hasPassword);
} catch {
setHasE2ePassword(false);
}
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
@@ -827,6 +841,145 @@ export function SettingsDialog({
</RippleButton>
</div>
{/* Sync Encryption Section */}
<div className="space-y-4">
<Label className="text-base font-medium">
{t("settings.encryption.title", "Sync Encryption")}
</Label>
<p className="text-xs text-muted-foreground">
{t(
"settings.encryption.description",
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
)}
</p>
{hasE2ePassword ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="default">
{t("settings.encryption.passwordSet", "Active")}
</Badge>
<span className="text-sm text-muted-foreground">
{t(
"settings.encryption.passwordSetDescription",
"E2E encryption password is set",
)}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setHasE2ePassword(false);
setE2ePassword("");
setE2ePasswordConfirm("");
setE2eError("");
}}
>
{t("settings.encryption.changePassword", "Change Password")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={async () => {
try {
await invoke("delete_e2e_password");
setHasE2ePassword(false);
showSuccessToast(
t(
"settings.encryption.removed",
"Encryption password removed",
),
);
} catch (error) {
showErrorToast(String(error));
}
}}
>
{t("settings.encryption.removePassword", "Remove Password")}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<Input
type="password"
placeholder={t(
"settings.encryption.passwordPlaceholder",
"Password (min 8 characters)",
)}
value={e2ePassword}
onChange={(e) => {
setE2ePassword(e.target.value);
setE2eError("");
}}
/>
<Input
type="password"
placeholder={t(
"settings.encryption.confirmPlaceholder",
"Confirm password",
)}
value={e2ePasswordConfirm}
onChange={(e) => {
setE2ePasswordConfirm(e.target.value);
setE2eError("");
}}
/>
{e2eError && (
<p className="text-sm text-destructive">{e2eError}</p>
)}
<LoadingButton
variant="default"
size="sm"
isLoading={isSavingE2e}
onClick={async () => {
if (e2ePassword.length < 8) {
setE2eError(
t(
"settings.encryption.passwordTooShort",
"Password must be at least 8 characters",
),
);
return;
}
if (e2ePassword !== e2ePasswordConfirm) {
setE2eError(
t(
"settings.encryption.passwordMismatch",
"Passwords do not match",
),
);
return;
}
setIsSavingE2e(true);
try {
await invoke("set_e2e_password", {
password: e2ePassword,
});
setHasE2ePassword(true);
setE2ePassword("");
setE2ePasswordConfirm("");
showSuccessToast(
t(
"settings.encryption.passwordSaved",
"Encryption password set",
),
);
} catch (error) {
showErrorToast(String(error));
} finally {
setIsSavingE2e(false);
}
}}
>
{t("settings.encryption.setPassword", "Set Password")}
</LoadingButton>
</div>
)}
</div>
{/* Commercial License Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Commercial License</Label>
File diff suppressed because it is too large Load Diff
+67 -13
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff, LuLock } from "react-icons/lu";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProBadge } from "@/components/ui/pro-badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
@@ -59,7 +60,21 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const [activeTab, setActiveTab] = useState<string>("cloud");
const isConnected = Boolean(serverUrl && token);
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "testing" | "connected" | "error"
>("unknown");
const hasConfig = Boolean(serverUrl && token);
const testConnection = useCallback(async (url: string) => {
setConnectionStatus("testing");
try {
const healthUrl = `${url.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
setConnectionStatus(response.ok ? "connected" : "error");
} catch {
setConnectionStatus("error");
}
}, []);
const loadSettings = useCallback(async () => {
setIsLoading(true);
@@ -67,15 +82,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const settings = await invoke<SyncSettings>("get_sync_settings");
setServerUrl(settings.sync_server_url || "");
setToken(settings.sync_token || "");
if (settings.sync_server_url && settings.sync_token) {
void testConnection(settings.sync_server_url);
}
} catch (error) {
console.error("Failed to load sync settings:", error);
} finally {
setIsLoading(false);
}
}, []);
}, [testConnection]);
useEffect(() => {
if (isOpen) {
setConnectionStatus("unknown");
void loadSettings();
setCodeSent(false);
setOtpCode("");
@@ -102,15 +121,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
}
setIsTesting(true);
setConnectionStatus("testing");
try {
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
if (response.ok) {
setConnectionStatus("connected");
showSuccessToast("Connection successful!");
} else {
setConnectionStatus("error");
showErrorToast("Server responded with an error");
}
} catch {
setConnectionStatus("error");
showErrorToast("Failed to connect to server");
} finally {
setIsTesting(false);
@@ -124,6 +147,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
syncServerUrl: serverUrl || null,
syncToken: token || null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
showSuccessToast("Sync settings saved");
onClose();
} catch (error) {
@@ -141,8 +169,14 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
syncServerUrl: null,
syncToken: null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
setServerUrl("");
setToken("");
setConnectionStatus("unknown");
showSuccessToast("Sync disconnected");
} catch (error) {
console.error("Failed to disconnect:", error);
@@ -208,7 +242,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
}, [logout, t]);
// Determine which tabs are available
const cloudBlocked = !isLoggedIn && isConnected;
const cloudBlocked = !isLoggedIn && hasConfig;
const selfHostedBlocked = isLoggedIn;
return (
@@ -258,11 +292,23 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
<div className="flex justify-between">
<span className="text-muted-foreground">Proxy Bandwidth</span>
<span>
{user.proxyBandwidthUsedMb} / {user.proxyBandwidthLimitMb}{" "}
{user.proxyBandwidthUsedMb} /{" "}
{user.proxyBandwidthLimitMb +
(user.proxyBandwidthExtraMb || 0)}{" "}
MB
</span>
</div>
)}
{(user.proxyBandwidthExtraMb || 0) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Extra Bandwidth</span>
<span>
{user.proxyBandwidthExtraMb >= 1000
? `${(user.proxyBandwidthExtraMb / 1000).toFixed(1)} GB`
: `${user.proxyBandwidthExtraMb} MB`}
</span>
</div>
)}
</div>
<div className="flex gap-2 pt-2">
@@ -294,9 +340,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
>
<span className="flex items-center gap-2">
{t("sync.cloud.tabLabel")}
{cloudBlocked && (
<LuLock className="w-3 h-3 text-muted-foreground" />
)}
{cloudBlocked && <ProBadge />}
</span>
</TabsTrigger>
<TabsTrigger
@@ -306,9 +350,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
>
<span className="flex items-center gap-2">
{t("sync.cloud.selfHostedTabLabel")}
{selfHostedBlocked && (
<LuLock className="w-3 h-3 text-muted-foreground" />
)}
{selfHostedBlocked && <ProBadge />}
</span>
</TabsTrigger>
</TabsList>
@@ -430,17 +472,29 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
</div>
</div>
{isConnected && (
{connectionStatus === "testing" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
{t("sync.status.syncing")}
</div>
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-green-500" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-red-500" />
{t("sync.status.disconnected")}
</div>
)}
</div>
)}
<DialogFooter className="flex gap-2">
{isConnected && (
{hasConfig && (
<Button
variant="outline"
onClick={handleDisconnect}
+14
View File
@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
export function ProBadge({ className }: { className?: string }) {
return (
<span
className={cn(
"text-[10px] font-semibold px-1 py-0.5 rounded bg-primary text-primary-foreground",
className,
)}
>
PRO
</span>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
interface WindowResizeWarningDialogProps {
isOpen: boolean;
onResult: (proceed: boolean) => void;
browserType?: string;
}
export function WindowResizeWarningDialog({
isOpen,
onResult,
browserType,
}: WindowResizeWarningDialogProps) {
const { t } = useTranslation();
const [dontShowAgain, setDontShowAgain] = useState(false);
useEffect(() => {
if (isOpen) {
setDontShowAgain(false);
}
}, [isOpen]);
const handleContinue = async () => {
if (dontShowAgain) {
try {
await invoke("dismiss_window_resize_warning");
} catch (error) {
console.error("Failed to dismiss window resize warning:", error);
}
}
onResult(true);
};
const handleCancel = () => {
onResult(false);
};
const isCamoufox = browserType === "camoufox";
const title = isCamoufox
? t("warnings.windowResizeCamoufoxTitle")
: t("warnings.windowResizeTitle");
const description = isCamoufox
? t("warnings.windowResizeCamoufoxDescription")
: t("warnings.windowResizeDescription");
return (
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-sm"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="flex items-center space-x-2">
<Checkbox
id="dont-show-again"
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
/>
<Label htmlFor="dont-show-again" className="text-sm">
{t("warnings.dontShowAgain")}
</Label>
</div>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button variant="ghost" onClick={handleCancel}>
{t("warnings.cancel")}
</Button>
<Button onClick={handleContinue}>{t("warnings.continue")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+11 -7
View File
@@ -49,7 +49,9 @@ export function useBrowserDownload() {
const [availableVersions, setAvailableVersions] = useState<GithubRelease[]>(
[],
);
const [downloadedVersions, setDownloadedVersions] = useState<string[]>([]);
const [downloadedVersionsMap, setDownloadedVersionsMap] = useState<
Record<string, string[]>
>({});
const [downloadingBrowsers, setDownloadingBrowsers] = useState<Set<string>>(
new Set(),
);
@@ -166,12 +168,12 @@ export function useBrowserDownload() {
const loadDownloadedVersions = useCallback(async (browserStr: string) => {
try {
const downloadedVersions = await invoke<string[]>(
const versions = await invoke<string[]>(
"get_downloaded_browser_versions",
{ browserStr },
);
setDownloadedVersions(downloadedVersions);
return downloadedVersions;
setDownloadedVersionsMap((prev) => ({ ...prev, [browserStr]: versions }));
return versions;
} catch (error) {
console.error("Failed to load downloaded versions:", error);
throw error;
@@ -243,9 +245,11 @@ export function useBrowserDownload() {
const isVersionDownloaded = useCallback(
(version: string) => {
return downloadedVersions.includes(version);
return Object.values(downloadedVersionsMap).some((versions) =>
versions.includes(version),
);
},
[downloadedVersions],
[downloadedVersionsMap],
);
// Check if a browser type is currently downloading
@@ -434,7 +438,7 @@ export function useBrowserDownload() {
return {
availableVersions,
downloadedVersions,
downloadedVersionsMap,
isDownloading,
isBrowserDownloading,
downloadingBrowsers,
+7 -35
View File
@@ -12,7 +12,7 @@ import type { BrowserProfile } from "@/types";
export function useBrowserState(
profiles: BrowserProfile[],
runningProfiles: Set<string>,
isUpdating: (browser: string) => boolean,
_isUpdating: (browser: string) => boolean,
launchingProfiles: Set<string>,
stoppingProfiles: Set<string>,
) {
@@ -57,7 +57,6 @@ export function useBrowserState(
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If the profile is launching or stopping, disable the button
if (isLaunching || isStopping) {
@@ -67,11 +66,6 @@ export function useBrowserState(
// If the profile is already running, it can always be stopped
if (isRunning) return true;
// If THIS specific browser is updating or downloading, block this profile
if (isBrowserUpdating) {
return false;
}
// For single-instance browsers, check if any instance is running
if (isSingleInstanceBrowser(profile.browser)) {
return !isAnyInstanceRunning(profile.browser);
@@ -82,7 +76,6 @@ export function useBrowserState(
[
runningProfiles,
isClient,
isUpdating,
isSingleInstanceBrowser,
isAnyInstanceRunning,
launchingProfiles,
@@ -98,18 +91,17 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If this specific browser is updating, downloading, launching, or stopping, block it
if (isBrowserUpdating || isLaunching || isStopping) {
// If this specific browser is launching or stopping, block it
if (isLaunching || isStopping) {
return false;
}
// For single-instance browsers
if (isSingleInstanceBrowser(profile.browser)) {
const isRunning = runningProfiles.has(profile.id);
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
);
@@ -131,7 +123,6 @@ export function useBrowserState(
runningProfiles,
isClient,
isSingleInstanceBrowser,
isUpdating,
launchingProfiles,
stoppingProfiles,
],
@@ -147,22 +138,15 @@ export function useBrowserState(
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If profile is running, launching, stopping, or browser is updating, block selection
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
// If profile is running, launching, or stopping, block selection
if (isRunning || isLaunching || isStopping) {
return false;
}
return true;
},
[
isClient,
runningProfiles,
launchingProfiles,
stoppingProfiles,
isUpdating,
],
[isClient, runningProfiles, launchingProfiles, stoppingProfiles],
);
/**
@@ -180,7 +164,6 @@ export function useBrowserState(
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
if (isLaunching) {
return "Launching browser...";
@@ -194,10 +177,6 @@ export function useBrowserState(
return "";
}
if (isBrowserUpdating) {
return `${getBrowserDisplayName(profile.browser)} is being updated. Please wait for the update to complete.`;
}
if (
isSingleInstanceBrowser(profile.browser) &&
!canLaunchProfile(profile)
@@ -210,7 +189,6 @@ export function useBrowserState(
[
runningProfiles,
isClient,
isUpdating,
isSingleInstanceBrowser,
canLaunchProfile,
launchingProfiles,
@@ -231,7 +209,6 @@ export function useBrowserState(
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
if (isLaunching) {
return "Profile is currently launching. Please wait.";
@@ -241,10 +218,6 @@ export function useBrowserState(
return "Profile is currently stopping. Please wait.";
}
if (isBrowserUpdating) {
return `${getBrowserDisplayName(profile.browser)} is being updated. Please wait for the update to complete.`;
}
if (isSingleInstanceBrowser(profile.browser)) {
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
@@ -266,7 +239,6 @@ export function useBrowserState(
isClient,
canUseProfileForLinks,
isSingleInstanceBrowser,
isUpdating,
launchingProfiles,
stoppingProfiles,
],
+73
View File
@@ -0,0 +1,73 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { Extension, ExtensionGroup } from "@/types";
export function useExtensionEvents() {
const [extensions, setExtensions] = useState<Extension[]>([]);
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadExtensions = useCallback(async () => {
try {
const exts = await invoke<Extension[]>("list_extensions");
setExtensions(exts);
setError(null);
} catch (err: unknown) {
console.error("Failed to load extensions:", err);
setExtensions([]);
}
}, []);
const loadExtensionGroups = useCallback(async () => {
try {
const groups = await invoke<ExtensionGroup[]>("list_extension_groups");
setExtensionGroups(groups);
setError(null);
} catch (err: unknown) {
console.error("Failed to load extension groups:", err);
setExtensionGroups([]);
}
}, []);
const loadAll = useCallback(async () => {
await Promise.all([loadExtensions(), loadExtensionGroups()]);
}, [loadExtensions, loadExtensionGroups]);
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
try {
await loadAll();
unlisten = await listen("extensions-changed", () => {
void loadAll();
});
} catch (err) {
console.error("Failed to setup extension event listeners:", err);
setError(
`Failed to setup extension event listeners: ${JSON.stringify(err)}`,
);
} finally {
setIsLoading(false);
}
};
void setup();
return () => {
if (unlisten) unlisten();
};
}, [loadAll]);
return {
extensions,
extensionGroups,
isLoading,
error,
loadExtensions,
loadExtensionGroups,
loadAll,
};
}
+268 -4
View File
@@ -106,6 +106,22 @@
"description": "Configure Local API and MCP (Model Context Protocol) for integrating with external tools and AI assistants.",
"openSettings": "Open Integrations Settings"
},
"encryption": {
"title": "Sync Encryption",
"description": "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
"passwordSet": "Active",
"passwordSetDescription": "E2E encryption password is set",
"noPassword": "No password set",
"passwordPlaceholder": "Password (min 8 characters)",
"confirmPlaceholder": "Confirm password",
"setPassword": "Set Password",
"changePassword": "Change Password",
"removePassword": "Remove Password",
"removed": "Encryption password removed",
"passwordSaved": "Encryption password set",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters"
},
"commercial": {
"title": "Commercial License",
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
@@ -130,7 +146,8 @@
"groups": "Groups",
"syncService": "Account",
"integrations": "Integrations",
"importProfile": "Import Profile"
"importProfile": "Import Profile",
"extensions": "Extensions"
}
},
"profiles": {
@@ -157,8 +174,17 @@
"delete": "Delete",
"copyCookies": "Copy Cookies",
"configure": "Configure",
"clone": "Clone Profile"
}
"clone": "Clone Profile",
"viewNetwork": "View Network",
"syncSettings": "Sync Settings",
"assignToGroup": "Assign to Group",
"changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile"
},
"ephemeral": "Ephemeral",
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
"ephemeralBadge": "Ephemeral",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Create New Profile",
@@ -262,6 +288,25 @@
}
},
"sync": {
"mode": {
"title": "Profile Sync",
"description": "Manage sync settings for \"{{name}}\"",
"disabled": "Disabled",
"regular": "Regular Sync",
"encrypted": "E2E Encrypted Sync",
"disabledDescription": "No sync for this profile",
"regularDescription": "Fast sync, unencrypted",
"encryptedDescription": "Encrypted before upload. Server never sees plaintext data.",
"noPasswordWarning": "E2E password not set. Please set a password in Settings.",
"passwordRequired": "E2E password not set. Please set a password in Settings first.",
"enabledToast": "Sync enabled",
"disabledToast": "Sync disabled",
"syncQueued": "Sync queued",
"syncNow": "Sync Now",
"lastSynced": "Last Synced",
"notConfigured": "Sync service not configured.",
"configureService": "Configure Sync Service"
},
"title": "Account",
"config": "Sync Configuration",
"serverUrl": "Server URL",
@@ -483,7 +528,113 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Spoofing fingerprint for a different operating system is less reliable because it is impossible to perfectly mimic all underlying components. Use with caution."
"crossOsWarning": "Spoofing fingerprint for a different operating system is less reliable because it is impossible to perfectly mimic all underlying components. Use with caution.",
"crossOsLimitations": "Cross-OS fingerprinting has limitations. System-level APIs may still reflect your actual operating system, and some features may have degraded performance.",
"osLabel": "Operating System Fingerprint",
"selectOSPlaceholder": "Select operating system",
"generateRandomOnLaunch": "Generate random fingerprint on every launch",
"generateRandomDescription": "When enabled, a new fingerprint will be generated each time the browser is launched.",
"generateRandomDescriptionAuto": "When enabled, a new fingerprint will be generated each time the browser is launched. The generated fingerprint is saved for reference.",
"autoLocationDescription": "Automatically configure location information based on proxy configuration or your connection if no proxy provided",
"editingDisabledRunning": "Fingerprint editing is disabled because the profile is currently running. Stop the profile to make changes.",
"editingDisabledRandomized": "Fingerprint editing is disabled because random fingerprint generation is enabled. Disable the option above to manually edit the fingerprint configuration.",
"advancedWarning": "Warning: Only edit these parameters if you know what you're doing. Incorrect values may break websites, make them detect you, and lead to hard-to-debug bugs.",
"basicWarning": "Warning: Only edit these parameters if you know what you're doing.",
"automatic": "Automatic",
"manual": "Manual",
"blockingOptions": "Blocking Options",
"blockImages": "Block Images",
"blockWebRTC": "Block WebRTC",
"blockWebGL": "Block WebGL",
"navigatorProperties": "Navigator Properties",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
"appVersion": "App Version",
"osCpu": "OS CPU",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "Max Touch Points",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Select DNT value",
"dntAllowed": "0 (tracking allowed)",
"dntNotAllowed": "1 (tracking not allowed)",
"dntUnspecified": "unspecified",
"language": "Language",
"primaryLanguage": "Primary Language (navigator.language)",
"languages": "Languages (JSON array)",
"languageAndLocale": "Language & Locale",
"screenProperties": "Screen Properties",
"screenWidth": "Screen Width",
"screenHeight": "Screen Height",
"availableWidth": "Available Width",
"availableHeight": "Available Height",
"colorDepth": "Color Depth",
"pixelDepth": "Pixel Depth",
"devicePixelRatio": "Device Pixel Ratio",
"windowProperties": "Window Properties",
"outerWidth": "Outer Width",
"outerHeight": "Outer Height",
"innerWidth": "Inner Width",
"innerHeight": "Inner Height",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Geolocation",
"timezoneAndGeolocation": "Timezone & Geolocation",
"timezoneGeolocationDescription": "These values override the browser's timezone and geolocation APIs.",
"latitude": "Latitude",
"longitude": "Longitude",
"timezone": "Timezone",
"timezoneIana": "Timezone (IANA)",
"timezoneOffset": "Offset (minutes from UTC)",
"accuracy": "Accuracy (meters)",
"locale": "Locale",
"region": "Region",
"script": "Script",
"webglProperties": "WebGL Properties",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "WebGL Parameters",
"webglParametersJson": "WebGL Parameters (JSON)",
"webgl2Parameters": "WebGL2 Parameters",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "This seed is used to generate a consistent but unique canvas fingerprint. Each profile should have a different seed.",
"fonts": "Fonts",
"fontsJson": "Fonts (JSON array)",
"battery": "Battery",
"charging": "Charging",
"chargingTime": "Charging Time",
"dischargingTime": "Discharging Time",
"batteryLevel": "Level (0-1)",
"screenResolution": "Screen Resolution",
"maxWidth": "Max Width",
"maxHeight": "Max Height",
"minWidth": "Min Width",
"minHeight": "Min Height",
"hardwareProperties": "Hardware Properties",
"deviceMemory": "Device Memory (GB)",
"audioProperties": "Audio Properties",
"sampleRate": "Sample Rate",
"maxChannelCount": "Max Channel Count",
"vendorInfo": "Vendor Info",
"vendor": "Vendor",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Brand",
"brandVersion": "Brand Version",
"proFeature": "This is a Pro feature"
},
"warnings": {
"windowResizeTitle": "Custom Window Dimensions",
"windowResizeDescription": "Changing browser window dimensions may increase the chance of website detection that browser information is spoofed.",
"windowResizeCamoufoxTitle": "Viewport Locked by Camoufox",
"windowResizeCamoufoxDescription": "Camoufox locks the viewport to the spoofed screen dimensions for anti-fingerprinting. Resizing the window may cause cropped or grey areas. This is expected behavior.",
"dontShowAgain": "Don't show this again",
"continue": "Continue",
"cancel": "Cancel"
},
"syncAll": {
"title": "Enable Sync for Existing Items",
@@ -503,5 +654,118 @@
"viewOnly": "This profile was created on {{os}} and is not supported on this system",
"cannotLaunch": "This profile was created on {{os}} and is not supported on this system",
"cannotModify": "Cannot modify sync settings for a cross-OS profile"
},
"cookies": {
"management": {
"title": "Cookie Management",
"menuItem": "Cookie Management"
},
"import": {
"title": "Import Cookies",
"description": "Import cookies from a Netscape or JSON format file.",
"selectFile": "Choose File",
"preview": "{{count}} cookies found",
"success": "Successfully imported {{imported}} cookies ({{replaced}} replaced)",
"error": "Failed to import cookies",
"proFeature": "Cookie import is a Pro feature"
},
"export": {
"title": "Export Cookies",
"description": "Export cookies from this profile.",
"formatLabel": "Format",
"netscape": "Netscape TXT",
"json": "JSON",
"success": "Cookies exported successfully",
"error": "Failed to export cookies"
}
},
"profileInfo": {
"title": "Profile Details",
"tabs": {
"info": "Info",
"settings": "Settings"
},
"fields": {
"profileId": "Profile ID",
"browser": "Browser",
"releaseType": "Release Type",
"proxyVpn": "Proxy / VPN",
"group": "Group",
"tags": "Tags",
"note": "Note",
"syncStatus": "Sync Status",
"lastLaunched": "Last Launched",
"hostOs": "Host OS",
"ephemeral": "Ephemeral",
"extensionGroup": "Extension Group"
},
"values": {
"none": "None",
"never": "Never",
"copied": "Copied!",
"yes": "Yes"
},
"network": {
"bypassRules": "Proxy Bypass Rules",
"bypassRulesDescription": "Requests matching these rules will connect directly, bypassing the proxy.",
"addRule": "Add Rule",
"rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local",
"noRules": "No bypass rules configured.",
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
},
"actions": {
"manageCookies": "Manage Cookies"
},
"clone": {
"title": "Clone Profile",
"description": "Enter a name for the cloned profile",
"namePlaceholder": "Profile name",
"button": "Clone"
}
},
"extensions": {
"title": "Extensions",
"description": "Manage browser extensions and extension groups for your profiles.",
"upload": "Upload",
"delete": "Delete",
"extensionsTab": "Extensions",
"groupsTab": "Groups",
"managedNotice": "Extensions managed here will replace any manually installed extensions in profiles when launched.",
"proRequired": "Extension management is a Pro feature",
"empty": "No extensions uploaded yet.",
"noGroups": "No extension groups created yet.",
"createGroup": "Create Group",
"addToGroup": "Add extension...",
"removeFromGroup": "Remove from group",
"deleteGroup": "Delete group",
"extensionGroup": "Extension Group",
"compatibility": {
"label": "Compatibility",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium & Firefox"
},
"selectedFile": "Selected file",
"namePlaceholder": "Extension name",
"groupNamePlaceholder": "Group name",
"uploadSuccess": "Extension uploaded successfully",
"deleteSuccess": "Extension deleted successfully",
"groupCreateSuccess": "Extension group created successfully",
"groupUpdateSuccess": "Extension group updated successfully",
"groupDeleteSuccess": "Extension group deleted successfully",
"deleteConfirmTitle": "Delete Extension",
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleteGroupConfirmTitle": "Delete Extension Group",
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
"invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.",
"readError": "Failed to read the extension file."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "Fingerprint editing is a Pro feature",
"cookieCopyLocked": "Cookie copying is a Pro feature",
"cookieImportLocked": "Cookie import is a Pro feature",
"cookieExportLocked": "Cookie export is a Pro feature",
"cookieManagementLocked": "Cookie management is a Pro feature"
}
}
+268 -4
View File
@@ -106,6 +106,22 @@
"description": "Configura la API Local y MCP (Protocolo de Contexto de Modelo) para integración con herramientas externas y asistentes de IA.",
"openSettings": "Abrir Configuración de Integraciones"
},
"encryption": {
"title": "Cifrado de sincronización",
"description": "Establece una contraseña para habilitar la sincronización cifrada E2E. Si pierdes esta contraseña, los perfiles cifrados no podrán recuperarse.",
"passwordSet": "Activo",
"passwordSetDescription": "La contraseña de cifrado E2E está configurada",
"noPassword": "Sin contraseña configurada",
"passwordPlaceholder": "Contraseña (mín. 8 caracteres)",
"confirmPlaceholder": "Confirmar contraseña",
"setPassword": "Establecer contraseña",
"changePassword": "Cambiar contraseña",
"removePassword": "Eliminar contraseña",
"removed": "Contraseña de cifrado eliminada",
"passwordSaved": "Contraseña de cifrado establecida",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres"
},
"commercial": {
"title": "Licencia Comercial",
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
@@ -130,7 +146,8 @@
"groups": "Grupos",
"syncService": "Cuenta",
"integrations": "Integraciones",
"importProfile": "Importar Perfil"
"importProfile": "Importar Perfil",
"extensions": "Extensiones"
}
},
"profiles": {
@@ -157,8 +174,17 @@
"delete": "Eliminar",
"copyCookies": "Copiar Cookies",
"configure": "Configurar",
"clone": "Clonar perfil"
}
"clone": "Clonar perfil",
"viewNetwork": "Ver Red",
"syncSettings": "Configuración de Sincronización",
"assignToGroup": "Asignar a Grupo",
"changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil"
},
"ephemeral": "Efímero",
"ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.",
"ephemeralBadge": "Efímero",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Crear Nuevo Perfil",
@@ -262,6 +288,25 @@
}
},
"sync": {
"mode": {
"title": "Sincronización de perfil",
"description": "Gestionar configuración de sincronización para \"{{name}}\"",
"disabled": "Deshabilitado",
"regular": "Sincronización regular",
"encrypted": "Sincronización cifrada E2E",
"disabledDescription": "Sin sincronización para este perfil",
"regularDescription": "Sincronización rápida, sin cifrar",
"encryptedDescription": "Cifrado antes de subir. El servidor nunca ve los datos en texto plano.",
"noPasswordWarning": "Contraseña E2E no configurada. Por favor establece una contraseña en Ajustes.",
"passwordRequired": "Contraseña E2E no configurada. Por favor establece una contraseña en Ajustes primero.",
"enabledToast": "Sincronización habilitada",
"disabledToast": "Sincronización deshabilitada",
"syncQueued": "Sincronización en cola",
"syncNow": "Sincronizar ahora",
"lastSynced": "Última sincronización",
"notConfigured": "Servicio de sincronización no configurado.",
"configureService": "Configurar servicio de sincronización"
},
"title": "Servicio de Sincronización",
"config": "Configuración de Sincronización",
"serverUrl": "URL del Servidor",
@@ -483,7 +528,113 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "La suplantación de huella digital para un sistema operativo diferente es menos fiable porque es imposible imitar perfectamente todos los componentes subyacentes. Usar con precaución."
"crossOsWarning": "La suplantación de huella digital para un sistema operativo diferente es menos fiable porque es imposible imitar perfectamente todos los componentes subyacentes. Usar con precaución.",
"crossOsLimitations": "La suplantación de huella digital entre sistemas operativos tiene limitaciones. Las APIs a nivel de sistema pueden seguir reflejando su sistema operativo real y algunas funciones pueden tener un rendimiento reducido.",
"osLabel": "Huella digital del sistema operativo",
"selectOSPlaceholder": "Seleccionar sistema operativo",
"generateRandomOnLaunch": "Generar huella digital aleatoria en cada inicio",
"generateRandomDescription": "Cuando está activado, se generará una nueva huella digital cada vez que se inicie el navegador.",
"generateRandomDescriptionAuto": "Cuando está activado, se generará una nueva huella digital cada vez que se inicie el navegador. La huella digital generada se guarda como referencia.",
"autoLocationDescription": "Configurar automáticamente la información de ubicación basándose en la configuración del proxy o en su conexión si no se proporciona un proxy",
"editingDisabledRunning": "La edición de huellas digitales está desactivada porque el perfil se está ejecutando actualmente. Detenga el perfil para realizar cambios.",
"editingDisabledRandomized": "La edición de huellas digitales está desactivada porque la generación aleatoria de huellas digitales está activada. Desactive la opción anterior para editar manualmente la configuración de la huella digital.",
"advancedWarning": "Advertencia: Solo edite estos parámetros si sabe lo que está haciendo. Los valores incorrectos pueden romper sitios web, hacer que lo detecten y provocar errores difíciles de depurar.",
"basicWarning": "Advertencia: Solo edite estos parámetros si sabe lo que está haciendo.",
"automatic": "Automático",
"manual": "Manual",
"blockingOptions": "Opciones de bloqueo",
"blockImages": "Bloquear imágenes",
"blockWebRTC": "Bloquear WebRTC",
"blockWebGL": "Bloquear WebGL",
"navigatorProperties": "Propiedades del navegador",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent y plataforma",
"platform": "Plataforma",
"platformVersion": "Versión de plataforma",
"appVersion": "Versión de la aplicación",
"osCpu": "OS CPU",
"hardwareConcurrency": "Concurrencia de hardware",
"maxTouchPoints": "Puntos táctiles máximos",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Seleccionar valor DNT",
"dntAllowed": "0 (rastreo permitido)",
"dntNotAllowed": "1 (rastreo no permitido)",
"dntUnspecified": "no especificado",
"language": "Idioma",
"primaryLanguage": "Idioma principal (navigator.language)",
"languages": "Idiomas (JSON array)",
"languageAndLocale": "Idioma y configuración regional",
"screenProperties": "Propiedades de pantalla",
"screenWidth": "Ancho de pantalla",
"screenHeight": "Alto de pantalla",
"availableWidth": "Ancho disponible",
"availableHeight": "Alto disponible",
"colorDepth": "Profundidad de color",
"pixelDepth": "Profundidad de píxel",
"devicePixelRatio": "Relación de píxeles del dispositivo",
"windowProperties": "Propiedades de ventana",
"outerWidth": "Ancho exterior",
"outerHeight": "Alto exterior",
"innerWidth": "Ancho interior",
"innerHeight": "Alto interior",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Geolocalización",
"timezoneAndGeolocation": "Zona horaria y geolocalización",
"timezoneGeolocationDescription": "Estos valores anulan las APIs de zona horaria y geolocalización del navegador.",
"latitude": "Latitud",
"longitude": "Longitud",
"timezone": "Zona horaria",
"timezoneIana": "Zona horaria (IANA)",
"timezoneOffset": "Desfase (minutos desde UTC)",
"accuracy": "Precisión (metros)",
"locale": "Configuración regional",
"region": "Región",
"script": "Script",
"webglProperties": "Propiedades de WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Parámetros de WebGL",
"webglParametersJson": "Parámetros de WebGL (JSON)",
"webgl2Parameters": "Parámetros de WebGL2",
"webglShaderPrecisionFormats": "Formatos de precisión de WebGL Shader",
"webgl2ShaderPrecisionFormats": "Formatos de precisión de WebGL2 Shader",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Esta semilla se usa para generar una huella digital de Canvas consistente pero única. Cada perfil debe tener una semilla diferente.",
"fonts": "Fuentes",
"fontsJson": "Fuentes (JSON array)",
"battery": "Batería",
"charging": "Cargando",
"chargingTime": "Tiempo de carga",
"dischargingTime": "Tiempo de descarga",
"batteryLevel": "Nivel (0-1)",
"screenResolution": "Resolución de pantalla",
"maxWidth": "Ancho máximo",
"maxHeight": "Alto máximo",
"minWidth": "Ancho mínimo",
"minHeight": "Alto mínimo",
"hardwareProperties": "Propiedades de hardware",
"deviceMemory": "Memoria del dispositivo (GB)",
"audioProperties": "Propiedades de audio",
"sampleRate": "Frecuencia de muestreo",
"maxChannelCount": "Número máximo de canales",
"vendorInfo": "Información del proveedor",
"vendor": "Proveedor",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Marca",
"brandVersion": "Versión de marca",
"proFeature": "Esta es una función Pro"
},
"warnings": {
"windowResizeTitle": "Dimensiones de ventana personalizadas",
"windowResizeDescription": "Cambiar las dimensiones de la ventana del navegador puede aumentar la posibilidad de que los sitios web detecten que la información del navegador está falsificada.",
"windowResizeCamoufoxTitle": "Viewport bloqueado por Camoufox",
"windowResizeCamoufoxDescription": "Camoufox bloquea el viewport a las dimensiones de pantalla falsificadas para anti-fingerprinting. Redimensionar la ventana puede causar áreas recortadas o grises. Este es el comportamiento esperado.",
"dontShowAgain": "No mostrar esto de nuevo",
"continue": "Continuar",
"cancel": "Cancelar"
},
"syncAll": {
"title": "Activar sincronización para elementos existentes",
@@ -503,5 +654,118 @@
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
"cannotLaunch": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
"cannotModify": "No se pueden modificar los ajustes de sincronización de un perfil de otro sistema operativo"
},
"cookies": {
"management": {
"title": "Gestión de Cookies",
"menuItem": "Gestión de Cookies"
},
"import": {
"title": "Importar Cookies",
"description": "Importar cookies desde un archivo en formato Netscape o JSON.",
"selectFile": "Elegir Archivo",
"preview": "{{count}} cookies encontradas",
"success": "Se importaron {{imported}} cookies exitosamente ({{replaced}} reemplazadas)",
"error": "Error al importar cookies",
"proFeature": "La importación de cookies es una función Pro"
},
"export": {
"title": "Exportar Cookies",
"description": "Exportar cookies de este perfil.",
"formatLabel": "Formato",
"netscape": "Netscape TXT",
"json": "JSON",
"success": "Cookies exportadas exitosamente",
"error": "Error al exportar cookies"
}
},
"profileInfo": {
"title": "Detalles del Perfil",
"tabs": {
"info": "Info",
"settings": "Configuración"
},
"fields": {
"profileId": "ID del Perfil",
"browser": "Navegador",
"releaseType": "Tipo de Versión",
"proxyVpn": "Proxy / VPN",
"group": "Grupo",
"tags": "Etiquetas",
"note": "Nota",
"syncStatus": "Estado de Sincronización",
"lastLaunched": "Último Lanzamiento",
"hostOs": "SO Host",
"ephemeral": "Efímero",
"extensionGroup": "Grupo de Extensiones"
},
"values": {
"none": "Ninguno",
"never": "Nunca",
"copied": "¡Copiado!",
"yes": "Sí"
},
"network": {
"bypassRules": "Reglas de Omisión de Proxy",
"bypassRulesDescription": "Las solicitudes que coincidan con estas reglas se conectarán directamente, omitiendo el proxy.",
"addRule": "Agregar Regla",
"rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local",
"noRules": "No hay reglas de omisión configuradas.",
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
},
"actions": {
"manageCookies": "Administrar Cookies"
},
"clone": {
"title": "Clonar Perfil",
"description": "Ingrese un nombre para el perfil clonado",
"namePlaceholder": "Nombre del perfil",
"button": "Clonar"
}
},
"extensions": {
"title": "Extensiones",
"description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.",
"upload": "Subir",
"delete": "Eliminar",
"extensionsTab": "Extensiones",
"groupsTab": "Grupos",
"managedNotice": "Las extensiones administradas aquí reemplazarán cualquier extensión instalada manualmente en los perfiles al iniciarlos.",
"proRequired": "La gestión de extensiones es una función Pro",
"empty": "No se han subido extensiones aún.",
"noGroups": "No se han creado grupos de extensiones aún.",
"createGroup": "Crear Grupo",
"addToGroup": "Agregar extensión...",
"removeFromGroup": "Eliminar del grupo",
"deleteGroup": "Eliminar grupo",
"extensionGroup": "Grupo de Extensiones",
"compatibility": {
"label": "Compatibilidad",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium y Firefox"
},
"selectedFile": "Archivo seleccionado",
"namePlaceholder": "Nombre de la extensión",
"groupNamePlaceholder": "Nombre del grupo",
"uploadSuccess": "Extensión subida exitosamente",
"deleteSuccess": "Extensión eliminada exitosamente",
"groupCreateSuccess": "Grupo de extensiones creado exitosamente",
"groupUpdateSuccess": "Grupo de extensiones actualizado exitosamente",
"groupDeleteSuccess": "Grupo de extensiones eliminado exitosamente",
"deleteConfirmTitle": "Eliminar Extensión",
"deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
"invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.",
"readError": "No se pudo leer el archivo de extensión."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "La edición de huellas digitales es una función Pro",
"cookieCopyLocked": "La copia de cookies es una función Pro",
"cookieImportLocked": "La importación de cookies es una función Pro",
"cookieExportLocked": "La exportación de cookies es una función Pro",
"cookieManagementLocked": "La gestión de cookies es una función Pro"
}
}
+268 -4
View File
@@ -106,6 +106,22 @@
"description": "Configurez l'API locale et MCP (Model Context Protocol) pour l'intégration avec des outils externes et des assistants IA.",
"openSettings": "Ouvrir les paramètres d'intégration"
},
"encryption": {
"title": "Chiffrement de synchronisation",
"description": "Définissez un mot de passe pour activer la synchronisation chiffrée E2E. Si vous perdez ce mot de passe, les profils chiffrés ne pourront pas être récupérés.",
"passwordSet": "Actif",
"passwordSetDescription": "Le mot de passe de chiffrement E2E est défini",
"noPassword": "Aucun mot de passe défini",
"passwordPlaceholder": "Mot de passe (min. 8 caractères)",
"confirmPlaceholder": "Confirmer le mot de passe",
"setPassword": "Définir le mot de passe",
"changePassword": "Changer le mot de passe",
"removePassword": "Supprimer le mot de passe",
"removed": "Mot de passe de chiffrement supprimé",
"passwordSaved": "Mot de passe de chiffrement défini",
"passwordMismatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères"
},
"commercial": {
"title": "Licence commerciale",
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
@@ -130,7 +146,8 @@
"groups": "Groupes",
"syncService": "Compte",
"integrations": "Intégrations",
"importProfile": "Importer un profil"
"importProfile": "Importer un profil",
"extensions": "Extensions"
}
},
"profiles": {
@@ -157,8 +174,17 @@
"delete": "Supprimer",
"copyCookies": "Copier les cookies",
"configure": "Configurer",
"clone": "Cloner le profil"
}
"clone": "Cloner le profil",
"viewNetwork": "Voir le Réseau",
"syncSettings": "Paramètres de Synchronisation",
"assignToGroup": "Assigner au Groupe",
"changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil"
},
"ephemeral": "Éphémère",
"ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.",
"ephemeralBadge": "Éphémère",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Créer un nouveau profil",
@@ -262,6 +288,25 @@
}
},
"sync": {
"mode": {
"title": "Synchronisation du profil",
"description": "Gérer les paramètres de synchronisation pour \"{{name}}\"",
"disabled": "Désactivé",
"regular": "Synchronisation régulière",
"encrypted": "Synchronisation chiffrée E2E",
"disabledDescription": "Pas de synchronisation pour ce profil",
"regularDescription": "Synchronisation rapide, non chiffrée",
"encryptedDescription": "Chiffré avant l'envoi. Le serveur ne voit jamais les données en clair.",
"noPasswordWarning": "Mot de passe E2E non défini. Veuillez définir un mot de passe dans les Paramètres.",
"passwordRequired": "Mot de passe E2E non défini. Veuillez d'abord définir un mot de passe dans les Paramètres.",
"enabledToast": "Synchronisation activée",
"disabledToast": "Synchronisation désactivée",
"syncQueued": "Synchronisation en file d'attente",
"syncNow": "Synchroniser maintenant",
"lastSynced": "Dernière synchronisation",
"notConfigured": "Service de synchronisation non configuré.",
"configureService": "Configurer le service de synchronisation"
},
"title": "Service de synchronisation",
"config": "Configuration de la synchronisation",
"serverUrl": "URL du serveur",
@@ -483,7 +528,113 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "L'usurpation d'empreinte pour un système d'exploitation différent est moins fiable car il est impossible d'imiter parfaitement tous les composants sous-jacents. À utiliser avec précaution."
"crossOsWarning": "L'usurpation d'empreinte pour un système d'exploitation différent est moins fiable car il est impossible d'imiter parfaitement tous les composants sous-jacents. À utiliser avec précaution.",
"crossOsLimitations": "L'usurpation d'empreinte inter-OS a des limitations. Les APIs au niveau du système peuvent toujours refléter votre système d'exploitation réel et certaines fonctionnalités peuvent avoir des performances réduites.",
"osLabel": "Empreinte du système d'exploitation",
"selectOSPlaceholder": "Sélectionner le système d'exploitation",
"generateRandomOnLaunch": "Générer une empreinte aléatoire à chaque lancement",
"generateRandomDescription": "Lorsque cette option est activée, une nouvelle empreinte sera générée à chaque lancement du navigateur.",
"generateRandomDescriptionAuto": "Lorsque cette option est activée, une nouvelle empreinte sera générée à chaque lancement du navigateur. L'empreinte générée est sauvegardée pour référence.",
"autoLocationDescription": "Configurer automatiquement les informations de localisation en fonction de la configuration du proxy ou de votre connexion si aucun proxy n'est fourni",
"editingDisabledRunning": "La modification de l'empreinte est désactivée car le profil est en cours d'exécution. Arrêtez le profil pour effectuer des modifications.",
"editingDisabledRandomized": "La modification de l'empreinte est désactivée car la génération aléatoire d'empreinte est activée. Désactivez l'option ci-dessus pour modifier manuellement la configuration de l'empreinte.",
"advancedWarning": "Avertissement : Ne modifiez ces paramètres que si vous savez ce que vous faites. Des valeurs incorrectes peuvent casser des sites web, vous faire détecter et provoquer des bugs difficiles à résoudre.",
"basicWarning": "Avertissement : Ne modifiez ces paramètres que si vous savez ce que vous faites.",
"automatic": "Automatique",
"manual": "Manuel",
"blockingOptions": "Options de blocage",
"blockImages": "Bloquer les images",
"blockWebRTC": "Bloquer WebRTC",
"blockWebGL": "Bloquer WebGL",
"navigatorProperties": "Propriétés du navigateur",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent et plateforme",
"platform": "Plateforme",
"platformVersion": "Version de la plateforme",
"appVersion": "Version de l'application",
"osCpu": "OS CPU",
"hardwareConcurrency": "Concurrence matérielle",
"maxTouchPoints": "Points tactiles maximum",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Sélectionner la valeur DNT",
"dntAllowed": "0 (suivi autorisé)",
"dntNotAllowed": "1 (suivi non autorisé)",
"dntUnspecified": "non spécifié",
"language": "Langue",
"primaryLanguage": "Langue principale (navigator.language)",
"languages": "Langues (JSON array)",
"languageAndLocale": "Langue et paramètres régionaux",
"screenProperties": "Propriétés de l'écran",
"screenWidth": "Largeur de l'écran",
"screenHeight": "Hauteur de l'écran",
"availableWidth": "Largeur disponible",
"availableHeight": "Hauteur disponible",
"colorDepth": "Profondeur de couleur",
"pixelDepth": "Profondeur de pixel",
"devicePixelRatio": "Ratio de pixels de l'appareil",
"windowProperties": "Propriétés de la fenêtre",
"outerWidth": "Largeur extérieure",
"outerHeight": "Hauteur extérieure",
"innerWidth": "Largeur intérieure",
"innerHeight": "Hauteur intérieure",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Géolocalisation",
"timezoneAndGeolocation": "Fuseau horaire et géolocalisation",
"timezoneGeolocationDescription": "Ces valeurs remplacent les APIs de fuseau horaire et de géolocalisation du navigateur.",
"latitude": "Latitude",
"longitude": "Longitude",
"timezone": "Fuseau horaire",
"timezoneIana": "Fuseau horaire (IANA)",
"timezoneOffset": "Décalage (minutes depuis UTC)",
"accuracy": "Précision (mètres)",
"locale": "Paramètres régionaux",
"region": "Région",
"script": "Script",
"webglProperties": "Propriétés WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Paramètres WebGL",
"webglParametersJson": "Paramètres WebGL (JSON)",
"webgl2Parameters": "Paramètres WebGL2",
"webglShaderPrecisionFormats": "Formats de précision WebGL Shader",
"webgl2ShaderPrecisionFormats": "Formats de précision WebGL2 Shader",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Cette graine est utilisée pour générer une empreinte Canvas cohérente mais unique. Chaque profil doit avoir une graine différente.",
"fonts": "Polices",
"fontsJson": "Polices (JSON array)",
"battery": "Batterie",
"charging": "En charge",
"chargingTime": "Temps de charge",
"dischargingTime": "Temps de décharge",
"batteryLevel": "Niveau (0-1)",
"screenResolution": "Résolution de l'écran",
"maxWidth": "Largeur maximale",
"maxHeight": "Hauteur maximale",
"minWidth": "Largeur minimale",
"minHeight": "Hauteur minimale",
"hardwareProperties": "Propriétés matérielles",
"deviceMemory": "Mémoire de l'appareil (Go)",
"audioProperties": "Propriétés audio",
"sampleRate": "Fréquence d'échantillonnage",
"maxChannelCount": "Nombre maximum de canaux",
"vendorInfo": "Informations du fournisseur",
"vendor": "Fournisseur",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Marque",
"brandVersion": "Version de la marque",
"proFeature": "Ceci est une fonctionnalité Pro"
},
"warnings": {
"windowResizeTitle": "Dimensions de fenêtre personnalisées",
"windowResizeDescription": "Modifier les dimensions de la fenêtre du navigateur peut augmenter les chances de détection par les sites web que les informations du navigateur sont falsifiées.",
"windowResizeCamoufoxTitle": "Viewport verrouillé par Camoufox",
"windowResizeCamoufoxDescription": "Camoufox verrouille le viewport aux dimensions d'écran falsifiées pour l'anti-fingerprinting. Redimensionner la fenêtre peut causer des zones recadrées ou grises. C'est le comportement attendu.",
"dontShowAgain": "Ne plus afficher",
"continue": "Continuer",
"cancel": "Annuler"
},
"syncAll": {
"title": "Activer la synchronisation pour les éléments existants",
@@ -503,5 +654,118 @@
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
"cannotLaunch": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
"cannotModify": "Impossible de modifier les paramètres de synchronisation d'un profil d'un autre système d'exploitation"
},
"cookies": {
"management": {
"title": "Gestion des Cookies",
"menuItem": "Gestion des Cookies"
},
"import": {
"title": "Importer des Cookies",
"description": "Importer des cookies depuis un fichier au format Netscape ou JSON.",
"selectFile": "Choisir un Fichier",
"preview": "{{count}} cookies trouvés",
"success": "{{imported}} cookies importés avec succès ({{replaced}} remplacés)",
"error": "Échec de l'importation des cookies",
"proFeature": "L'importation de cookies est une fonctionnalité Pro"
},
"export": {
"title": "Exporter les Cookies",
"description": "Exporter les cookies de ce profil.",
"formatLabel": "Format",
"netscape": "Netscape TXT",
"json": "JSON",
"success": "Cookies exportés avec succès",
"error": "Échec de l'exportation des cookies"
}
},
"profileInfo": {
"title": "Détails du Profil",
"tabs": {
"info": "Info",
"settings": "Paramètres"
},
"fields": {
"profileId": "ID du Profil",
"browser": "Navigateur",
"releaseType": "Type de Version",
"proxyVpn": "Proxy / VPN",
"group": "Groupe",
"tags": "Tags",
"note": "Note",
"syncStatus": "État de Synchronisation",
"lastLaunched": "Dernier Lancement",
"hostOs": "OS Hôte",
"ephemeral": "Éphémère",
"extensionGroup": "Groupe d'Extensions"
},
"values": {
"none": "Aucun",
"never": "Jamais",
"copied": "Copié !",
"yes": "Oui"
},
"network": {
"bypassRules": "Règles de Contournement du Proxy",
"bypassRulesDescription": "Les requêtes correspondant à ces règles se connecteront directement, contournant le proxy.",
"addRule": "Ajouter une Règle",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
"noRules": "Aucune règle de contournement configurée.",
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
},
"actions": {
"manageCookies": "Gérer les Cookies"
},
"clone": {
"title": "Cloner le Profil",
"description": "Entrez un nom pour le profil cloné",
"namePlaceholder": "Nom du profil",
"button": "Cloner"
}
},
"extensions": {
"title": "Extensions",
"description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.",
"upload": "Télécharger",
"delete": "Supprimer",
"extensionsTab": "Extensions",
"groupsTab": "Groupes",
"managedNotice": "Les extensions gérées ici remplaceront toutes les extensions installées manuellement dans les profils lors du lancement.",
"proRequired": "La gestion des extensions est une fonctionnalité Pro",
"empty": "Aucune extension téléchargée pour l'instant.",
"noGroups": "Aucun groupe d'extensions créé pour l'instant.",
"createGroup": "Créer un Groupe",
"addToGroup": "Ajouter une extension...",
"removeFromGroup": "Retirer du groupe",
"deleteGroup": "Supprimer le groupe",
"extensionGroup": "Groupe d'Extensions",
"compatibility": {
"label": "Compatibilité",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium et Firefox"
},
"selectedFile": "Fichier sélectionné",
"namePlaceholder": "Nom de l'extension",
"groupNamePlaceholder": "Nom du groupe",
"uploadSuccess": "Extension téléchargée avec succès",
"deleteSuccess": "Extension supprimée avec succès",
"groupCreateSuccess": "Groupe d'extensions créé avec succès",
"groupUpdateSuccess": "Groupe d'extensions mis à jour avec succès",
"groupDeleteSuccess": "Groupe d'extensions supprimé avec succès",
"deleteConfirmTitle": "Supprimer l'Extension",
"deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
"invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.",
"readError": "Impossible de lire le fichier d'extension."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro",
"cookieCopyLocked": "La copie de cookies est une fonctionnalité Pro",
"cookieImportLocked": "L'importation de cookies est une fonctionnalité Pro",
"cookieExportLocked": "L'exportation de cookies est une fonctionnalité Pro",
"cookieManagementLocked": "La gestion des cookies est une fonctionnalité Pro"
}
}
+268 -4
View File
@@ -106,6 +106,22 @@
"description": "外部ツールやAIアシスタントと統合するためのローカルAPIとMCP(モデルコンテキストプロトコル)を設定します。",
"openSettings": "統合設定を開く"
},
"encryption": {
"title": "同期暗号化",
"description": "E2E暗号化同期を有効にするためのパスワードを設定してください。このパスワードを紛失すると、暗号化されたプロファイルは復元できません。",
"passwordSet": "有効",
"passwordSetDescription": "E2E暗号化パスワードが設定されています",
"noPassword": "パスワード未設定",
"passwordPlaceholder": "パスワード(8文字以上)",
"confirmPlaceholder": "パスワードを確認",
"setPassword": "パスワードを設定",
"changePassword": "パスワードを変更",
"removePassword": "パスワードを削除",
"removed": "暗号化パスワードが削除されました",
"passwordSaved": "暗号化パスワードが設定されました",
"passwordMismatch": "パスワードが一致しません",
"passwordTooShort": "パスワードは8文字以上である必要があります"
},
"commercial": {
"title": "商用ライセンス",
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
@@ -130,7 +146,8 @@
"groups": "グループ",
"syncService": "アカウント",
"integrations": "統合",
"importProfile": "プロファイルをインポート"
"importProfile": "プロファイルをインポート",
"extensions": "拡張機能"
}
},
"profiles": {
@@ -157,8 +174,17 @@
"delete": "削除",
"copyCookies": "Cookieをコピー",
"configure": "設定",
"clone": "プロファイルを複製"
}
"clone": "プロファイルを複製",
"viewNetwork": "ネットワークを表示",
"syncSettings": "同期設定",
"assignToGroup": "グループに割り当て",
"changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー"
},
"ephemeral": "一時的",
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。",
"ephemeralBadge": "一時的",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "新しいプロファイルを作成",
@@ -262,6 +288,25 @@
}
},
"sync": {
"mode": {
"title": "プロファイル同期",
"description": "\"{{name}}\" の同期設定を管理",
"disabled": "無効",
"regular": "通常同期",
"encrypted": "E2E暗号化同期",
"disabledDescription": "このプロファイルの同期なし",
"regularDescription": "高速同期、暗号化なし",
"encryptedDescription": "アップロード前に暗号化。サーバーは平文データを見ることができません。",
"noPasswordWarning": "E2Eパスワードが設定されていません。設定でパスワードを設定してください。",
"passwordRequired": "E2Eパスワードが設定されていません。まず設定でパスワードを設定してください。",
"enabledToast": "同期が有効になりました",
"disabledToast": "同期が無効になりました",
"syncQueued": "同期がキューに追加されました",
"syncNow": "今すぐ同期",
"lastSynced": "最終同期",
"notConfigured": "同期サービスが設定されていません。",
"configureService": "同期サービスを設定"
},
"title": "同期サービス",
"config": "同期設定",
"serverUrl": "サーバーURL",
@@ -483,7 +528,113 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "異なるオペレーティングシステムのフィンガープリント偽装は、すべての基盤コンポーネントを完璧に模倣することが不可能なため、信頼性が低くなります。注意してご使用ください。"
"crossOsWarning": "異なるオペレーティングシステムのフィンガープリント偽装は、すべての基盤コンポーネントを完璧に模倣することが不可能なため、信頼性が低くなります。注意してご使用ください。",
"crossOsLimitations": "クロスOSフィンガープリントには制限があります。システムレベルのAPIは実際のオペレーティングシステムを反映する場合があり、一部の機能のパフォーマンスが低下する可能性があります。",
"osLabel": "オペレーティングシステムのフィンガープリント",
"selectOSPlaceholder": "オペレーティングシステムを選択",
"generateRandomOnLaunch": "起動ごとにランダムなフィンガープリントを生成",
"generateRandomDescription": "有効にすると、ブラウザの起動ごとに新しいフィンガープリントが生成されます。",
"generateRandomDescriptionAuto": "有効にすると、ブラウザの起動ごとに新しいフィンガープリントが生成されます。生成されたフィンガープリントは参照用に保存されます。",
"autoLocationDescription": "プロキシ設定に基づいて位置情報を自動的に設定します。プロキシが提供されていない場合は接続情報を使用します。",
"editingDisabledRunning": "プロファイルが現在実行中のため、フィンガープリントの編集は無効です。変更するにはプロファイルを停止してください。",
"editingDisabledRandomized": "ランダムフィンガープリント生成が有効なため、フィンガープリントの編集は無効です。手動で編集するには上記のオプションを無効にしてください。",
"advancedWarning": "警告: これらのパラメータは、内容を理解している場合にのみ編集してください。不正な値はウェブサイトの動作を妨げたり、検出されたり、デバッグが困難なバグにつながる可能性があります。",
"basicWarning": "警告: これらのパラメータは、内容を理解している場合にのみ編集してください。",
"automatic": "自動",
"manual": "手動",
"blockingOptions": "ブロックオプション",
"blockImages": "画像をブロック",
"blockWebRTC": "WebRTCをブロック",
"blockWebGL": "WebGLをブロック",
"navigatorProperties": "Navigatorプロパティ",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
"appVersion": "App Version",
"osCpu": "OS CPU",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "最大タッチポイント数",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "DNT値を選択",
"dntAllowed": "0(トラッキング許可)",
"dntNotAllowed": "1(トラッキング不許可)",
"dntUnspecified": "未指定",
"language": "言語",
"primaryLanguage": "プライマリ言語 (navigator.language)",
"languages": "言語一覧 (JSON配列)",
"languageAndLocale": "言語とロケール",
"screenProperties": "画面プロパティ",
"screenWidth": "画面幅",
"screenHeight": "画面高さ",
"availableWidth": "利用可能な幅",
"availableHeight": "利用可能な高さ",
"colorDepth": "色深度",
"pixelDepth": "ピクセル深度",
"devicePixelRatio": "デバイスピクセル比",
"windowProperties": "ウィンドウプロパティ",
"outerWidth": "外側の幅",
"outerHeight": "外側の高さ",
"innerWidth": "内側の幅",
"innerHeight": "内側の高さ",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "ジオロケーション",
"timezoneAndGeolocation": "タイムゾーンとジオロケーション",
"timezoneGeolocationDescription": "これらの値はブラウザのタイムゾーンとジオロケーションAPIを上書きします。",
"latitude": "緯度",
"longitude": "経度",
"timezone": "タイムゾーン",
"timezoneIana": "タイムゾーン (IANA)",
"timezoneOffset": "オフセット(UTCからの分数)",
"accuracy": "精度(メートル)",
"locale": "ロケール",
"region": "地域",
"script": "スクリプト",
"webglProperties": "WebGLプロパティ",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "WebGLパラメータ",
"webglParametersJson": "WebGLパラメータ (JSON)",
"webgl2Parameters": "WebGL2パラメータ",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "このシードは一貫性がありながらもユニークなCanvasフィンガープリントを生成するために使用されます。各プロファイルには異なるシードを設定してください。",
"fonts": "フォント",
"fontsJson": "フォント (JSON配列)",
"battery": "バッテリー",
"charging": "充電中",
"chargingTime": "充電時間",
"dischargingTime": "放電時間",
"batteryLevel": "レベル (0-1)",
"screenResolution": "画面解像度",
"maxWidth": "最大幅",
"maxHeight": "最大高さ",
"minWidth": "最小幅",
"minHeight": "最小高さ",
"hardwareProperties": "ハードウェアプロパティ",
"deviceMemory": "デバイスメモリ (GB)",
"audioProperties": "オーディオプロパティ",
"sampleRate": "サンプルレート",
"maxChannelCount": "最大チャンネル数",
"vendorInfo": "ベンダー情報",
"vendor": "ベンダー",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "ブランド",
"brandVersion": "ブランドバージョン",
"proFeature": "これはPro機能です"
},
"warnings": {
"windowResizeTitle": "カスタムウィンドウサイズ",
"windowResizeDescription": "ブラウザウィンドウのサイズを変更すると、ブラウザ情報が偽装されていることをウェブサイトに検出される可能性が高くなります。",
"windowResizeCamoufoxTitle": "Camoufoxによりビューポートがロックされています",
"windowResizeCamoufoxDescription": "Camoufoxはアンチフィンガープリントのためにビューポートを偽装された画面サイズにロックします。ウィンドウのサイズを変更すると、切り取られた領域やグレーの領域が表示される場合があります。これは想定された動作です。",
"dontShowAgain": "今後表示しない",
"continue": "続行",
"cancel": "キャンセル"
},
"syncAll": {
"title": "既存アイテムの同期を有効にする",
@@ -503,5 +654,118 @@
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
"cannotLaunch": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
"cannotModify": "他のOSのプロファイルの同期設定は変更できません"
},
"cookies": {
"management": {
"title": "Cookie管理",
"menuItem": "Cookie管理"
},
"import": {
"title": "Cookieのインポート",
"description": "NetscapeまたはJSON形式のファイルからCookieをインポートします。",
"selectFile": "ファイルを選択",
"preview": "{{count}}件のCookieが見つかりました",
"success": "{{imported}}件のCookieをインポートしました({{replaced}}件を置換)",
"error": "Cookieのインポートに失敗しました",
"proFeature": "Cookieのインポートはプロ機能です"
},
"export": {
"title": "Cookieのエクスポート",
"description": "このプロファイルからCookieをエクスポートします。",
"formatLabel": "形式",
"netscape": "Netscape TXT",
"json": "JSON",
"success": "Cookieのエクスポートに成功しました",
"error": "Cookieのエクスポートに失敗しました"
}
},
"profileInfo": {
"title": "プロフィール詳細",
"tabs": {
"info": "情報",
"settings": "設定"
},
"fields": {
"profileId": "プロフィールID",
"browser": "ブラウザ",
"releaseType": "リリースタイプ",
"proxyVpn": "プロキシ / VPN",
"group": "グループ",
"tags": "タグ",
"note": "メモ",
"syncStatus": "同期ステータス",
"lastLaunched": "最終起動",
"hostOs": "ホストOS",
"ephemeral": "エフェメラル",
"extensionGroup": "拡張機能グループ"
},
"values": {
"none": "なし",
"never": "なし",
"copied": "コピーしました!",
"yes": "はい"
},
"network": {
"bypassRules": "プロキシバイパスルール",
"bypassRulesDescription": "これらのルールに一致するリクエストは、プロキシをバイパスして直接接続します。",
"addRule": "ルールを追加",
"rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local",
"noRules": "バイパスルールは設定されていません。",
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
},
"actions": {
"manageCookies": "Cookieを管理"
},
"clone": {
"title": "プロフィールを複製",
"description": "複製されたプロフィールの名前を入力してください",
"namePlaceholder": "プロフィール名",
"button": "複製"
}
},
"extensions": {
"title": "拡張機能",
"description": "プロファイル用のブラウザ拡張機能と拡張機能グループを管理します。",
"upload": "アップロード",
"delete": "削除",
"extensionsTab": "拡張機能",
"groupsTab": "グループ",
"managedNotice": "ここで管理される拡張機能は、起動時にプロファイルに手動でインストールされた拡張機能を置き換えます。",
"proRequired": "拡張機能管理はプロ機能です",
"empty": "まだ拡張機能がアップロードされていません。",
"noGroups": "まだ拡張機能グループが作成されていません。",
"createGroup": "グループを作成",
"addToGroup": "拡張機能を追加...",
"removeFromGroup": "グループから削除",
"deleteGroup": "グループを削除",
"extensionGroup": "拡張機能グループ",
"compatibility": {
"label": "互換性",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium & Firefox"
},
"selectedFile": "選択されたファイル",
"namePlaceholder": "拡張機能名",
"groupNamePlaceholder": "グループ名",
"uploadSuccess": "拡張機能が正常にアップロードされました",
"deleteSuccess": "拡張機能が正常に削除されました",
"groupCreateSuccess": "拡張機能グループが正常に作成されました",
"groupUpdateSuccess": "拡張機能グループが正常に更新されました",
"groupDeleteSuccess": "拡張機能グループが正常に削除されました",
"deleteConfirmTitle": "拡張機能を削除",
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"deleteGroupConfirmTitle": "拡張機能グループを削除",
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
"readError": "拡張機能ファイルの読み取りに失敗しました。"
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "フィンガープリント編集はプロ機能です",
"cookieCopyLocked": "Cookieのコピーはプロ機能です",
"cookieImportLocked": "Cookieのインポートはプロ機能です",
"cookieExportLocked": "Cookieのエクスポートはプロ機能です",
"cookieManagementLocked": "Cookie管理はプロ機能です"
}
}
+268 -4
View File
@@ -106,6 +106,22 @@
"description": "Configure a API Local e MCP (Protocolo de Contexto de Modelo) para integração com ferramentas externas e assistentes de IA.",
"openSettings": "Abrir Configurações de Integrações"
},
"encryption": {
"title": "Criptografia de sincronização",
"description": "Defina uma senha para habilitar a sincronização criptografada E2E. Se você perder esta senha, os perfis criptografados não poderão ser recuperados.",
"passwordSet": "Ativo",
"passwordSetDescription": "A senha de criptografia E2E está definida",
"noPassword": "Sem senha definida",
"passwordPlaceholder": "Senha (mín. 8 caracteres)",
"confirmPlaceholder": "Confirmar senha",
"setPassword": "Definir senha",
"changePassword": "Alterar senha",
"removePassword": "Remover senha",
"removed": "Senha de criptografia removida",
"passwordSaved": "Senha de criptografia definida",
"passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres"
},
"commercial": {
"title": "Licença Comercial",
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
@@ -130,7 +146,8 @@
"groups": "Grupos",
"syncService": "Conta",
"integrations": "Integrações",
"importProfile": "Importar Perfil"
"importProfile": "Importar Perfil",
"extensions": "Extensões"
}
},
"profiles": {
@@ -157,8 +174,17 @@
"delete": "Excluir",
"copyCookies": "Copiar Cookies",
"configure": "Configurar",
"clone": "Clonar perfil"
}
"clone": "Clonar perfil",
"viewNetwork": "Ver Rede",
"syncSettings": "Configurações de Sincronização",
"assignToGroup": "Atribuir ao Grupo",
"changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil"
},
"ephemeral": "Efêmero",
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Os dados são excluídos ao fechar o navegador.",
"ephemeralBadge": "Efêmero",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Criar Novo Perfil",
@@ -262,6 +288,25 @@
}
},
"sync": {
"mode": {
"title": "Sincronização de perfil",
"description": "Gerenciar configurações de sincronização para \"{{name}}\"",
"disabled": "Desabilitado",
"regular": "Sincronização regular",
"encrypted": "Sincronização criptografada E2E",
"disabledDescription": "Sem sincronização para este perfil",
"regularDescription": "Sincronização rápida, sem criptografia",
"encryptedDescription": "Criptografado antes do upload. O servidor nunca vê os dados em texto simples.",
"noPasswordWarning": "Senha E2E não definida. Por favor defina uma senha nas Configurações.",
"passwordRequired": "Senha E2E não definida. Por favor defina uma senha nas Configurações primeiro.",
"enabledToast": "Sincronização habilitada",
"disabledToast": "Sincronização desabilitada",
"syncQueued": "Sincronização na fila",
"syncNow": "Sincronizar agora",
"lastSynced": "Última sincronização",
"notConfigured": "Serviço de sincronização não configurado.",
"configureService": "Configurar serviço de sincronização"
},
"title": "Serviço de Sincronização",
"config": "Configuração de Sincronização",
"serverUrl": "URL do Servidor",
@@ -483,7 +528,113 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "A falsificação de impressão digital para um sistema operacional diferente é menos confiável porque é impossível imitar perfeitamente todos os componentes subjacentes. Use com cautela."
"crossOsWarning": "A falsificação de impressão digital para um sistema operacional diferente é menos confiável porque é impossível imitar perfeitamente todos os componentes subjacentes. Use com cautela.",
"crossOsLimitations": "A impressão digital entre sistemas operacionais tem limitações. APIs de nível de sistema ainda podem refletir seu sistema operacional real, e alguns recursos podem ter desempenho reduzido.",
"osLabel": "Impressão Digital do Sistema Operacional",
"selectOSPlaceholder": "Selecionar sistema operacional",
"generateRandomOnLaunch": "Gerar impressão digital aleatória a cada inicialização",
"generateRandomDescription": "Quando ativado, uma nova impressão digital será gerada cada vez que o navegador for iniciado.",
"generateRandomDescriptionAuto": "Quando ativado, uma nova impressão digital será gerada cada vez que o navegador for iniciado. A impressão digital gerada é salva para referência.",
"autoLocationDescription": "Configurar automaticamente as informações de localização com base na configuração do proxy ou na sua conexão se nenhum proxy for fornecido",
"editingDisabledRunning": "A edição de impressão digital está desativada porque o perfil está em execução. Pare o perfil para fazer alterações.",
"editingDisabledRandomized": "A edição de impressão digital está desativada porque a geração aleatória de impressão digital está ativada. Desative a opção acima para editar manualmente a configuração da impressão digital.",
"advancedWarning": "Aviso: Edite estes parâmetros apenas se souber o que está fazendo. Valores incorretos podem quebrar sites, fazer com que detectem você e levar a bugs difíceis de depurar.",
"basicWarning": "Aviso: Edite estes parâmetros apenas se souber o que está fazendo.",
"automatic": "Automático",
"manual": "Manual",
"blockingOptions": "Opções de Bloqueio",
"blockImages": "Bloquear Imagens",
"blockWebRTC": "Bloquear WebRTC",
"blockWebGL": "Bloquear WebGL",
"navigatorProperties": "Propriedades do Navigator",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
"appVersion": "App Version",
"osCpu": "OS CPU",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "Pontos de Toque Máximos",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Selecionar valor DNT",
"dntAllowed": "0 (rastreamento permitido)",
"dntNotAllowed": "1 (rastreamento não permitido)",
"dntUnspecified": "não especificado",
"language": "Idioma",
"primaryLanguage": "Idioma Principal (navigator.language)",
"languages": "Idiomas (JSON array)",
"languageAndLocale": "Idioma e Localidade",
"screenProperties": "Propriedades da Tela",
"screenWidth": "Largura da Tela",
"screenHeight": "Altura da Tela",
"availableWidth": "Largura Disponível",
"availableHeight": "Altura Disponível",
"colorDepth": "Profundidade de Cor",
"pixelDepth": "Profundidade de Pixel",
"devicePixelRatio": "Proporção de Pixels do Dispositivo",
"windowProperties": "Propriedades da Janela",
"outerWidth": "Largura Externa",
"outerHeight": "Altura Externa",
"innerWidth": "Largura Interna",
"innerHeight": "Altura Interna",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Geolocalização",
"timezoneAndGeolocation": "Fuso Horário e Geolocalização",
"timezoneGeolocationDescription": "Estes valores substituem as APIs de fuso horário e geolocalização do navegador.",
"latitude": "Latitude",
"longitude": "Longitude",
"timezone": "Fuso Horário",
"timezoneIana": "Fuso Horário (IANA)",
"timezoneOffset": "Deslocamento (minutos a partir do UTC)",
"accuracy": "Precisão (metros)",
"locale": "Localidade",
"region": "Região",
"script": "Script",
"webglProperties": "Propriedades WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Parâmetros WebGL",
"webglParametersJson": "Parâmetros WebGL (JSON)",
"webgl2Parameters": "Parâmetros WebGL2",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Este seed é usado para gerar uma impressão digital Canvas consistente, mas única. Cada perfil deve ter um seed diferente.",
"fonts": "Fontes",
"fontsJson": "Fontes (JSON array)",
"battery": "Bateria",
"charging": "Carregando",
"chargingTime": "Tempo de Carregamento",
"dischargingTime": "Tempo de Descarregamento",
"batteryLevel": "Nível (0-1)",
"screenResolution": "Resolução da Tela",
"maxWidth": "Largura Máxima",
"maxHeight": "Altura Máxima",
"minWidth": "Largura Mínima",
"minHeight": "Altura Mínima",
"hardwareProperties": "Propriedades de Hardware",
"deviceMemory": "Memória do Dispositivo (GB)",
"audioProperties": "Propriedades de Áudio",
"sampleRate": "Taxa de Amostragem",
"maxChannelCount": "Contagem Máxima de Canais",
"vendorInfo": "Informações do Fabricante",
"vendor": "Fabricante",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Marca",
"brandVersion": "Versão da Marca",
"proFeature": "Este é um recurso Pro"
},
"warnings": {
"windowResizeTitle": "Dimensões de janela personalizadas",
"windowResizeDescription": "Alterar as dimensões da janela do navegador pode aumentar a chance de detecção pelos sites de que as informações do navegador estão falsificadas.",
"windowResizeCamoufoxTitle": "Viewport bloqueado pelo Camoufox",
"windowResizeCamoufoxDescription": "O Camoufox bloqueia o viewport nas dimensões de tela falsificadas para anti-fingerprinting. Redimensionar a janela pode causar áreas cortadas ou cinzas. Este é o comportamento esperado.",
"dontShowAgain": "Não mostrar novamente",
"continue": "Continuar",
"cancel": "Cancelar"
},
"syncAll": {
"title": "Ativar sincronização para itens existentes",
@@ -503,5 +654,118 @@
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
"cannotLaunch": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
"cannotModify": "Não é possível modificar as configurações de sincronização de um perfil de outro sistema operacional"
},
"cookies": {
"management": {
"title": "Gerenciamento de Cookies",
"menuItem": "Gerenciamento de Cookies"
},
"import": {
"title": "Importar Cookies",
"description": "Importar cookies de um arquivo no formato Netscape ou JSON.",
"selectFile": "Escolher Arquivo",
"preview": "{{count}} cookies encontrados",
"success": "{{imported}} cookies importados com sucesso ({{replaced}} substituídos)",
"error": "Falha ao importar cookies",
"proFeature": "A importação de cookies é um recurso Pro"
},
"export": {
"title": "Exportar Cookies",
"description": "Exportar cookies deste perfil.",
"formatLabel": "Formato",
"netscape": "Netscape TXT",
"json": "JSON",
"success": "Cookies exportados com sucesso",
"error": "Falha ao exportar cookies"
}
},
"profileInfo": {
"title": "Detalhes do Perfil",
"tabs": {
"info": "Info",
"settings": "Configurações"
},
"fields": {
"profileId": "ID do Perfil",
"browser": "Navegador",
"releaseType": "Tipo de Versão",
"proxyVpn": "Proxy / VPN",
"group": "Grupo",
"tags": "Tags",
"note": "Nota",
"syncStatus": "Status de Sincronização",
"lastLaunched": "Último Lançamento",
"hostOs": "SO Host",
"ephemeral": "Efêmero",
"extensionGroup": "Grupo de Extensões"
},
"values": {
"none": "Nenhum",
"never": "Nunca",
"copied": "Copiado!",
"yes": "Sim"
},
"network": {
"bypassRules": "Regras de Bypass de Proxy",
"bypassRulesDescription": "Solicitações que correspondam a estas regras se conectarão diretamente, ignorando o proxy.",
"addRule": "Adicionar Regra",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
"noRules": "Nenhuma regra de bypass configurada.",
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
},
"actions": {
"manageCookies": "Gerenciar Cookies"
},
"clone": {
"title": "Clonar Perfil",
"description": "Digite um nome para o perfil clonado",
"namePlaceholder": "Nome do perfil",
"button": "Clonar"
}
},
"extensions": {
"title": "Extensões",
"description": "Gerencie extensões de navegador e grupos de extensões para seus perfis.",
"upload": "Enviar",
"delete": "Excluir",
"extensionsTab": "Extensões",
"groupsTab": "Grupos",
"managedNotice": "As extensões gerenciadas aqui substituirão quaisquer extensões instaladas manualmente nos perfis ao serem iniciados.",
"proRequired": "O gerenciamento de extensões é um recurso Pro",
"empty": "Nenhuma extensão enviada ainda.",
"noGroups": "Nenhum grupo de extensões criado ainda.",
"createGroup": "Criar Grupo",
"addToGroup": "Adicionar extensão...",
"removeFromGroup": "Remover do grupo",
"deleteGroup": "Excluir grupo",
"extensionGroup": "Grupo de Extensões",
"compatibility": {
"label": "Compatibilidade",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium e Firefox"
},
"selectedFile": "Arquivo selecionado",
"namePlaceholder": "Nome da extensão",
"groupNamePlaceholder": "Nome do grupo",
"uploadSuccess": "Extensão enviada com sucesso",
"deleteSuccess": "Extensão excluída com sucesso",
"groupCreateSuccess": "Grupo de extensões criado com sucesso",
"groupUpdateSuccess": "Grupo de extensões atualizado com sucesso",
"groupDeleteSuccess": "Grupo de extensões excluído com sucesso",
"deleteConfirmTitle": "Excluir Extensão",
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
"invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.",
"readError": "Falha ao ler o arquivo de extensão."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "A edição de impressão digital é um recurso Pro",
"cookieCopyLocked": "A cópia de cookies é um recurso Pro",
"cookieImportLocked": "A importação de cookies é um recurso Pro",
"cookieExportLocked": "A exportação de cookies é um recurso Pro",
"cookieManagementLocked": "O gerenciamento de cookies é um recurso Pro"
}
}
+268 -4
View File
@@ -106,6 +106,22 @@
"description": "Настройте локальный API и MCP (Model Context Protocol) для интеграции с внешними инструментами и AI-ассистентами.",
"openSettings": "Открыть настройки интеграций"
},
"encryption": {
"title": "Шифрование синхронизации",
"description": "Установите пароль для включения E2E зашифрованной синхронизации. Если вы потеряете этот пароль, зашифрованные профили не могут быть восстановлены.",
"passwordSet": "Активно",
"passwordSetDescription": "Пароль шифрования E2E установлен",
"noPassword": "Пароль не установлен",
"passwordPlaceholder": "Пароль (мин. 8 символов)",
"confirmPlaceholder": "Подтвердите пароль",
"setPassword": "Установить пароль",
"changePassword": "Изменить пароль",
"removePassword": "Удалить пароль",
"removed": "Пароль шифрования удалён",
"passwordSaved": "Пароль шифрования установлен",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен содержать не менее 8 символов"
},
"commercial": {
"title": "Коммерческая лицензия",
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
@@ -130,7 +146,8 @@
"groups": "Группы",
"syncService": "Аккаунт",
"integrations": "Интеграции",
"importProfile": "Импорт профиля"
"importProfile": "Импорт профиля",
"extensions": "Расширения"
}
},
"profiles": {
@@ -157,8 +174,17 @@
"delete": "Удалить",
"copyCookies": "Копировать Cookie",
"configure": "Настроить",
"clone": "Клонировать профиль"
}
"clone": "Клонировать профиль",
"viewNetwork": "Просмотр сети",
"syncSettings": "Настройки синхронизации",
"assignToGroup": "Назначить группе",
"changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль"
},
"ephemeral": "Временный",
"ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.",
"ephemeralBadge": "Временный",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Создать новый профиль",
@@ -262,6 +288,25 @@
}
},
"sync": {
"mode": {
"title": "Синхронизация профиля",
"description": "Управление настройками синхронизации для \"{{name}}\"",
"disabled": "Отключено",
"regular": "Обычная синхронизация",
"encrypted": "E2E зашифрованная синхронизация",
"disabledDescription": "Без синхронизации для этого профиля",
"regularDescription": "Быстрая синхронизация, без шифрования",
"encryptedDescription": "Шифрование перед загрузкой. Сервер никогда не видит данные в открытом виде.",
"noPasswordWarning": "Пароль E2E не установлен. Пожалуйста, установите пароль в Настройках.",
"passwordRequired": "Пароль E2E не установлен. Сначала установите пароль в Настройках.",
"enabledToast": "Синхронизация включена",
"disabledToast": "Синхронизация отключена",
"syncQueued": "Синхронизация в очереди",
"syncNow": "Синхронизировать сейчас",
"lastSynced": "Последняя синхронизация",
"notConfigured": "Сервис синхронизации не настроен.",
"configureService": "Настроить сервис синхронизации"
},
"title": "Служба синхронизации",
"config": "Настройка синхронизации",
"serverUrl": "URL сервера",
@@ -483,7 +528,113 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Подмена отпечатка для другой операционной системы менее надёжна, так как невозможно идеально имитировать все базовые компоненты. Используйте с осторожностью."
"crossOsWarning": "Подмена отпечатка для другой операционной системы менее надёжна, так как невозможно идеально имитировать все базовые компоненты. Используйте с осторожностью.",
"crossOsLimitations": "Подмена отпечатка другой ОС имеет ограничения. Системные API могут по-прежнему отражать вашу реальную операционную систему, а некоторые функции могут работать с пониженной производительностью.",
"osLabel": "Отпечаток операционной системы",
"selectOSPlaceholder": "Выберите операционную систему",
"generateRandomOnLaunch": "Генерировать случайный отпечаток при каждом запуске",
"generateRandomDescription": "При включении новый отпечаток будет генерироваться при каждом запуске браузера.",
"generateRandomDescriptionAuto": "При включении новый отпечаток будет генерироваться при каждом запуске браузера. Сгенерированный отпечаток сохраняется для справки.",
"autoLocationDescription": "Автоматически настраивать информацию о местоположении на основе конфигурации прокси или вашего соединения, если прокси не указан",
"editingDisabledRunning": "Редактирование отпечатка отключено, так как профиль в данный момент запущен. Остановите профиль для внесения изменений.",
"editingDisabledRandomized": "Редактирование отпечатка отключено, так как включена генерация случайного отпечатка. Отключите опцию выше для ручного редактирования конфигурации отпечатка.",
"advancedWarning": "Внимание: редактируйте эти параметры только если вы знаете, что делаете. Неправильные значения могут нарушить работу сайтов, привести к вашему обнаружению и вызвать трудноотлаживаемые ошибки.",
"basicWarning": "Внимание: редактируйте эти параметры только если вы знаете, что делаете.",
"automatic": "Автоматически",
"manual": "Вручную",
"blockingOptions": "Параметры блокировки",
"blockImages": "Блокировать изображения",
"blockWebRTC": "Блокировать WebRTC",
"blockWebGL": "Блокировать WebGL",
"navigatorProperties": "Свойства Navigator",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent и платформа",
"platform": "Платформа",
"platformVersion": "Версия платформы",
"appVersion": "Версия приложения",
"osCpu": "OS CPU",
"hardwareConcurrency": "Количество потоков процессора",
"maxTouchPoints": "Максимальное количество точек касания",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Выберите значение DNT",
"dntAllowed": "0 (отслеживание разрешено)",
"dntNotAllowed": "1 (отслеживание не разрешено)",
"dntUnspecified": "не указано",
"language": "Язык",
"primaryLanguage": "Основной язык (navigator.language)",
"languages": "Языки (JSON-массив)",
"languageAndLocale": "Язык и локаль",
"screenProperties": "Свойства экрана",
"screenWidth": "Ширина экрана",
"screenHeight": "Высота экрана",
"availableWidth": "Доступная ширина",
"availableHeight": "Доступная высота",
"colorDepth": "Глубина цвета",
"pixelDepth": "Глубина пикселей",
"devicePixelRatio": "Соотношение пикселей устройства",
"windowProperties": "Свойства окна",
"outerWidth": "Внешняя ширина",
"outerHeight": "Внешняя высота",
"innerWidth": "Внутренняя ширина",
"innerHeight": "Внутренняя высота",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Геолокация",
"timezoneAndGeolocation": "Часовой пояс и геолокация",
"timezoneGeolocationDescription": "Эти значения переопределяют API часового пояса и геолокации браузера.",
"latitude": "Широта",
"longitude": "Долгота",
"timezone": "Часовой пояс",
"timezoneIana": "Часовой пояс (IANA)",
"timezoneOffset": "Смещение (минуты от UTC)",
"accuracy": "Точность (метры)",
"locale": "Локаль",
"region": "Регион",
"script": "Скрипт",
"webglProperties": "Свойства WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Параметры WebGL",
"webglParametersJson": "Параметры WebGL (JSON)",
"webgl2Parameters": "Параметры WebGL2",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Отпечаток Canvas",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Это зерно используется для генерации постоянного, но уникального отпечатка Canvas. У каждого профиля должно быть своё зерно.",
"fonts": "Шрифты",
"fontsJson": "Шрифты (JSON-массив)",
"battery": "Батарея",
"charging": "Зарядка",
"chargingTime": "Время зарядки",
"dischargingTime": "Время разрядки",
"batteryLevel": "Уровень (0-1)",
"screenResolution": "Разрешение экрана",
"maxWidth": "Макс. ширина",
"maxHeight": "Макс. высота",
"minWidth": "Мин. ширина",
"minHeight": "Мин. высота",
"hardwareProperties": "Свойства оборудования",
"deviceMemory": "Память устройства (ГБ)",
"audioProperties": "Свойства аудио",
"sampleRate": "Частота дискретизации",
"maxChannelCount": "Максимальное количество каналов",
"vendorInfo": "Информация о производителе",
"vendor": "Производитель",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Бренд",
"brandVersion": "Версия бренда",
"proFeature": "Это функция Pro"
},
"warnings": {
"windowResizeTitle": "Пользовательские размеры окна",
"windowResizeDescription": "Изменение размеров окна браузера может повысить вероятность обнаружения сайтами того, что информация браузера подменена.",
"windowResizeCamoufoxTitle": "Viewport заблокирован Camoufox",
"windowResizeCamoufoxDescription": "Camoufox блокирует viewport на подменённых размерах экрана для защиты от фингерпринтинга. Изменение размера окна может вызвать обрезанные или серые области. Это ожидаемое поведение.",
"dontShowAgain": "Больше не показывать",
"continue": "Продолжить",
"cancel": "Отмена"
},
"syncAll": {
"title": "Включить синхронизацию для существующих элементов",
@@ -503,5 +654,118 @@
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
"cannotLaunch": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
"cannotModify": "Невозможно изменить настройки синхронизации профиля другой ОС"
},
"cookies": {
"management": {
"title": "Управление Cookies",
"menuItem": "Управление Cookies"
},
"import": {
"title": "Импорт Cookies",
"description": "Импорт cookies из файла в формате Netscape или JSON.",
"selectFile": "Выбрать файл",
"preview": "Найдено {{count}} cookies",
"success": "Успешно импортировано {{imported}} cookies ({{replaced}} заменено)",
"error": "Ошибка импорта cookies",
"proFeature": "Импорт cookies — функция Pro"
},
"export": {
"title": "Экспорт Cookies",
"description": "Экспорт cookies из этого профиля.",
"formatLabel": "Формат",
"netscape": "Netscape TXT",
"json": "JSON",
"success": "Cookies успешно экспортированы",
"error": "Ошибка экспорта cookies"
}
},
"profileInfo": {
"title": "Детали профиля",
"tabs": {
"info": "Информация",
"settings": "Настройки"
},
"fields": {
"profileId": "ID профиля",
"browser": "Браузер",
"releaseType": "Тип релиза",
"proxyVpn": "Прокси / VPN",
"group": "Группа",
"tags": "Теги",
"note": "Заметка",
"syncStatus": "Статус синхронизации",
"lastLaunched": "Последний запуск",
"hostOs": "ОС хоста",
"ephemeral": "Эфемерный",
"extensionGroup": "Группа расширений"
},
"values": {
"none": "Нет",
"never": "Никогда",
"copied": "Скопировано!",
"yes": "Да"
},
"network": {
"bypassRules": "Правила обхода прокси",
"bypassRulesDescription": "Запросы, соответствующие этим правилам, будут подключаться напрямую, минуя прокси.",
"addRule": "Добавить правило",
"rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local",
"noRules": "Правила обхода не настроены.",
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
},
"actions": {
"manageCookies": "Управление Cookie"
},
"clone": {
"title": "Клонировать профиль",
"description": "Введите имя для клонированного профиля",
"namePlaceholder": "Имя профиля",
"button": "Клонировать"
}
},
"extensions": {
"title": "Расширения",
"description": "Управляйте расширениями браузера и группами расширений для ваших профилей.",
"upload": "Загрузить",
"delete": "Удалить",
"extensionsTab": "Расширения",
"groupsTab": "Группы",
"managedNotice": "Расширения, управляемые здесь, заменят все вручную установленные расширения в профилях при запуске.",
"proRequired": "Управление расширениями — функция Pro",
"empty": "Расширения ещё не загружены.",
"noGroups": "Группы расширений ещё не созданы.",
"createGroup": "Создать группу",
"addToGroup": "Добавить расширение...",
"removeFromGroup": "Удалить из группы",
"deleteGroup": "Удалить группу",
"extensionGroup": "Группа расширений",
"compatibility": {
"label": "Совместимость",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium и Firefox"
},
"selectedFile": "Выбранный файл",
"namePlaceholder": "Название расширения",
"groupNamePlaceholder": "Название группы",
"uploadSuccess": "Расширение успешно загружено",
"deleteSuccess": "Расширение успешно удалено",
"groupCreateSuccess": "Группа расширений успешно создана",
"groupUpdateSuccess": "Группа расширений успешно обновлена",
"groupDeleteSuccess": "Группа расширений успешно удалена",
"deleteConfirmTitle": "Удалить расширение",
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
"deleteGroupConfirmTitle": "Удалить группу расширений",
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
"readError": "Не удалось прочитать файл расширения."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "Редактирование отпечатка — функция Pro",
"cookieCopyLocked": "Копирование cookies — функция Pro",
"cookieImportLocked": "Импорт cookies — функция Pro",
"cookieExportLocked": "Экспорт cookies — функция Pro",
"cookieManagementLocked": "Управление cookies — функция Pro"
}
}
+268 -4
View File
@@ -106,6 +106,22 @@
"description": "配置本地 API 和 MCP(模型上下文协议)以与外部工具和 AI 助手集成。",
"openSettings": "打开集成设置"
},
"encryption": {
"title": "同步加密",
"description": "设置密码以启用E2E加密同步。如果您丢失此密码,加密的配置文件将无法恢复。",
"passwordSet": "已启用",
"passwordSetDescription": "E2E加密密码已设置",
"noPassword": "未设置密码",
"passwordPlaceholder": "密码(至少8个字符)",
"confirmPlaceholder": "确认密码",
"setPassword": "设置密码",
"changePassword": "更改密码",
"removePassword": "删除密码",
"removed": "加密密码已删除",
"passwordSaved": "加密密码已设置",
"passwordMismatch": "密码不匹配",
"passwordTooShort": "密码必须至少8个字符"
},
"commercial": {
"title": "商业许可",
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
@@ -130,7 +146,8 @@
"groups": "分组",
"syncService": "账户",
"integrations": "集成",
"importProfile": "导入配置文件"
"importProfile": "导入配置文件",
"extensions": "扩展程序"
}
},
"profiles": {
@@ -157,8 +174,17 @@
"delete": "删除",
"copyCookies": "复制 Cookies",
"configure": "配置",
"clone": "克隆配置文件"
}
"clone": "克隆配置文件",
"viewNetwork": "查看网络",
"syncSettings": "同步设置",
"assignToGroup": "分配到组",
"changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件"
},
"ephemeral": "临时",
"ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除。",
"ephemeralBadge": "临时",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "创建新配置文件",
@@ -262,6 +288,25 @@
}
},
"sync": {
"mode": {
"title": "配置文件同步",
"description": "管理 \"{{name}}\" 的同步设置",
"disabled": "已禁用",
"regular": "常规同步",
"encrypted": "E2E加密同步",
"disabledDescription": "此配置文件不同步",
"regularDescription": "快速同步,无加密",
"encryptedDescription": "上传前加密。服务器永远不会看到明文数据。",
"noPasswordWarning": "未设置E2E密码。请在设置中设置密码。",
"passwordRequired": "未设置E2E密码。请先在设置中设置密码。",
"enabledToast": "同步已启用",
"disabledToast": "同步已禁用",
"syncQueued": "同步已排队",
"syncNow": "立即同步",
"lastSynced": "上次同步",
"notConfigured": "同步服务未配置。",
"configureService": "配置同步服务"
},
"title": "同步服务",
"config": "同步配置",
"serverUrl": "服务器 URL",
@@ -483,7 +528,113 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "伪装不同操作系统的指纹不太可靠,因为不可能完美模拟所有底层组件。请谨慎使用。"
"crossOsWarning": "伪装不同操作系统的指纹不太可靠,因为不可能完美模拟所有底层组件。请谨慎使用。",
"crossOsLimitations": "跨操作系统指纹伪装存在局限性。系统级 API 可能仍会反映您的实际操作系统,某些功能的性能可能会降低。",
"osLabel": "操作系统指纹",
"selectOSPlaceholder": "选择操作系统",
"generateRandomOnLaunch": "每次启动时生成随机指纹",
"generateRandomDescription": "启用后,每次启动浏览器时将生成新的指纹。",
"generateRandomDescriptionAuto": "启用后,每次启动浏览器时将生成新的指纹。生成的指纹会保存以供参考。",
"autoLocationDescription": "根据代理配置或您的连接(未提供代理时)自动配置位置信息",
"editingDisabledRunning": "指纹编辑已禁用,因为配置文件正在运行中。停止配置文件后才能进行更改。",
"editingDisabledRandomized": "指纹编辑已禁用,因为已启用随机指纹生成。禁用上方选项后才能手动编辑指纹配置。",
"advancedWarning": "警告:请仅在了解自己操作的情况下编辑这些参数。错误的值可能导致网站无法正常工作、被检测到,并引发难以调试的问题。",
"basicWarning": "警告:请仅在了解自己操作的情况下编辑这些参数。",
"automatic": "自动",
"manual": "手动",
"blockingOptions": "阻止选项",
"blockImages": "阻止图片",
"blockWebRTC": "阻止 WebRTC",
"blockWebGL": "阻止 WebGL",
"navigatorProperties": "Navigator 属性",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent 和平台",
"platform": "平台",
"platformVersion": "平台版本",
"appVersion": "应用版本",
"osCpu": "OS CPU",
"hardwareConcurrency": "硬件并发数",
"maxTouchPoints": "最大触摸点数",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "选择 DNT 值",
"dntAllowed": "0(允许跟踪)",
"dntNotAllowed": "1(不允许跟踪)",
"dntUnspecified": "未指定",
"language": "语言",
"primaryLanguage": "主要语言 (navigator.language)",
"languages": "语言列表 (JSON 数组)",
"languageAndLocale": "语言和区域设置",
"screenProperties": "屏幕属性",
"screenWidth": "屏幕宽度",
"screenHeight": "屏幕高度",
"availableWidth": "可用宽度",
"availableHeight": "可用高度",
"colorDepth": "颜色深度",
"pixelDepth": "像素深度",
"devicePixelRatio": "设备像素比",
"windowProperties": "窗口属性",
"outerWidth": "外部宽度",
"outerHeight": "外部高度",
"innerWidth": "内部宽度",
"innerHeight": "内部高度",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "地理位置",
"timezoneAndGeolocation": "时区和地理位置",
"timezoneGeolocationDescription": "这些值会覆盖浏览器的时区和地理位置 API。",
"latitude": "纬度",
"longitude": "经度",
"timezone": "时区",
"timezoneIana": "时区 (IANA)",
"timezoneOffset": "偏移量(距 UTC 分钟数)",
"accuracy": "精度(米)",
"locale": "区域设置",
"region": "地区",
"script": "脚本",
"webglProperties": "WebGL 属性",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "WebGL 参数",
"webglParametersJson": "WebGL 参数 (JSON)",
"webgl2Parameters": "WebGL2 参数",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas 指纹",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "此种子用于生成一致但唯一的 Canvas 指纹。每个配置文件应使用不同的种子。",
"fonts": "字体",
"fontsJson": "字体 (JSON 数组)",
"battery": "电池",
"charging": "充电中",
"chargingTime": "充电时间",
"dischargingTime": "放电时间",
"batteryLevel": "电量 (0-1)",
"screenResolution": "屏幕分辨率",
"maxWidth": "最大宽度",
"maxHeight": "最大高度",
"minWidth": "最小宽度",
"minHeight": "最小高度",
"hardwareProperties": "硬件属性",
"deviceMemory": "设备内存 (GB)",
"audioProperties": "音频属性",
"sampleRate": "采样率",
"maxChannelCount": "最大通道数",
"vendorInfo": "供应商信息",
"vendor": "供应商",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "品牌",
"brandVersion": "品牌版本",
"proFeature": "这是 Pro 功能"
},
"warnings": {
"windowResizeTitle": "自定义窗口尺寸",
"windowResizeDescription": "更改浏览器窗口尺寸可能会增加网站检测到浏览器信息被伪装的概率。",
"windowResizeCamoufoxTitle": "视口已被 Camoufox 锁定",
"windowResizeCamoufoxDescription": "Camoufox 将视口锁定为伪装的屏幕尺寸以防止指纹识别。调整窗口大小可能会导致内容被裁剪或出现灰色区域。这是预期行为。",
"dontShowAgain": "不再显示",
"continue": "继续",
"cancel": "取消"
},
"syncAll": {
"title": "为现有项目启用同步",
@@ -503,5 +654,118 @@
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
"cannotLaunch": "此配置文件在 {{os}} 上创建,不受此系统支持",
"cannotModify": "无法修改跨操作系统配置文件的同步设置"
},
"cookies": {
"management": {
"title": "Cookie 管理",
"menuItem": "Cookie 管理"
},
"import": {
"title": "导入 Cookies",
"description": "从 Netscape 或 JSON 格式文件导入 Cookies。",
"selectFile": "选择文件",
"preview": "找到 {{count}} 个 Cookies",
"success": "成功导入 {{imported}} 个 Cookies(替换了 {{replaced}} 个)",
"error": "导入 Cookies 失败",
"proFeature": "导入 Cookies 是 Pro 功能"
},
"export": {
"title": "导出 Cookies",
"description": "从此配置文件导出 Cookies。",
"formatLabel": "格式",
"netscape": "Netscape TXT",
"json": "JSON",
"success": "Cookies 导出成功",
"error": "导出 Cookies 失败"
}
},
"profileInfo": {
"title": "配置文件详情",
"tabs": {
"info": "信息",
"settings": "设置"
},
"fields": {
"profileId": "配置文件 ID",
"browser": "浏览器",
"releaseType": "发布类型",
"proxyVpn": "代理 / VPN",
"group": "分组",
"tags": "标签",
"note": "备注",
"syncStatus": "同步状态",
"lastLaunched": "上次启动",
"hostOs": "主机操作系统",
"ephemeral": "临时",
"extensionGroup": "扩展程序组"
},
"values": {
"none": "无",
"never": "从未",
"copied": "已复制!",
"yes": "是"
},
"network": {
"bypassRules": "代理绕过规则",
"bypassRulesDescription": "匹配这些规则的请求将直接连接,绕过代理。",
"addRule": "添加规则",
"rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local",
"noRules": "未配置绕过规则。",
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
},
"actions": {
"manageCookies": "管理 Cookie"
},
"clone": {
"title": "克隆配置文件",
"description": "输入克隆配置文件的名称",
"namePlaceholder": "配置文件名称",
"button": "克隆"
}
},
"extensions": {
"title": "扩展程序",
"description": "管理配置文件的浏览器扩展程序和扩展程序组。",
"upload": "上传",
"delete": "删除",
"extensionsTab": "扩展程序",
"groupsTab": "分组",
"managedNotice": "此处管理的扩展程序将在启动时替换配置文件中手动安装的所有扩展程序。",
"proRequired": "扩展程序管理是 Pro 功能",
"empty": "尚未上传任何扩展程序。",
"noGroups": "尚未创建任何扩展程序组。",
"createGroup": "创建分组",
"addToGroup": "添加扩展程序...",
"removeFromGroup": "从分组中移除",
"deleteGroup": "删除分组",
"extensionGroup": "扩展程序组",
"compatibility": {
"label": "兼容性",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium 和 Firefox"
},
"selectedFile": "已选文件",
"namePlaceholder": "扩展程序名称",
"groupNamePlaceholder": "分组名称",
"uploadSuccess": "扩展程序上传成功",
"deleteSuccess": "扩展程序删除成功",
"groupCreateSuccess": "扩展程序组创建成功",
"groupUpdateSuccess": "扩展程序组更新成功",
"groupDeleteSuccess": "扩展程序组删除成功",
"deleteConfirmTitle": "删除扩展程序",
"deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。",
"deleteGroupConfirmTitle": "删除扩展程序组",
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
"readError": "读取扩展程序文件失败。"
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "指纹编辑是 Pro 功能",
"cookieCopyLocked": "Cookie 复制是 Pro 功能",
"cookieImportLocked": "Cookie 导入是 Pro 功能",
"cookieExportLocked": "Cookie 导出是 Pro 功能",
"cookieManagementLocked": "Cookie 管理是 Pro 功能"
}
}

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