Compare commits

...

10 Commits

Author SHA1 Message Date
zhom 35a874ead0 chore: version bump 2026-05-12 20:52:10 +04:00
zhom f02397dba9 refactor: creation button disaster recovery 2026-05-12 20:50:29 +04:00
github-actions[bot] d5752633c8 chore: update flake.nix for v0.24.0 [skip ci] (#357)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:02:08 +00:00
github-actions[bot] 5752260018 docs: update CHANGELOG.md and README.md for v0.24.0 [skip ci] (#356)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:01:52 +00:00
zhom 405d7c5716 fix: pass correct parameter for dns list selection 2026-05-12 13:17:29 +04:00
zhom 7d9bed2114 chore: version bump 2026-05-12 13:04:51 +04:00
zhom 2633e2ba09 refactor: better error handling and prevention of creating ephemeral password protected profiles 2026-05-12 13:03:34 +04:00
zhom 06b5a41b37 feat: support latest camoufox 2026-05-12 02:19:59 +04:00
zhom bb5f4ea166 chore: update dependencies 2026-05-12 01:35:28 +04:00
zhom 9c1cb011a5 refactor: ui cleanup 2026-05-12 01:22:04 +04:00
43 changed files with 26365 additions and 3343 deletions
+17 -4
View File
@@ -41,15 +41,28 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
# `head_branch` of a workflow_run trigger is attacker-influenceable
# (anyone with push to a tag can choose its name), so we pass it via
# env and validate before use rather than splicing it into the
# shell script literally. See CodeQL actions/code-injection.
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
REPO: ${{ github.repository }}
run: |
if [[ -n "${INPUT_TAG:-}" ]]; then
TAG="${INPUT_TAG}"
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
elif [[ "${EVENT_NAME}" == "workflow_run" ]]; then
# The Release workflow runs on `push: tags: v*` so head_branch
# of the triggering run is the tag name.
TAG="${{ github.event.workflow_run.head_branch }}"
# of the triggering run is the tag name. Reject anything that
# isn't a plain tag-shaped string to keep this resistant to
# shell metacharacters injected via a crafted ref name.
if [[ ! "${WORKFLOW_RUN_HEAD_BRANCH}" =~ ^[A-Za-z0-9._/-]+$ ]]; then
echo "::error::Refusing tag with unexpected characters: ${WORKFLOW_RUN_HEAD_BRANCH}"
exit 1
fi
TAG="${WORKFLOW_RUN_HEAD_BRANCH}"
else
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName)
TAG=$(gh release view --repo "${REPO}" --json tagName -q .tagName)
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved tag: ${TAG}"
+27
View File
@@ -1,6 +1,33 @@
# Changelog
## v0.24.0 (2026-05-12)
### Features
- support latest camoufox
- full ui refresh
### Bug Fixes
- pass correct parameter for dns list selection
### Refactoring
- better error handling and prevention of creating ephemeral password protected profiles
- ui cleanup
- sync cleanup
- proxy spawn
### Maintenance
- chore: version bump
- chore: update dependencies
- chore: fix telegram notifications
- chore: fix issue validation
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
## v0.23.0 (2026-05-10)
### Features
+5 -5
View File
@@ -48,7 +48,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64.dmg) |
Or install via Homebrew:
@@ -58,15 +58,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut-0.23.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut-0.23.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut-0.24.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut-0.24.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+12 -12
View File
@@ -18,33 +18,33 @@
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1024.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0",
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.18",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.2",
"@types/node": "^25.7.0",
"@types/supertest": "^7.2.0",
"jest": "^30.3.0",
"jest": "^30.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.2"
"typescript": "^6.0.3"
},
"jest": {
"moduleFileExtensions": [
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.23.0";
releaseVersion = "0.24.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.AppImage";
hash = "sha256-bcdZOV1Vj7H9BxlYKUUtGZprrA80283J34xb3NslRjg=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.AppImage";
hash = "sha256-tidp6JvFPCbsPzZldeG4697dzQjhYv83DouzgxS+lKY=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.AppImage";
hash = "sha256-IbGvqHMxwYHFj6dFP07MhFl00aiHVont+KoZck+HIvk=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.AppImage";
hash = "sha256-9kHwDafQ+UsKeOeJ+7DbXGGeugogn+NjnhUBYxUeUUo=";
}
else
null;
+19 -20
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.23.0",
"version": "0.24.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -48,47 +48,46 @@
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "~2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "~2.5.0",
"@tauri-apps/plugin-deep-link": "^2.4.9",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "~2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^26.0.3",
"lucide-react": "^1.7.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.3",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-icons": "^5.6.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "~2.11.0",
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1",
"@types/color": "^4.2.1",
"@types/node": "^25.5.2",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"tailwindcss": "^4.2.2",
"lint-staged": "^17.0.4",
"tailwindcss": "^4.3.0",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2"
"typescript": "~6.0.3"
},
"pnpm": {
"overrides": {
+2263 -3012
View File
File diff suppressed because it is too large Load Diff
+133 -106
View File
@@ -594,9 +594,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
@@ -764,6 +764,15 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -890,7 +899,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cairo-sys-rs",
"glib",
"libc",
@@ -1041,7 +1050,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.0",
"rand_core 0.10.1",
]
[[package]]
@@ -1268,7 +1277,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
@@ -1281,7 +1290,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"core-foundation 0.10.1",
"libc",
]
@@ -1664,9 +1673,9 @@ dependencies = [
[[package]]
name = "digest"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
@@ -1709,7 +1718,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -1775,7 +1784,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.23.0"
version = "0.24.1"
dependencies = [
"aes 0.9.0",
"aes-gcm",
@@ -2591,7 +2600,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
@@ -2654,7 +2663,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"futures-channel",
"futures-core",
"futures-executor",
@@ -2789,7 +2798,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -2849,6 +2858,12 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.11.0"
@@ -2973,9 +2988,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.11"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
@@ -3056,7 +3071,7 @@ dependencies = [
"tower-layer",
"tower-service",
"tracing",
"windows-registry",
"windows-registry 0.6.1",
]
[[package]]
@@ -3267,12 +3282,12 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.13.0"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
@@ -3493,9 +3508,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.94"
version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
dependencies = [
"cfg-if",
"futures-util",
@@ -3531,7 +3546,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"serde",
"unicode-segmentation",
]
@@ -3941,7 +3956,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -3967,11 +3982,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.2"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -4123,7 +4138,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -4144,7 +4159,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
]
@@ -4155,7 +4170,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
]
@@ -4166,7 +4181,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"dispatch2",
"objc2",
]
@@ -4177,7 +4192,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -4210,7 +4225,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -4222,7 +4237,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -4250,7 +4265,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -4273,7 +4288,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
]
@@ -4295,7 +4310,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -4307,7 +4322,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -4338,7 +4353,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"block2",
"objc2",
"objc2-app-kit",
@@ -4382,7 +4397,7 @@ version = "0.10.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
@@ -4562,7 +4577,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.13.0",
"indexmap 2.14.0",
]
[[package]]
@@ -4725,7 +4740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"indexmap 2.14.0",
"quick-xml",
"serde",
"time",
@@ -4750,7 +4765,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -4875,7 +4890,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.10+spec-1.1.0",
"toml_edit 0.25.11+spec-1.1.0",
]
[[package]]
@@ -5059,7 +5074,7 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20 0.10.0",
"getrandom 0.4.2",
"rand_core 0.10.0",
"rand_core 0.10.1",
]
[[package]]
@@ -5102,9 +5117,9 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rav1e"
@@ -5188,7 +5203,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
]
[[package]]
@@ -5487,7 +5502,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -5544,7 +5559,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
@@ -5596,7 +5611,7 @@ version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"bytemuck",
"core_maths",
"log",
@@ -5727,7 +5742,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -5750,7 +5765,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cssparser",
"derive_more",
"log",
@@ -5903,15 +5918,16 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.19.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.13.0",
"indexmap 2.14.0",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
@@ -5922,9 +5938,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.19.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
dependencies = [
"darling",
"proc-macro2",
@@ -5938,7 +5954,7 @@ version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
@@ -6032,7 +6048,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.2",
"digest 0.11.3",
]
[[package]]
@@ -6432,9 +6448,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.39.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd9f9fe3d2b7b75cf4f2805e5b9926e8ac47146667b16b86298c4a8bf08cc469"
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
dependencies = [
"libc",
"memchr",
@@ -6451,7 +6467,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -6485,7 +6501,7 @@ version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"block2",
"core-foundation 0.10.1",
"core-graphics",
@@ -6714,7 +6730,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry",
"windows-registry 0.5.3",
"windows-result 0.3.4",
]
@@ -7247,7 +7263,7 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.14.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
@@ -7262,13 +7278,13 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.14.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.1",
"winnow 1.0.2",
]
[[package]]
@@ -7304,7 +7320,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.14.0",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
@@ -7315,7 +7331,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.14.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
@@ -7324,14 +7340,14 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.10+spec-1.1.0"
version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.1",
"winnow 1.0.2",
]
[[package]]
@@ -7340,7 +7356,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.1",
"winnow 1.0.2",
]
[[package]]
@@ -7371,7 +7387,7 @@ version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"bytes",
"futures-core",
"futures-util",
@@ -7775,7 +7791,7 @@ version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.14.0",
"serde",
"serde_json",
"utoipa-gen",
@@ -7918,9 +7934,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.117"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"once_cell",
@@ -7931,9 +7947,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.67"
version = "0.4.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -7941,9 +7957,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.117"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -7951,9 +7967,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.117"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -7964,9 +7980,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.117"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
@@ -7988,7 +8004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.13.0",
"indexmap 2.14.0",
"wasm-encoder",
"wasmparser",
]
@@ -8012,9 +8028,9 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap 2.13.0",
"indexmap 2.14.0",
"semver",
]
@@ -8037,7 +8053,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"rustix",
"wayland-backend",
"wayland-scanner",
@@ -8049,7 +8065,7 @@ version = "0.32.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@@ -8061,7 +8077,7 @@ version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -8090,9 +8106,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.94"
version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -8398,6 +8414,17 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -8709,9 +8736,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [
"memchr",
]
@@ -8793,7 +8820,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck 0.5.0",
"indexmap 2.13.0",
"indexmap 2.14.0",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
@@ -8823,8 +8850,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"indexmap 2.13.0",
"bitflags 2.11.1",
"indexmap 2.14.0",
"log",
"serde",
"serde_derive",
@@ -8843,7 +8870,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.13.0",
"indexmap 2.14.0",
"log",
"semver",
"serde",
@@ -9063,7 +9090,7 @@ dependencies = [
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.1",
"winnow 1.0.2",
"zbus_macros",
"zbus_names",
"zvariant",
@@ -9091,7 +9118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow 1.0.1",
"winnow 1.0.2",
"zvariant",
]
@@ -9206,7 +9233,7 @@ dependencies = [
"flate2",
"getrandom 0.3.4",
"hmac",
"indexmap 2.13.0",
"indexmap 2.14.0",
"lzma-rs",
"memchr",
"pbkdf2",
@@ -9227,7 +9254,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.13.0",
"indexmap 2.14.0",
"memchr",
"typed-path",
]
@@ -9311,7 +9338,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.1",
"winnow 1.0.2",
"zvariant_derive",
"zvariant_utils",
]
@@ -9339,5 +9366,5 @@ dependencies = [
"quote",
"serde",
"syn 2.0.117",
"winnow 1.0.1",
"winnow 1.0.2",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.23.0"
version = "0.24.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+1
View File
@@ -702,6 +702,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
}
}
+1
View File
@@ -1219,6 +1219,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+108 -6
View File
@@ -7,10 +7,78 @@ use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System;
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
/// low-traffic window for the average user; everyone shares the same UTC
/// instant so the value here doesn't track any one user's local schedule.
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
/// File name of the per-profile marker recording the last fingerprint
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
/// and is excluded from cloud sync (see `sync::manifest`) so each device
/// runs its own refresh schedule.
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
/// Most recent rollover instant on or before `now` — used as a staleness
/// threshold for Wayfern fingerprints. Anything generated before this
/// timestamp is considered stale and gets regenerated on next launch.
fn most_recent_rollover_epoch() -> u64 {
let now = Utc::now();
let today_threshold = Utc
.with_ymd_and_hms(
now.year(),
now.month(),
now.day(),
FINGERPRINT_ROLLOVER_HOUR_UTC,
0,
0,
)
.single()
.unwrap_or(now);
let threshold = if now >= today_threshold {
today_threshold
} else {
today_threshold - chrono::Duration::days(1)
};
threshold.timestamp().max(0) as u64
}
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
}
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
/// Returns `None` if the file doesn't exist or its content can't be parsed —
/// both signal "needs a refresh" to the caller.
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
let path = last_fp_refresh_path(profile_id, profiles_dir);
let content = std::fs::read_to_string(&path).ok()?;
content.trim().parse::<u64>().ok()
}
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
/// this profile. Failure is logged but never propagated — a missing marker
/// only costs an extra regen on the next launch, never blocks one.
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
let path = last_fp_refresh_path(profile_id, profiles_dir);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
return;
}
}
}
if let Err(e) = std::fs::write(&path, ts.to_string()) {
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
}
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -544,12 +612,32 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Check if we need to generate a new fingerprint on every launch
// Decide whether to (re)generate the Wayfern fingerprint for this
// launch. Two triggers:
//
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
// randomization the user opted into.
// 2. The fingerprint hasn't been refreshed since the most recent
// rollover instant. We check the per-profile marker file first
// (`.last-fp-refresh`); if it's absent we fall back to
// `profile.created_at` so brand-new profiles don't immediately
// regenerate the fingerprint they were just created with.
// Profiles with neither (truly legacy) are treated as ancient
// and refresh on next launch — once.
let mut updated_profile = profile.clone();
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
let stale_threshold = most_recent_rollover_epoch();
let profile_id_str = profile.id.to_string();
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
let effective_last_refresh =
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
if randomize_every_launch || is_stale_profile {
log::info!(
"Generating random fingerprint for Wayfern profile: {}",
profile.name
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
profile.name,
randomize_every_launch,
is_stale_profile
);
// Create a config copy without the existing fingerprint to force generation of a new one
@@ -571,10 +659,24 @@ impl BrowserRunner {
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
// Save the updated fingerprint to the profile so it persists
// Write the marker so the next launch within the same rollover
// window skips this branch. The marker is excluded from cloud
// sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each
// device's refresh schedule is independent.
let now_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(stale_threshold);
write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch);
// Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint);
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
// Preserve the user's randomize-on-launch preference rather than
// forcing it on. The rollover path must not silently flip this
// flag for users who only opted into the scheduled refresh.
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone();
}
+57 -11
View File
@@ -12,6 +12,7 @@ use crate::camoufox::env_vars;
use crate::camoufox::fingerprint::types::*;
use crate::camoufox::fonts;
use crate::camoufox::geolocation;
use crate::camoufox::presets;
use crate::camoufox::webgl;
/// Browserforge mapping from YAML.
@@ -307,10 +308,59 @@ impl CamoufoxConfigBuilder {
}
/// Build the complete Camoufox launch configuration.
///
/// Prefers a real-fingerprint preset (matched against the Camoufox build's
/// Firefox version via `presets::preset_line_for`) when no explicit
/// fingerprint was passed. Falls back to the Bayesian network-based
/// synthesizer when presets are unavailable, so callers without a known
/// Firefox version (or with no preset for the requested OS) still get a
/// valid config — matching pre-v150 behaviour byte-for-byte.
pub fn build(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
// Generate or use provided fingerprint
let fingerprint = if let Some(fp) = self.fingerprint {
fp
let mut rng = rand::rng();
let ff_version = self.ff_version;
// 1) The caller supplied a fingerprint outright — honour it and skip
// presets entirely. This is the path tests and advanced consumers
// use to inject deterministic fixtures.
// 2) Otherwise, try a bundled preset for the requested OS / FF line.
// 3) Fall back to the Bayesian generator. This is also the path that
// runs for users whose Camoufox binary has no readable `version.json`
// (`ff_version == None`), or whose OS has no presets bundled.
let (mut config, target_os) = if let Some(fp) = self.fingerprint {
let target_os = env_vars::determine_ua_os(&fp.navigator.user_agent);
// `from_browserforge` already runs `handle_screen_xy` internally.
let config = from_browserforge(&fp, ff_version);
(config, target_os)
} else if let Some(preset) =
presets::get_random_preset(self.operating_system.as_deref(), ff_version)
{
let mut config = presets::from_preset(&preset, ff_version);
let target_os = config
.get("navigator.userAgent")
.and_then(|v| v.as_str())
.map(env_vars::determine_ua_os)
.or_else(|| {
// Last-resort heuristic from the platform string — keeps target_os
// sensible even if a preset somehow omits the user agent.
config
.get("navigator.platform")
.and_then(|v| v.as_str())
.map(|p| match p {
"Win32" => "windows",
"MacIntel" => "macos",
_ => "linux",
})
})
.unwrap_or("macos");
// Presets don't carry multi-monitor offsets, so default screenX/Y to
// (0, 0) — matches what real single-display users send.
config
.entry("window.screenX".to_string())
.or_insert(serde_json::json!(0));
config
.entry("window.screenY".to_string())
.or_insert(serde_json::json!(0));
(config, target_os)
} else {
let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?;
let options = FingerprintOptions {
@@ -320,17 +370,13 @@ impl CamoufoxConfigBuilder {
screen: self.screen_constraints,
..Default::default()
};
generator.get_fingerprint(&options)?.fingerprint
let fingerprint = generator.get_fingerprint(&options)?.fingerprint;
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
let config = from_browserforge(&fingerprint, ff_version);
(config, target_os)
};
// Determine target OS from user agent
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
// Convert fingerprint to config
let mut config = from_browserforge(&fingerprint, self.ff_version);
// Add random window history length
let mut rng = rand::rng();
config.insert(
"window.history.length".to_string(),
serde_json::json!(rng.random_range(1..=5)),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -7,3 +7,21 @@ pub const FONTS_JSON: &str = include_str!("fonts.json");
pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml");
pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db");
pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml");
/// Real fingerprint presets bundled with the original Camoufox v135 line
/// (Firefox <= 148). Frozen upstream — kept around so users who haven't
/// upgraded their Camoufox binary keep getting matched fingerprints.
/// Mirrors `pythonlib/camoufox/fingerprint-presets.json` upstream.
pub const FINGERPRINT_PRESETS_V135_JSON: &str = include_str!("fingerprint-presets-v135.json");
/// Real fingerprint presets for every Camoufox release after the v135 line
/// (currently Firefox 149+ via the v150 build). This file is expected to
/// be refreshed regularly as upstream Camoufox tracks newer Firefox
/// releases — we keep the upstream filename here so each refresh is a
/// straight `cp` from `pythonlib/camoufox/fingerprint-presets-v150.json`.
pub const FINGERPRINT_PRESETS_NEWER_JSON: &str = include_str!("fingerprint-presets-v150.json");
/// Firefox major version at which the newer preset bundle takes over from
/// the frozen v135 bundle. Matches `PRESETS_V150_MIN_FF` in
/// `pythonlib/camoufox/fingerprints.py`.
pub const PRESETS_NEWER_MIN_FF: u32 = 149;
+1
View File
@@ -43,6 +43,7 @@ pub mod fingerprint;
pub mod fonts;
pub mod geolocation;
pub mod launcher;
pub mod presets;
pub mod webgl;
// Re-export main types for convenience
+405
View File
@@ -0,0 +1,405 @@
//! Real-fingerprint preset support for Camoufox.
//!
//! Mirrors the preset-selection logic from
//! `pythonlib/camoufox/fingerprints.py` (`_select_presets_file`,
//! `load_presets`, `get_random_preset`, `from_preset`).
//!
//! Camoufox ships two bundled preset files:
//! - `fingerprint-presets-v135.json` — real fingerprints harvested from
//! browsers running Firefox ≤148. The frozen "v135 line" — kept around
//! so users who haven't upgraded their Camoufox binary keep getting
//! consistent fingerprints.
//! - `fingerprint-presets-v150.json` — the *newer* bundle, refreshed by
//! upstream as Camoufox tracks newer Firefox versions. This is the
//! bundle every newer Camoufox release uses; we make no assumption that
//! Firefox 150 is the ceiling.
//!
//! At launch we know the bundled Firefox version (see
//! `config::get_firefox_version`) and pick `v135` or `newer` accordingly.
//! The split point lives in `data::PRESETS_NEWER_MIN_FF` (currently 149)
//! and is the only number we hard-code — anything ≥ that gets the newer
//! bundle, regardless of how far Firefox itself has moved on.
//!
//! Falling back to Bayesian-network synthesis (the previous default) is
//! still possible when no preset matches the requested OS.
use rand::prelude::IndexedRandom;
use regex_lite::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::camoufox::data;
#[derive(Debug, Clone, Deserialize)]
pub struct Navigator {
#[serde(rename = "userAgent")]
pub user_agent: Option<String>,
pub platform: Option<String>,
#[serde(rename = "hardwareConcurrency")]
pub hardware_concurrency: Option<u32>,
#[serde(rename = "maxTouchPoints")]
pub max_touch_points: Option<u32>,
pub oscpu: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Screen {
pub width: Option<u32>,
pub height: Option<u32>,
#[serde(rename = "colorDepth")]
pub color_depth: Option<u32>,
#[serde(rename = "availWidth")]
pub avail_width: Option<u32>,
#[serde(rename = "availHeight")]
pub avail_height: Option<u32>,
#[serde(rename = "devicePixelRatio")]
pub device_pixel_ratio: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebGl {
#[serde(rename = "unmaskedVendor")]
pub unmasked_vendor: Option<String>,
#[serde(rename = "unmaskedRenderer")]
pub unmasked_renderer: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Preset {
#[serde(default)]
pub navigator: Option<Navigator>,
#[serde(default)]
pub screen: Option<Screen>,
#[serde(default)]
pub webgl: Option<WebGl>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub fonts: Option<Vec<String>>,
#[serde(rename = "speechVoices", default)]
pub speech_voices: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PresetBundle {
/// Bundle schema version — upstream writes this as a JSON integer (e.g.
/// `1`), so we accept any JSON shape here and ignore it. Only the
/// `presets` map matters at runtime.
#[allow(dead_code)]
#[serde(default)]
pub version: Option<serde_json::Value>,
#[serde(default)]
pub presets: HashMap<String, Vec<Preset>>,
}
/// Which Camoufox release line the active binary belongs to. Determines
/// which preset bundle to load. The set is intentionally just two-valued:
/// the legacy v135 line and "everything newer" — upstream refreshes the
/// newer bundle as Firefox versions advance, but our routing logic stays
/// the same.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PresetLine {
V135,
Newer,
}
/// Pick the preset line that matches a Firefox major version, mirroring
/// `_select_presets_file` in the Python lib. Unknown / very old versions
/// fall back to the v135 bundle so the older Camoufox builds keep working.
pub fn preset_line_for(ff_version: Option<u32>) -> PresetLine {
match ff_version {
Some(v) if v >= data::PRESETS_NEWER_MIN_FF => PresetLine::Newer,
_ => PresetLine::V135,
}
}
/// Cache the parsed bundles forever — they're static, embedded data and
/// parsing the newer file twice would waste a few megs of CPU work on
/// every launch.
static V135_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
static NEWER_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
fn parse_bundle(json: &str) -> Option<PresetBundle> {
match serde_json::from_str::<PresetBundle>(json) {
Ok(b) => Some(b),
Err(e) => {
log::warn!("camoufox preset bundle failed to parse: {e}");
None
}
}
}
pub fn load_presets(line: PresetLine) -> Option<&'static PresetBundle> {
let slot = match line {
PresetLine::V135 => &V135_BUNDLE,
PresetLine::Newer => &NEWER_BUNDLE,
};
slot
.get_or_init(|| match line {
PresetLine::V135 => parse_bundle(data::FINGERPRINT_PRESETS_V135_JSON),
PresetLine::Newer => parse_bundle(data::FINGERPRINT_PRESETS_NEWER_JSON),
})
.as_ref()
}
/// Normalize the OS string the rest of the codebase uses ("macos", "windows",
/// "linux") to the preset key. Returns `None` for OSes that don't have any
/// presets bundled.
fn normalize_os(os: &str) -> Option<&'static str> {
match os {
"windows" | "win" => Some("windows"),
"macos" | "mac" | "darwin" => Some("macos"),
"linux" | "lin" => Some("linux"),
_ => None,
}
}
/// Pick a random preset for the requested OS. `None` if there are no
/// presets bundled for that OS (which can happen in tests with reduced
/// fixtures, or if a new OS is added before its preset bundle ships).
pub fn get_random_preset(os: Option<&str>, ff_version: Option<u32>) -> Option<Preset> {
let bundle = load_presets(preset_line_for(ff_version))?;
let candidates: Vec<&Preset> = match os.and_then(normalize_os) {
Some(os_key) => bundle.presets.get(os_key).map(|v| v.iter().collect())?,
None => bundle.presets.values().flatten().collect(),
};
if candidates.is_empty() {
return None;
}
candidates.choose(&mut rand::rng()).map(|p| (*p).clone())
}
/// Match python's `from_preset` — translate a real-fingerprint preset into
/// the CAMOU_CONFIG-style HashMap the rest of the launcher expects.
///
/// The caller is responsible for filling in fonts, voices, and the random
/// seeds; those are intentionally left out here so each call site can layer
/// its own RNG and font policy.
pub fn from_preset(preset: &Preset, ff_version: Option<u32>) -> HashMap<String, serde_json::Value> {
let mut config: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(nav) = &preset.navigator {
if let Some(ua) = &nav.user_agent {
let ua = if let Some(v) = ff_version {
rewrite_ua_firefox_version(ua, v)
} else {
ua.clone()
};
config.insert("navigator.userAgent".to_string(), serde_json::json!(ua));
}
if let Some(p) = &nav.platform {
config.insert("navigator.platform".to_string(), serde_json::json!(p));
}
if let Some(hc) = nav.hardware_concurrency {
config.insert(
"navigator.hardwareConcurrency".to_string(),
serde_json::json!(hc),
);
}
if let Some(mtp) = nav.max_touch_points {
config.insert(
"navigator.maxTouchPoints".to_string(),
serde_json::json!(mtp),
);
}
// navigator.oscpu — explicit, or derived from the platform.
let oscpu = nav.oscpu.clone().or_else(|| {
nav.platform.as_deref().and_then(|plat| match plat {
"MacIntel" => Some("Intel Mac OS X 10.15".to_string()),
"Win32" => Some("Windows NT 10.0; Win64; x64".to_string()),
p if p.to_ascii_lowercase().contains("linux") => Some("Linux x86_64".to_string()),
_ => None,
})
});
if let Some(o) = oscpu {
config.insert("navigator.oscpu".to_string(), serde_json::json!(o));
}
}
if let Some(s) = &preset.screen {
if let Some(w) = s.width {
config.insert("screen.width".to_string(), serde_json::json!(w));
}
if let Some(h) = s.height {
config.insert("screen.height".to_string(), serde_json::json!(h));
}
if let Some(cd) = s.color_depth {
config.insert("screen.colorDepth".to_string(), serde_json::json!(cd));
config.insert("screen.pixelDepth".to_string(), serde_json::json!(cd));
}
if let Some(aw) = s.avail_width {
config.insert("screen.availWidth".to_string(), serde_json::json!(aw));
}
if let Some(ah) = s.avail_height {
config.insert("screen.availHeight".to_string(), serde_json::json!(ah));
}
}
if let Some(w) = &preset.webgl {
if let Some(v) = &w.unmasked_vendor {
config.insert("webGl:vendor".to_string(), serde_json::json!(v));
}
if let Some(r) = &w.unmasked_renderer {
config.insert("webGl:renderer".to_string(), serde_json::json!(r));
}
}
if let Some(tz) = &preset.timezone {
config.insert("timezone".to_string(), serde_json::json!(tz));
}
config
}
fn rewrite_ua_firefox_version(ua: &str, version: u32) -> String {
let firefox_re = Regex::new(r"Firefox/\d+\.0").expect("static regex");
let rv_re = Regex::new(r"rv:\d+\.0").expect("static regex");
let first = firefox_re.replace_all(ua, format!("Firefox/{version}.0"));
rv_re
.replace_all(&first, format!("rv:{version}.0"))
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picks_v135_for_old_firefox() {
assert_eq!(preset_line_for(Some(135)), PresetLine::V135);
assert_eq!(preset_line_for(Some(148)), PresetLine::V135);
assert_eq!(preset_line_for(None), PresetLine::V135);
}
#[test]
fn picks_newer_for_anything_past_the_legacy_line() {
// The threshold is data::PRESETS_NEWER_MIN_FF (currently 149).
// Future Firefox versions all share the same bundle — there's
// intentionally no per-version routing past v135.
assert_eq!(preset_line_for(Some(149)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(150)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(160)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(200)), PresetLine::Newer);
}
#[test]
fn both_bundles_parse_and_cover_all_platforms() {
for (line, json) in [
(PresetLine::V135, data::FINGERPRINT_PRESETS_V135_JSON),
(PresetLine::Newer, data::FINGERPRINT_PRESETS_NEWER_JSON),
] {
let bundle: PresetBundle =
serde_json::from_str(json).unwrap_or_else(|e| panic!("bundle {line:?} parse error: {e}"));
for os in ["macos", "windows", "linux"] {
let presets = bundle.presets.get(os).unwrap_or_else(|| {
panic!("bundle {line:?} is missing presets for {os}");
});
assert!(
!presets.is_empty(),
"bundle {line:?} has zero presets for {os}"
);
}
}
}
#[test]
fn random_preset_returns_for_each_os() {
for os in ["macos", "windows", "linux"] {
let preset = get_random_preset(Some(os), Some(150)).expect("preset");
assert!(preset.navigator.is_some(), "navigator present for {os}");
}
}
#[test]
fn from_preset_rewrites_firefox_version() {
let preset = Preset {
navigator: Some(Navigator {
user_agent: Some(
"Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".to_string(),
),
platform: Some("Linux x86_64".to_string()),
hardware_concurrency: Some(8),
max_touch_points: Some(0),
oscpu: None,
}),
screen: None,
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
let config = from_preset(&preset, Some(150));
let ua = config
.get("navigator.userAgent")
.and_then(|v| v.as_str())
.unwrap();
assert!(ua.contains("Firefox/150.0"), "got: {ua}");
assert!(ua.contains("rv:150.0"), "got: {ua}");
// oscpu derived from "Linux x86_64" platform
assert_eq!(
config
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Linux x86_64"
);
}
#[test]
fn from_preset_derives_oscpu_for_mac_and_win() {
let mut preset = Preset {
navigator: Some(Navigator {
user_agent: None,
platform: Some("MacIntel".to_string()),
hardware_concurrency: None,
max_touch_points: None,
oscpu: None,
}),
screen: None,
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
assert_eq!(
from_preset(&preset, None)
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Intel Mac OS X 10.15"
);
preset.navigator.as_mut().unwrap().platform = Some("Win32".to_string());
assert_eq!(
from_preset(&preset, None)
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Windows NT 10.0; Win64; x64"
);
}
#[test]
fn screen_color_depth_fills_both_keys() {
let preset = Preset {
navigator: None,
screen: Some(Screen {
width: Some(1920),
height: Some(1080),
color_depth: Some(24),
avail_width: Some(1920),
avail_height: Some(1050),
device_pixel_ratio: Some(1.0),
}),
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
let config = from_preset(&preset, None);
assert_eq!(config.get("screen.colorDepth").unwrap(), 24);
assert_eq!(config.get("screen.pixelDepth").unwrap(), 24);
assert_eq!(config.get("screen.availWidth").unwrap(), 1920);
}
}
+7 -6
View File
@@ -1215,13 +1215,14 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?;
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
// Always clear the stored sync URL and token on cloud logout. While the
// user was signed in, the cloud auth flow populated these with the hosted
// sync server's URL + a server-issued token — leaving them in place would
// pre-fill the Self-Hosted tab with our production URL and a token the
// user never typed. The cloud-URL-only check we used to do here missed
// trailing-slash / scheme variants and any future cloud endpoint moves.
let manager = crate::settings_manager::SettingsManager::instance();
if let Ok(sync_settings) = manager.get_sync_settings() {
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
let _ = manager.save_sync_server_url(None);
}
}
let _ = manager.save_sync_server_url(None);
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
+1
View File
@@ -280,6 +280,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
}
}
+1
View File
@@ -1161,6 +1161,7 @@ async fn generate_sample_fingerprint(
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
if browser == "camoufox" {
+14
View File
@@ -185,6 +185,7 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -287,6 +288,7 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -343,6 +345,12 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
};
// Save profile info
@@ -989,6 +997,12 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist: source.dns_blocklist,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
};
self.save_profile(&new_profile)?;
+9
View File
@@ -233,6 +233,15 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
return Err(err_code("PROFILE_ALREADY_PROTECTED"));
}
// Ephemeral profiles live in RAM-backed dirs that get wiped on quit, so
// there's no on-disk data to encrypt. The two features are mutually
// exclusive by design — fail loudly rather than silently producing a
// half-broken state where `password_protected` is true but the encrypted
// dir vanishes between launches.
if profile.ephemeral {
return Err(err_code("PROFILE_EPHEMERAL"));
}
if profile
.process_id
.is_some_and(crate::proxy_storage::is_process_running)
+5
View File
@@ -73,6 +73,11 @@ pub struct BrowserProfile {
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)]
pub password_protected: bool,
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
/// profiles that pre-date this field — those are treated as ancient by
/// any staleness check.
#[serde(default)]
pub created_at: Option<u64>,
}
pub fn default_release_type() -> String {
+8
View File
@@ -585,6 +585,7 @@ impl ProfileImporter {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -666,6 +667,7 @@ impl ProfileImporter {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -718,6 +720,12 @@ impl ProfileImporter {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
};
self.profile_manager.save_profile(&profile)?;
+11
View File
@@ -991,6 +991,17 @@ pub async fn save_sync_settings(
sync_server_url: Option<String>,
sync_token: Option<String>,
) -> Result<SyncSettings, String> {
// Cloud login and self-hosted sync share the same sync engine and a
// profile can't be sync'd to two backends at once. Block any *write*
// (non-null URL or token) while the user is signed into their cloud
// account — the clearing path (both `None`) is always allowed so logged-
// in users can wipe a stale self-hosted config that pre-dates their
// sign-in.
let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some();
if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string());
}
let manager = SettingsManager::instance();
manager
+43
View File
@@ -3526,6 +3526,49 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all eligible profiles. Without this the user would see
// groups/proxies/vpns syncing while their profiles stay local-only — the
// long-standing source of issue #352. Encrypted mode wins when an E2E
// password is already configured; otherwise we fall back to plain Regular.
{
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let desired_mode = if encryption::has_e2e_password() {
SyncMode::Encrypted
} else {
SyncMode::Regular
};
let desired_mode_str = match desired_mode {
SyncMode::Encrypted => "Encrypted",
SyncMode::Regular => "Regular",
SyncMode::Disabled => "Disabled",
};
for profile in &profiles {
// Skip profiles that are already syncing (any non-Disabled mode),
// ephemeral profiles (data wipes on quit, sync is meaningless), and
// cross-OS profiles (the OS-specific binary isn't installed locally
// so a sync round-trip would be one-sided).
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
continue;
}
if let Err(e) = set_profile_sync_mode(
app_handle.clone(),
profile.id.to_string(),
desired_mode_str.to_string(),
)
.await
{
log::warn!(
"Failed to enable sync for profile {} ({}): {e}",
profile.name,
profile.id
);
}
}
}
// Enable sync for all unsynced proxies
{
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
+4
View File
@@ -52,6 +52,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*",
"**/.DS_Store",
".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's
// fingerprint. Each device decides its own refresh cadence, so syncing
// this would cause one device's refresh to silence others.
".last-fp-refresh",
];
/// A single file entry in the manifest
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.23.0",
"version": "0.24.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+434 -102
View File
@@ -1,12 +1,28 @@
"use client";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCloud, LuLogOut, LuRefreshCw, LuUser } from "react-icons/lu";
import {
LuCloud,
LuEye,
LuEyeOff,
LuLogOut,
LuRefreshCw,
LuUser,
} from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types";
interface AccountPageProps {
isOpen: boolean;
@@ -15,6 +31,8 @@ interface AccountPageProps {
onOpenSignIn: () => void;
}
type ConnectionStatus = "unknown" | "testing" | "connected" | "error";
export function AccountPage({
isOpen,
onClose,
@@ -22,8 +40,34 @@ export function AccountPage({
onOpenSignIn,
}: AccountPageProps) {
const { t } = useTranslation();
const { user, isLoggedIn, logout, refreshProfile } = useCloudAuth();
const {
user,
isLoggedIn,
isLoading: isCloudLoading,
logout,
refreshProfile,
} = useCloudAuth();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Self-hosted server state. Loaded once when the dialog opens and persisted
// via `save_sync_settings` so the rest of the app picks up the new URL/token
// from `SettingsManager`.
const [serverUrl, setServerUrl] = useState("");
const [token, setToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [isSavingSelfHosted, setIsSavingSelfHosted] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("unknown");
const hasConfig = Boolean(serverUrl && token);
// Self-hosted and cloud are mutually exclusive — both share the same sync
// engine and a profile can't be sync'd to two backends. The tab trigger is
// disabled here AND the backend rejects mixed state (see `save_sync_settings`
// / `cloud_logout`), so even if someone bypasses the UI we don't end up
// with split-brain.
const selfHostedDisabled = isLoggedIn || isCloudLoading;
const handleRefresh = async () => {
setIsRefreshing(true);
@@ -38,119 +82,407 @@ export function AccountPage({
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await logout();
// The backend wipes sync URL + token as part of cloud_logout (see
// `cloud_auth::cloud_logout`); pull the now-empty settings back into
// the form so a user who flips to the Self-hosted tab doesn't see the
// pre-logout production URL still sitting there.
await loadSelfHostedSettings();
showSuccessToast(t("account.loggedOut"));
} catch (e) {
showErrorToast(String(e));
} finally {
setIsLoggingOut(false);
}
};
const loadSelfHostedSettings = useCallback(async () => {
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
setServerUrl(settings.sync_server_url ?? "");
setToken(settings.sync_token ?? "");
setConnectionStatus(
settings.sync_server_url && settings.sync_token ? "unknown" : "unknown",
);
} catch (error) {
console.error("Failed to load sync settings:", error);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadSelfHostedSettings();
}
}, [isOpen, loadSelfHostedSettings]);
const handleTestConnection = useCallback(async () => {
if (!serverUrl) {
showErrorToast(t("sync.config.serverUrlRequired"));
return;
}
setIsTestingConnection(true);
setConnectionStatus("testing");
try {
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
if (response.ok) {
setConnectionStatus("connected");
showSuccessToast(t("sync.config.connectionSuccess"));
} else {
setConnectionStatus("error");
showErrorToast(t("sync.config.serverError"));
}
} catch {
setConnectionStatus("error");
showErrorToast(t("sync.config.connectFailed"));
} finally {
setIsTestingConnection(false);
}
}, [serverUrl, t]);
const handleSaveSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: serverUrl || null,
syncToken: token || null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
showSuccessToast(t("sync.config.settingsSaved"));
} catch (error) {
console.error("Failed to save sync settings:", error);
// Use the structured backend-error translator so the cloud-vs-self-
// hosted mutex (`SELF_HOSTED_REQUIRES_LOGOUT`) shows a clear message
// instead of the generic "save failed" toast.
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsSavingSelfHosted(false);
}
}, [serverUrl, token, t]);
const handleDisconnectSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
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(t("sync.config.disconnected"));
} catch (error) {
console.error("Failed to disconnect:", error);
showErrorToast(t("sync.config.disconnectFailed"));
} finally {
setIsSavingSelfHosted(false);
}
}, [t]);
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="w-6 h-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="text-base font-semibold truncate">
{user.email}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.signedOutDescription")}
</p>
</>
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">{user.plan}</p>
</div>
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="w-3 h-3" />
{t("account.refresh")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="w-3 h-3" />
{t("account.logout")}
</Button>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
<LuCloud className="w-3 h-3" />
{t("account.signIn")}
</Button>
)}
</div>
{t("account.tabs.account")}
</TabsTrigger>
<TabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
selfHostedDisabled
? t("account.selfHosted.disabledWhileLoggedIn")
: undefined
}
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs disabled:opacity-50 disabled:hover:text-muted-foreground",
)}
>
{t("account.tabs.selfHosted")}
</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="w-6 h-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="text-base font-semibold truncate">
{user.email}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">
{user.plan}
</p>
</div>
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="w-3 h-3" />
{t("account.refresh")}
</Button>
<LoadingButton
size="sm"
variant="destructive"
isLoading={isLoggingOut}
disabled={isRefreshing}
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="w-3 h-3" />
{t("account.logout")}
</LoadingButton>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<LuCloud className="w-3 h-3" />
{t("account.signIn")}
</Button>
)}
</div>
</div>
</TabsContent>
<TabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
// but if state flips mid-render (e.g. a cloud login finishes
// while the tab is open), show the explanation instead of
// a silent empty card.
<p className="text-sm text-muted-foreground">
{t("account.selfHosted.disabledWhileLoggedIn")}
</p>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="text-sm font-medium">
{t("account.selfHosted.title")}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.selfHosted.description")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-server-url" className="text-xs">
{t("sync.serverUrl")}
</Label>
<Input
id="self-hosted-server-url"
type="url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-token" className="text-xs">
{t("sync.token")}
</Label>
<div className="relative">
<Input
id="self-hosted-token"
type={showToken ? "text" : "password"}
placeholder={t("sync.tokenPlaceholder")}
value={token}
onChange={(e) => {
setToken(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
className="pr-9"
/>
<button
type="button"
onClick={() => {
setShowToken((v) => !v);
}}
aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<LuEyeOff className="w-3.5 h-3.5" />
) : (
<LuEye className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("account.selfHosted.connectionStatus")}
</span>
{connectionStatus === "connected" && (
<Badge
variant="default"
className="text-success-foreground bg-success"
>
{t("sync.status.connected")}
</Badge>
)}
{connectionStatus === "error" && (
<Badge variant="destructive">
{t("sync.status.error")}
</Badge>
)}
{connectionStatus === "testing" && (
<Badge variant="secondary">
{t("sync.status.syncing")}
</Badge>
)}
{connectionStatus === "unknown" && (
<Badge variant="secondary">
{t("account.selfHosted.statusUnknown")}
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
size="sm"
variant="outline"
isLoading={isTestingConnection}
disabled={!serverUrl || isSavingSelfHosted}
onClick={() => void handleTestConnection()}
className="h-8 text-xs"
>
{t("account.selfHosted.testConnection")}
</LoadingButton>
<LoadingButton
size="sm"
isLoading={isSavingSelfHosted}
disabled={!serverUrl || !token || isTestingConnection}
onClick={() => void handleSaveSelfHosted()}
className="h-8 text-xs"
>
{t("common.buttons.save")}
</LoadingButton>
{hasConfig && (
<Button
size="sm"
variant="destructive"
disabled={isSavingSelfHosted || isTestingConnection}
onClick={() => void handleDisconnectSelfHosted()}
className="h-8 text-xs"
>
{t("account.selfHosted.disconnect")}
</Button>
)}
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
+11 -1
View File
@@ -156,6 +156,8 @@ const HomeHeader = ({
};
}, []);
const isWindows = platform === "windows";
return (
<div
ref={dragRootRef}
@@ -163,7 +165,15 @@ const HomeHeader = ({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
className="flex items-center gap-2 h-11 px-3 border-b border-border bg-card select-none"
className={cn(
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
// Windows: WindowDragArea renders two 44px native-style controls
// (minimize + close) fixed at top-right with z-50, total 88px wide.
// Reserve 100px on the right edge so the "+ New" button and search
// input clear them with a few pixels of breathing room — issues
// #358, #361, #362 all reported the same overlap before this fix.
isWindows ? "pr-[100px]" : "pr-3",
)}
>
{isMacOS && (
<div
+2 -4
View File
@@ -422,7 +422,7 @@ function DnsCell({
try {
await invoke("update_profile_dns_blocklist", {
profileId: profile.id,
level: nextLevel,
dnsBlocklist: nextLevel,
});
} catch (err) {
console.error("Failed to update DNS blocklist:", err);
@@ -1931,9 +1931,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const browser = profile.browser;
const IconComponent = profile.password_protected
? LuLock
: getProfileIcon(profile);
const IconComponent = getProfileIcon(profile);
const isCrossOs = isCrossOsProfile(profile);
const isSelected = meta.isProfileSelected(profile.id);
+1 -14
View File
@@ -5,14 +5,7 @@ import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuShieldCheck,
LuUser,
LuUsers,
} from "react-icons/lu";
import { LuCloud, LuPlug, LuPuzzle, LuUser, LuUsers } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -262,12 +255,6 @@ const MORE_ITEMS: MoreMenuItem[] = [
labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint",
},
{
page: "vpns",
Icon: LuShieldCheck,
labelKey: "rail.more.vpns",
hintKey: "rail.more.vpnsHint",
},
{
page: "integrations",
Icon: LuPlug,
+54 -5
View File
@@ -131,6 +131,7 @@ export function SettingsDialog({
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
const [e2eError, setE2eError] = useState("");
const [isSavingE2e, setIsSavingE2e] = useState(false);
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
const [systemInfo, setSystemInfo] = useState<{
app_version: string;
os: string;
@@ -994,6 +995,7 @@ export function SettingsDialog({
<Button
variant="outline"
size="sm"
disabled={isRemovingE2e}
onClick={() => {
setHasE2ePassword(false);
setE2ePassword("");
@@ -1003,22 +1005,41 @@ export function SettingsDialog({
>
{t("settings.encryption.changePassword")}
</Button>
<Button
<LoadingButton
variant="destructive"
size="sm"
isLoading={isRemovingE2e}
onClick={async () => {
setIsRemovingE2e(true);
try {
// Await the rollover so the user sees an error if
// re-syncing fails. Previously the rollover was
// fire-and-forget (`void invoke(...)`) which left
// half-removed state on screen with no feedback —
// the source of issue #360 "completely bugged".
await invoke("delete_e2e_password");
setHasE2ePassword(false);
try {
await invoke(
"rollover_encryption_for_all_entities",
);
} catch (rolloverErr) {
console.error(
"Rollover after password removal failed:",
rolloverErr,
);
showErrorToast(String(rolloverErr));
}
showSuccessToast(t("settings.encryption.removed"));
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
} finally {
setIsRemovingE2e(false);
}
}}
>
{t("settings.encryption.removePassword")}
</Button>
</LoadingButton>
</div>
</div>
) : (
@@ -1065,10 +1086,22 @@ export function SettingsDialog({
setHasE2ePassword(true);
setE2ePassword("");
setE2ePasswordConfirm("");
try {
// Await rollover so any failure surfaces to the
// user instead of being lost via fire-and-forget.
// Without this, "change password" leaves entities
// half-re-encrypted with no visible error.
await invoke("rollover_encryption_for_all_entities");
} catch (rolloverErr) {
console.error(
"Rollover after password set failed:",
rolloverErr,
);
showErrorToast(String(rolloverErr));
}
showSuccessToast(
t("settings.encryption.passwordSaved"),
);
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
} finally {
@@ -1089,7 +1122,23 @@ export function SettingsDialog({
</Label>
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
{trialStatus?.type === "Active" ? (
{cloudUser != null && cloudUser.plan !== "free" ? (
// Paid Donut plan supersedes the local commercial trial —
// the trial only exists to gate commercial use until the
// user subscribes. Showing "Trial expired" to a paying
// customer reads like a billing error, so swap in a
// subscription-active badge instead.
<div className="space-y-1">
<p className="text-sm font-medium text-success">
{t("settings.commercial.subscriptionActive", {
plan: cloudUser.plan,
})}
</p>
<p className="text-xs text-muted-foreground">
{t("settings.commercial.subscriptionActiveDescription")}
</p>
</div>
) : trialStatus?.type === "Active" ? (
<div className="space-y-1">
<p className="text-sm font-medium">
{t("settings.commercial.trialActive", {
+19 -4
View File
@@ -165,7 +165,9 @@
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
"trialActiveDescription": "Commercial use is free during the trial. When it ends, all features keep working — personal use stays free, only commercial use will require a license.",
"trialExpired": "Trial expired",
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license."
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license.",
"subscriptionActive": "Subscribed — {{plan}} plan",
"subscriptionActiveDescription": "Your Donut Browser subscription is active. Commercial use is licensed for the duration of your plan."
},
"advanced": {
"title": "Advanced",
@@ -1734,6 +1736,7 @@
"profileNotProtected": "Profile is not password protected",
"profileAlreadyProtected": "Profile is already password protected",
"profileRunning": "Cannot perform this action while the profile is running",
"profileEphemeral": "Ephemeral profiles cannot be password-protected — their data wipes on quit.",
"profileMissingSalt": "Profile is missing its encryption salt",
"profileLocked": "Profile is locked. Enter the password first.",
"invalidProfileId": "Invalid profile id",
@@ -1741,7 +1744,8 @@
"internal": "Something went wrong: {{detail}}",
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable."
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server."
},
"rail": {
"profiles": "Profiles",
@@ -1754,8 +1758,6 @@
"closeAriaLabel": "Close menu",
"importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool",
"vpns": "VPN configs",
"vpnsHint": "WireGuard tunnels",
"integrations": "Integrations",
"integrationsHint": "Slack, MCP, automations",
"account": "Account",
@@ -1809,6 +1811,19 @@
"status": "Status",
"teamRole": "Team role",
"period": "Billing period"
},
"tabs": {
"account": "Account",
"selfHosted": "Self-hosted"
},
"selfHosted": {
"title": "Self-hosted sync server",
"description": "Point Donut at your own donut-sync server to sync profiles, proxies, groups, and extensions without using the hosted cloud.",
"disabledWhileLoggedIn": "Self-hosted sync is unavailable while you're signed into your Donut account. Sign out to use a custom server.",
"connectionStatus": "Connection:",
"statusUnknown": "Untested",
"testConnection": "Test connection",
"disconnect": "Disconnect"
}
}
}
+19 -4
View File
@@ -165,7 +165,9 @@
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
"trialActiveDescription": "El uso comercial es gratuito durante la prueba. Al finalizar, todas las funciones siguen funcionando — el uso personal sigue siendo gratuito, solo el uso comercial requerirá una licencia.",
"trialExpired": "Prueba expirada",
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia."
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia.",
"subscriptionActive": "Suscrito — plan {{plan}}",
"subscriptionActiveDescription": "Tu suscripción a Donut Browser está activa. El uso comercial está autorizado mientras tu plan esté vigente."
},
"advanced": {
"title": "Avanzado",
@@ -1734,6 +1736,7 @@
"profileNotProtected": "El perfil no está protegido por contraseña",
"profileAlreadyProtected": "El perfil ya está protegido por contraseña",
"profileRunning": "No se puede realizar esta acción mientras el perfil está en ejecución",
"profileEphemeral": "Los perfiles efímeros no pueden tener contraseña — sus datos se borran al salir.",
"profileMissingSalt": "Al perfil le falta su sal de cifrado",
"profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
"invalidProfileId": "ID de perfil no válido",
@@ -1741,7 +1744,8 @@
"internal": "Algo salió mal: {{detail}}",
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible."
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado."
},
"rail": {
"profiles": "Perfiles",
@@ -1754,8 +1758,6 @@
"closeAriaLabel": "Cerrar menú",
"importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta",
"vpns": "Configuraciones VPN",
"vpnsHint": "Túneles WireGuard",
"integrations": "Integraciones",
"integrationsHint": "Slack, MCP, automatizaciones",
"account": "Cuenta",
@@ -1809,6 +1811,19 @@
"status": "Estado",
"teamRole": "Rol en el equipo",
"period": "Período"
},
"tabs": {
"account": "Cuenta",
"selfHosted": "Autoalojado"
},
"selfHosted": {
"title": "Servidor de sincronización autoalojado",
"description": "Conecta Donut a tu propio servidor donut-sync para sincronizar perfiles, proxies, grupos y extensiones sin usar la nube alojada.",
"disabledWhileLoggedIn": "La sincronización autoalojada no está disponible mientras estás conectado a tu cuenta de Donut. Cierra sesión para usar un servidor personalizado.",
"connectionStatus": "Conexión:",
"statusUnknown": "Sin probar",
"testConnection": "Probar conexión",
"disconnect": "Desconectar"
}
}
}
+19 -4
View File
@@ -165,7 +165,9 @@
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
"trialActiveDescription": "L'utilisation commerciale est gratuite pendant l'essai. À l'expiration, toutes les fonctionnalités continuent de fonctionner — l'utilisation personnelle reste gratuite, seule l'utilisation commerciale nécessitera une licence.",
"trialExpired": "Essai expiré",
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence."
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence.",
"subscriptionActive": "Abonné — formule {{plan}}",
"subscriptionActiveDescription": "Votre abonnement Donut Browser est actif. L'usage commercial est licencié pendant toute la durée de votre formule."
},
"advanced": {
"title": "Avancé",
@@ -1734,6 +1736,7 @@
"profileNotProtected": "Le profil n'est pas protégé par mot de passe",
"profileAlreadyProtected": "Le profil est déjà protégé par mot de passe",
"profileRunning": "Impossible d'effectuer cette action pendant que le profil est en cours d'exécution",
"profileEphemeral": "Les profils éphémères ne peuvent pas être protégés par mot de passe — leurs données s'effacent à la fermeture.",
"profileMissingSalt": "Le sel de chiffrement du profil est manquant",
"profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
"invalidProfileId": "Identifiant de profil non valide",
@@ -1741,7 +1744,8 @@
"internal": "Une erreur s'est produite : {{detail}}",
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible."
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.",
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé."
},
"rail": {
"profiles": "Profils",
@@ -1754,8 +1758,6 @@
"closeAriaLabel": "Fermer le menu",
"importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil",
"vpns": "Configurations VPN",
"vpnsHint": "Tunnels WireGuard",
"integrations": "Intégrations",
"integrationsHint": "Slack, MCP, automatisations",
"account": "Compte",
@@ -1809,6 +1811,19 @@
"status": "Statut",
"teamRole": "Rôle d’équipe",
"period": "Période"
},
"tabs": {
"account": "Compte",
"selfHosted": "Auto-hébergé"
},
"selfHosted": {
"title": "Serveur de synchronisation auto-hébergé",
"description": "Connectez Donut à votre propre serveur donut-sync pour synchroniser profils, proxys, groupes et extensions sans utiliser le cloud hébergé.",
"disabledWhileLoggedIn": "La synchronisation auto-hébergée n'est pas disponible lorsque vous êtes connecté à votre compte Donut. Déconnectez-vous pour utiliser un serveur personnalisé.",
"connectionStatus": "Connexion :",
"statusUnknown": "Non testé",
"testConnection": "Tester la connexion",
"disconnect": "Déconnecter"
}
}
}
+19 -4
View File
@@ -165,7 +165,9 @@
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
"trialActiveDescription": "トライアル期間中は商用利用が無料です。期間が終了してもすべての機能はそのまま使用できます — 個人利用は引き続き無料で、商用利用のみライセンスが必要になります。",
"trialExpired": "トライアル期限切れ",
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。"
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。",
"subscriptionActive": "サブスクリプション中 — {{plan}} プラン",
"subscriptionActiveDescription": "Donut Browser のサブスクリプションが有効です。プランの期間中、商用利用がライセンスされます。"
},
"advanced": {
"title": "詳細設定",
@@ -1734,6 +1736,7 @@
"profileNotProtected": "プロファイルはパスワード保護されていません",
"profileAlreadyProtected": "プロファイルはすでにパスワード保護されています",
"profileRunning": "プロファイルの実行中はこの操作を実行できません",
"profileEphemeral": "エフェメラル プロファイルにはパスワードを設定できません — 終了時にデータが消去されます。",
"profileMissingSalt": "プロファイルに暗号化ソルトがありません",
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
"invalidProfileId": "無効なプロファイルIDです",
@@ -1741,7 +1744,8 @@
"internal": "問題が発生しました: {{detail}}",
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。"
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。"
},
"rail": {
"profiles": "プロファイル",
@@ -1754,8 +1758,6 @@
"closeAriaLabel": "メニューを閉じる",
"importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む",
"vpns": "VPN 設定",
"vpnsHint": "WireGuard トンネル",
"integrations": "連携",
"integrationsHint": "Slack、MCP、自動化",
"account": "アカウント",
@@ -1809,6 +1811,19 @@
"status": "ステータス",
"teamRole": "チームロール",
"period": "請求周期"
},
"tabs": {
"account": "アカウント",
"selfHosted": "セルフホスト"
},
"selfHosted": {
"title": "セルフホスト同期サーバー",
"description": "Donut を独自の donut-sync サーバーに接続して、ホスト型クラウドを使わずにプロファイル、プロキシ、グループ、拡張機能を同期します。",
"disabledWhileLoggedIn": "Donut アカウントにサインインしている間はセルフホスト同期を利用できません。カスタムサーバーを使うにはサインアウトしてください。",
"connectionStatus": "接続:",
"statusUnknown": "未テスト",
"testConnection": "接続をテスト",
"disconnect": "切断"
}
}
}
+19 -4
View File
@@ -165,7 +165,9 @@
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
"trialActiveDescription": "O uso comercial é gratuito durante o teste. Após o término, todos os recursos continuam funcionando — o uso pessoal permanece gratuito, apenas o uso comercial exigirá uma licença.",
"trialExpired": "Teste expirado",
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença."
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença.",
"subscriptionActive": "Assinado — plano {{plan}}",
"subscriptionActiveDescription": "Sua assinatura do Donut Browser está ativa. O uso comercial está licenciado enquanto seu plano estiver vigente."
},
"advanced": {
"title": "Avançado",
@@ -1734,6 +1736,7 @@
"profileNotProtected": "O perfil não está protegido por senha",
"profileAlreadyProtected": "O perfil já está protegido por senha",
"profileRunning": "Não é possível realizar esta ação enquanto o perfil está em execução",
"profileEphemeral": "Perfis efêmeros não podem ser protegidos por senha — seus dados são apagados ao sair.",
"profileMissingSalt": "O perfil está sem o sal de criptografia",
"profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
"invalidProfileId": "ID de perfil inválido",
@@ -1741,7 +1744,8 @@
"internal": "Algo deu errado: {{detail}}",
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível."
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.",
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado."
},
"rail": {
"profiles": "Perfis",
@@ -1754,8 +1758,6 @@
"closeAriaLabel": "Fechar menu",
"importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta",
"vpns": "Configurações VPN",
"vpnsHint": "Túneis WireGuard",
"integrations": "Integrações",
"integrationsHint": "Slack, MCP, automações",
"account": "Conta",
@@ -1809,6 +1811,19 @@
"status": "Status",
"teamRole": "Função na equipe",
"period": "Período"
},
"tabs": {
"account": "Conta",
"selfHosted": "Auto-hospedado"
},
"selfHosted": {
"title": "Servidor de sincronização auto-hospedado",
"description": "Conecte o Donut ao seu próprio servidor donut-sync para sincronizar perfis, proxies, grupos e extensões sem usar a nuvem hospedada.",
"disabledWhileLoggedIn": "A sincronização auto-hospedada não está disponível enquanto você está conectado à sua conta Donut. Saia para usar um servidor personalizado.",
"connectionStatus": "Conexão:",
"statusUnknown": "Não testado",
"testConnection": "Testar conexão",
"disconnect": "Desconectar"
}
}
}
+19 -4
View File
@@ -165,7 +165,9 @@
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода. После его окончания все функции продолжают работать — личное использование остаётся бесплатным, и только для коммерческого использования потребуется лицензия.",
"trialExpired": "Пробный период истёк",
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия."
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия.",
"subscriptionActive": "Подписка активна — план {{plan}}",
"subscriptionActiveDescription": "Ваша подписка на Donut Browser активна. Коммерческое использование лицензировано на срок действия плана."
},
"advanced": {
"title": "Дополнительно",
@@ -1734,6 +1736,7 @@
"profileNotProtected": "Профиль не защищён паролем",
"profileAlreadyProtected": "Профиль уже защищён паролем",
"profileRunning": "Невозможно выполнить это действие, пока профиль запущен",
"profileEphemeral": "Эфемерные профили не могут быть защищены паролем — их данные удаляются при выходе.",
"profileMissingSalt": "У профиля отсутствует соль шифрования",
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
"invalidProfileId": "Недействительный идентификатор профиля",
@@ -1741,7 +1744,8 @@
"internal": "Что-то пошло не так: {{detail}}",
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно."
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер."
},
"rail": {
"profiles": "Профили",
@@ -1754,8 +1758,6 @@
"closeAriaLabel": "Закрыть меню",
"importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента",
"vpns": "Конфигурации VPN",
"vpnsHint": "WireGuard-туннели",
"integrations": "Интеграции",
"integrationsHint": "Slack, MCP, автоматизации",
"account": "Аккаунт",
@@ -1809,6 +1811,19 @@
"status": "Статус",
"teamRole": "Роль в команде",
"period": "Период"
},
"tabs": {
"account": "Аккаунт",
"selfHosted": "Свой сервер"
},
"selfHosted": {
"title": "Свой сервер синхронизации",
"description": "Подключите Donut к собственному серверу donut-sync, чтобы синхронизировать профили, прокси, группы и расширения без использования облака.",
"disabledWhileLoggedIn": "Свой сервер синхронизации недоступен, пока вы вошли в аккаунт Donut. Выйдите из аккаунта, чтобы использовать собственный сервер.",
"connectionStatus": "Соединение:",
"statusUnknown": "Не проверено",
"testConnection": "Проверить соединение",
"disconnect": "Отключить"
}
}
}
+19 -4
View File
@@ -165,7 +165,9 @@
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
"trialActiveDescription": "试用期内商业使用免费。试用期结束后,所有功能继续正常使用 — 个人使用仍然免费,只有商业使用需要许可证。",
"trialExpired": "试用期已过期",
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。"
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。",
"subscriptionActive": "已订阅 — {{plan}} 方案",
"subscriptionActiveDescription": "您的 Donut Browser 订阅已激活。在订阅有效期内允许商业使用。"
},
"advanced": {
"title": "高级",
@@ -1734,6 +1736,7 @@
"profileNotProtected": "配置文件未受密码保护",
"profileAlreadyProtected": "配置文件已受密码保护",
"profileRunning": "配置文件运行时无法执行此操作",
"profileEphemeral": "临时配置文件无法设置密码 — 退出时数据会被清除。",
"profileMissingSalt": "配置文件缺少加密盐",
"profileLocked": "配置文件已锁定。请先输入密码。",
"invalidProfileId": "配置文件 ID 无效",
@@ -1741,7 +1744,8 @@
"internal": "出现问题:{{detail}}",
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。"
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。"
},
"rail": {
"profiles": "配置文件",
@@ -1754,8 +1758,6 @@
"closeAriaLabel": "关闭菜单",
"importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入",
"vpns": "VPN 配置",
"vpnsHint": "WireGuard 隧道",
"integrations": "集成",
"integrationsHint": "Slack、MCP、自动化",
"account": "账户",
@@ -1809,6 +1811,19 @@
"status": "状态",
"teamRole": "团队角色",
"period": "计费周期"
},
"tabs": {
"account": "账户",
"selfHosted": "自托管"
},
"selfHosted": {
"title": "自托管同步服务器",
"description": "将 Donut 连接到您自己的 donut-sync 服务器,无需使用托管云即可同步配置文件、代理、组和扩展程序。",
"disabledWhileLoggedIn": "登录 Donut 账户时无法使用自托管同步。请先退出登录以使用自定义服务器。",
"connectionStatus": "连接:",
"statusUnknown": "未测试",
"testConnection": "测试连接",
"disconnect": "断开连接"
}
}
}
+6
View File
@@ -11,6 +11,7 @@ export type BackendErrorCode =
| "PROFILE_NOT_PROTECTED"
| "PROFILE_ALREADY_PROTECTED"
| "PROFILE_RUNNING"
| "PROFILE_EPHEMERAL"
| "PROFILE_MISSING_SALT"
| "PROFILE_LOCKED"
| "INVALID_PROFILE_ID"
@@ -18,6 +19,7 @@ export type BackendErrorCode =
| "INVALID_LAUNCH_HOOK_URL"
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "SELF_HOSTED_REQUIRES_LOGOUT"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -74,6 +76,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.profileAlreadyProtected");
case "PROFILE_RUNNING":
return t("backendErrors.profileRunning");
case "PROFILE_EPHEMERAL":
return t("backendErrors.profileEphemeral");
case "PROFILE_MISSING_SALT":
return t("backendErrors.profileMissingSalt");
case "PROFILE_LOCKED":
@@ -90,6 +94,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.cookieDbLocked");
case "COOKIE_DB_UNAVAILABLE":
return t("backendErrors.cookieDbUnavailable");
case "SELF_HOSTED_REQUIRES_LOGOUT":
return t("backendErrors.selfHostedRequiresLogout");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+7
View File
@@ -9,6 +9,7 @@ import {
FaFire,
FaFirefox,
} from "react-icons/fa";
import { LuLock } from "react-icons/lu";
/**
* Map internal browser names to display names
@@ -42,7 +43,13 @@ export function getBrowserIcon(browserType: string) {
export function getProfileIcon(profile: {
browser: string;
ephemeral?: boolean;
password_protected?: boolean;
}) {
// `password_protected` and `ephemeral` are mutually exclusive (the backend
// rejects setting a password on an ephemeral profile), so the order here
// doesn't matter — checking lock first only matters if the invariant is
// ever violated, in which case showing the lock is the safer default.
if (profile.password_protected) return LuLock;
if (profile.ephemeral) return FaFire;
return getBrowserIcon(profile.browser);
}