Compare commits

..
Author SHA1 Message Date
zhom 598d3bd513 refactor: cleanup 2026-09-08 07:04:14 +04:00
dependabot[bot] c417c669c4 deps(rust)(deps): bump the rust-dependencies group (#600)
Bumps the rust-dependencies group in /src-tauri with 36 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri-plugin-opener](https://github.com/tauri-apps/plugins-workspace) | `2.5.4` | `2.5.5` |
| [tauri-plugin-fs](https://github.com/tauri-apps/plugins-workspace) | `2.5.1` | `2.5.2` |
| [tauri-plugin-shell](https://github.com/tauri-apps/plugins-workspace) | `2.3.5` | `2.3.6` |
| [tauri-plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.9` | `2.4.10` |
| [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace) | `2.4.3` | `2.4.4` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.7.2` | `2.7.3` |
| [tauri-plugin-log](https://github.com/tauri-apps/plugins-workspace) | `2.9.0` | `2.9.1` |
| [tauri-plugin-clipboard-manager](https://github.com/tauri-apps/plugins-workspace) | `2.3.2` | `2.3.3` |
| [flate2](https://github.com/rust-lang/flate2-rs) | `1.1.9` | `1.1.10` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.25.0` | `1.26.0` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.7.0` | `0.7.1` |
| [argon2](https://github.com/RustCrypto/password-hashes) | `0.5.3` | `0.6.0` |
| [aes](https://github.com/RustCrypto/block-ciphers) | `0.9.2` | `0.9.3` |
| [shadowsocks](https://github.com/shadowsocks/shadowsocks-rust) | `1.24.0` | `1.25.0` |
| [hyper](https://github.com/hyperium/hyper) | `1.11.0` | `1.11.1` |
| [secret-service](https://github.com/hwchen/secret-service-rs) | `5.1.0` | `5.2.0` |
| [block-padding](https://github.com/RustCrypto/utils) | `0.3.3` | `0.4.2` |
| [chacha20](https://github.com/RustCrypto/stream-ciphers) | `0.10.1` | `0.10.2` |
| [const-oid](https://github.com/RustCrypto/formats) | `0.9.6` | `0.10.2` |
| [cpufeatures](https://github.com/RustCrypto/utils) | `0.3.0` | `0.3.1` |
| [ctr](https://github.com/RustCrypto/block-modes) | `0.9.2` | `0.10.1` |
| [ghash](https://github.com/RustCrypto/universal-hashes) | `0.5.1` | `0.6.0` |
| [hermit-abi](https://github.com/hermit-os/hermit-rs) | `0.5.2` | `0.5.3` |
| [hkdf](https://github.com/RustCrypto/KDFs) | `0.12.4` | `0.13.0` |
| [imgref](https://github.com/kornelski/imgref) | `1.12.2` | `1.12.3` |
| [indexmap](https://github.com/indexmap-rs/indexmap) | `2.14.0` | `2.14.1` |
| libredox | `0.1.20` | `0.1.23` |
| [md-5](https://github.com/RustCrypto/hashes) | `0.10.6` | `0.11.0` |
| [open](https://github.com/Byron/open-rs) | `5.4.2` | `5.4.3` |
| [password-hash](https://github.com/RustCrypto/traits) | `0.5.0` | `0.6.1` |
| [polyval](https://github.com/RustCrypto/universal-hashes) | `0.6.2` | `0.7.3` |
| [sealed](https://github.com/jmg-duarte/sealed-rs) | `0.6.0` | `0.7.0` |
| [shadowsocks-crypto](https://github.com/shadowsocks/shadowsocks-crypto) | `0.6.2` | `0.8.0` |
| [smallvec](https://github.com/servo/rust-smallvec) | `1.15.2` | `1.16.0` |
| spin | `0.10.1` | `0.12.3` |
| [untrusted](https://github.com/briansmith/untrusted) | `0.9.0` | `0.7.1` |


Updates `tauri-plugin-opener` from 2.5.4 to 2.5.5
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/http-v2.5.4...http-v2.5.5)

Updates `tauri-plugin-fs` from 2.5.1 to 2.5.2
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.5.1...fs-v2.5.2)

Updates `tauri-plugin-shell` from 2.3.5 to 2.3.6
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/nfc-v2.3.5...nfc-v2.3.6)

Updates `tauri-plugin-deep-link` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/deep-link-v2.4.9...deep-link-v2.4.10)

Updates `tauri-plugin-single-instance` from 2.4.3 to 2.4.4
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.3...fs-v2.4.4)

Updates `tauri-plugin-dialog` from 2.7.2 to 2.7.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/dialog-v2.7.2...dialog-v2.7.3)

Updates `tauri-plugin-log` from 2.9.0 to 2.9.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.9.0...log-v2.9.1)

Updates `tauri-plugin-clipboard-manager` from 2.3.2 to 2.3.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/os-v2.3.2...nfc-v2.3.3)

Updates `flate2` from 1.1.9 to 1.1.10
- [Release notes](https://github.com/rust-lang/flate2-rs/releases)
- [Commits](https://github.com/rust-lang/flate2-rs/compare/1.1.9...1.1.10)

Updates `uuid` from 1.25.0 to 1.26.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/1.25.0...v1.26.0)

Updates `tower-http` from 0.7.0 to 0.7.1
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.7.0...tower-http-0.7.1)

Updates `argon2` from 0.5.3 to 0.6.0
- [Commits](https://github.com/RustCrypto/password-hashes/compare/argon2-v0.5.3...argon2-v0.6.0)

Updates `aes` from 0.9.2 to 0.9.3
- [Commits](https://github.com/RustCrypto/block-ciphers/compare/aes-v0.9.2...aes-v0.9.3)

Updates `shadowsocks` from 1.24.0 to 1.25.0
- [Release notes](https://github.com/shadowsocks/shadowsocks-rust/releases)
- [Commits](https://github.com/shadowsocks/shadowsocks-rust/compare/v1.24.0...v1.25.0)

Updates `hyper` from 1.11.0 to 1.11.1
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.11.0...v1.11.1)

Updates `secret-service` from 5.1.0 to 5.2.0
- [Release notes](https://github.com/hwchen/secret-service-rs/releases)
- [Changelog](https://github.com/open-source-cooperative/secret-service-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hwchen/secret-service-rs/compare/v5.1.0...v5.2.0)

Updates `block-padding` from 0.3.3 to 0.4.2
- [Commits](https://github.com/RustCrypto/utils/compare/block-padding-v0.3.3...block-padding-v0.4.2)

Updates `chacha20` from 0.10.1 to 0.10.2
- [Commits](https://github.com/RustCrypto/stream-ciphers/compare/chacha20-v0.10.1...chacha20-v0.10.2)

Updates `const-oid` from 0.9.6 to 0.10.2
- [Commits](https://github.com/RustCrypto/formats/compare/const-oid/v0.9.6...const-oid/v0.10.2)

Updates `cpufeatures` from 0.3.0 to 0.3.1
- [Commits](https://github.com/RustCrypto/utils/compare/cpufeatures-v0.3.0...cpufeatures-v0.3.1)

Updates `ctr` from 0.9.2 to 0.10.1
- [Commits](https://github.com/RustCrypto/block-modes/compare/ctr-v0.9.2...ctr-v0.10.1)

Updates `ghash` from 0.5.1 to 0.6.0
- [Commits](https://github.com/RustCrypto/universal-hashes/compare/ghash-v0.5.1...ghash-v0.6.0)

Updates `hermit-abi` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/hermit-os/hermit-rs/releases)
- [Commits](https://github.com/hermit-os/hermit-rs/compare/hermit-abi-0.5.2...hermit-sys-0.5.3)

Updates `hkdf` from 0.12.4 to 0.13.0
- [Commits](https://github.com/RustCrypto/KDFs/compare/hkdf-v0.12.4...hkdf-v0.13.0)

Updates `imgref` from 1.12.2 to 1.12.3
- [Commits](https://github.com/kornelski/imgref/compare/v1.12.2...v1.12.3)

Updates `indexmap` from 2.14.0 to 2.14.1
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.14.0...2.14.1)

Updates `libredox` from 0.1.20 to 0.1.23

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

Updates `open` from 5.4.2 to 5.4.3
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.4.2...v5.4.3)

Updates `password-hash` from 0.5.0 to 0.6.1
- [Commits](https://github.com/RustCrypto/traits/compare/password-hash-v0.5.0...password-hash-v0.6.1)

Updates `polyval` from 0.6.2 to 0.7.3
- [Commits](https://github.com/RustCrypto/universal-hashes/compare/polyval-v0.6.2...polyval-v0.7.3)

Updates `sealed` from 0.6.0 to 0.7.0
- [Commits](https://github.com/jmg-duarte/sealed-rs/commits)

Updates `shadowsocks-crypto` from 0.6.2 to 0.8.0
- [Release notes](https://github.com/shadowsocks/shadowsocks-crypto/releases)
- [Commits](https://github.com/shadowsocks/shadowsocks-crypto/compare/v0.6.2...v0.8.0)

Updates `smallvec` from 1.15.2 to 1.16.0
- [Release notes](https://github.com/servo/rust-smallvec/releases)
- [Commits](https://github.com/servo/rust-smallvec/compare/v1.15.2...v1.16.0)

Updates `spin` from 0.10.1 to 0.12.3

Updates `untrusted` from 0.9.0 to 0.7.1
- [Commits](https://github.com/briansmith/untrusted/commits)

---
updated-dependencies:
- dependency-name: tauri-plugin-opener
  dependency-version: 2.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-fs
  dependency-version: 2.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-shell
  dependency-version: 2.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-deep-link
  dependency-version: 2.4.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-single-instance
  dependency-version: 2.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-log
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-clipboard-manager
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: flate2
  dependency-version: 1.1.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: uuid
  dependency-version: 1.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: argon2
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: aes
  dependency-version: 0.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: shadowsocks
  dependency-version: 1.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hyper
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: secret-service
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: block-padding
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: chacha20
  dependency-version: 0.10.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: const-oid
  dependency-version: 0.10.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cpufeatures
  dependency-version: 0.3.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: ctr
  dependency-version: 0.10.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: ghash
  dependency-version: 0.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hermit-abi
  dependency-version: 0.5.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hkdf
  dependency-version: 0.13.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: imgref
  dependency-version: 1.12.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: indexmap
  dependency-version: 2.14.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: md-5
  dependency-version: 0.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: open
  dependency-version: 5.4.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: password-hash
  dependency-version: 0.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: polyval
  dependency-version: 0.7.3
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: sealed
  dependency-version: 0.7.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: shadowsocks-crypto
  dependency-version: 0.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: smallvec
  dependency-version: 1.16.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: spin
  dependency-version: 0.12.3
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: untrusted
  dependency-version: 0.7.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 09:50:20 +00:00
dependabot[bot] b60ffca115 ci(deps): bump the github-actions group with 2 updates (#599)
Bumps the github-actions group with 2 updates: [anomalyco/opencode/github](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `anomalyco/opencode/github` from 1.18.23 to 1.18.27
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/ef2880f379129aa048be9e9353e30aa168d42c17...4b7e19e315cca414121ba1d61523fef74bb3ae8b)

Updates `crate-ci/typos` from 1.49.0 to 1.50.1
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/8a48f81b6c64dcfea44b3633223084c4be58ac5f...d43b6c087ac471e2ea7b8af622ff15f05c0c365b)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 09:07:25 +00:00
zhom 7682fc6b57 refactor: cleanup 2026-09-05 07:32:03 +04:00
dependabot[bot] 4f655e2173 ci(deps): bump anomalyco/opencode/github in the github-actions group (#593)
Bumps the github-actions group with 1 update: [anomalyco/opencode/github](https://github.com/anomalyco/opencode).


Updates `anomalyco/opencode/github` from 1.18.18 to 1.18.23
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d...ef2880f379129aa048be9e9353e30aa168d42c17)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 09:08:43 +00:00
github-actions[bot]andgithub-actions[bot] 98b2acd338 chore: update flake.nix for v0.30.0 [skip ci] (#591)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-27 06:44:12 +00:00
github-actions[bot]andgithub-actions[bot] 2f4943fdc8 docs: update CHANGELOG.md and README.md for v0.30.0 [skip ci] (#590)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-27 06:43:54 +00:00
24 changed files with 871 additions and 679 deletions
+1 -1
View File
@@ -693,7 +693,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Run opencode
uses: anomalyco/opencode/github@31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d #v1.18.18
uses: anomalyco/opencode/github@4b7e19e315cca414121ba1d61523fef74bb3ae8b #v1.18.27
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Spell Check Repo
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f #v1.49.0
uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b #v1.50.1
+29
View File
@@ -1,6 +1,35 @@
# Changelog
## v0.30.0 (2026-08-27)
### Features
- verify checksum for wayfern
### Refactoring
- cleanup
- better cookie import experience
- table style unification
- confirmation button for profile-regeneration
### Documentation
- update CHANGELOG.md and README.md for v0.29.6 [skip ci] (#575)
### Maintenance
- chore: linting
- test: better sync coverage
- chore: update flake.nix for v0.29.6 [skip ci] (#576)
- ci(deps): bump the github-actions group with 5 updates
### Other
- style: copy
## v0.29.6 (2026-08-24)
### Refactoring
+5 -5
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_x64.dmg) |
Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut-0.29.6-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut-0.29.6-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut-0.30.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut-0.30.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+20 -16
View File
@@ -223,8 +223,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
"Wayfern returned an incomplete fingerprint",
);
// A browser with the identity API must hand back the UUID the device was
// derived from, plus the pre-edit baseline the launch path diffs against.
// Without both, the profile stores a device it cannot reproduce.
// derived from. Without it the profile cannot reproduce the device, since
// it stores none.
const identityCapable =
Number.parseInt(prepared.version.split(".")[0], 10) >= 151;
assert.equal(
@@ -232,28 +232,32 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
identityCapable,
"identity_id must be present exactly on browsers with the identity API",
);
assert.equal(
typeof sample.identity_baseline === "string",
identityCapable,
"identity_baseline must be present exactly on browsers with the identity API",
);
const profile = await createRealProfile(
app,
prepared.version,
`Real Wayfern (${prepared.source})`,
);
assert.ok(profile.wayfern_config.fingerprint);
assert.ok(
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10,
);
// Profile creation stores the identity alongside the device it derived, or
// the launch path would treat the profile as un-migrated and replace it.
// An identity-backed profile stores the identity and never the device: the
// browser rebuilds the device from the id on every launch. A browser
// without the identity API has nowhere to put an id, so there the payload
// is still what gets stored.
assert.equal(
typeof profile.wayfern_config.identity_id === "string",
identityCapable,
"a created profile must carry the identity its device came from",
);
assert.equal(
profile.wayfern_config.fingerprint === undefined,
identityCapable,
"an identity-backed profile must store no device payload",
);
if (!identityCapable) {
assert.ok(
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >=
10,
);
}
assert.equal(await app.invoke("check_missing_geoip_database"), true);
assert.equal(await app.invoke("is_geoip_database_available"), false);
await app.invoke("download_geoip_database");
@@ -281,9 +285,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
"the identity must survive update_wayfern_config and an exit re-match",
);
assert.equal(
stored.wayfern_config.identity_baseline,
profile.wayfern_config.identity_baseline,
"the baseline must survive with the identity it describes",
stored.wayfern_config.fingerprint,
undefined,
"neither call may leave a device payload behind",
);
}
// Pre-launch gate: local-only checks that must answer without starting a
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.29.6";
releaseVersion = "0.30.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.AppImage";
hash = "sha256-tZtULAhKl0HaiKj4WUZX4pkzjbPtOS/rioXPjrrm4Xk=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_amd64.AppImage";
hash = "sha256-Vcs7ZyWUOcny+ZjoxoP5U6laOTXbqMFAdr+DZABUeJM=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.AppImage";
hash = "sha256-5FWZeECixlNNxHuGzKoNxmHDjNtePU0yg0bgDLD1s5c=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_aarch64.AppImage";
hash = "sha256-KLZe+Vgce9KCzacVESnCkleD6x7yZbWofdMCrHjlJjE=";
}
else
null;
+234 -260
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -81,7 +81,7 @@ tower-http = { version = "0.7", features = ["cors"] }
rand = "0.10.2"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
argon2 = "0.6"
aes-gcm = "0.11"
aes = "0.9"
cbc = "0.2"
+55 -45
View File
@@ -460,24 +460,46 @@ impl BrowserRunner {
// Check if we need to generate a device for this launch.
//
// Two cases share the block: the user asked for a fresh device on every
// launch, or the profile stores none at all. The second is how a clone
// arrives here — cloning clears the fingerprint and the identity so the
// clone gets an independent device instead of the browser's default —
// and it also covers any profile that reached disk without one, which
// used to launch on whatever device the browser drew for itself.
// Three cases share the block: the user asked for a fresh device on
// every launch, the profile stores none at all, or the profile is legacy
// — a whole device payload and no identity — on a browser that speaks
// the identity API. The second is how a clone arrives here, since
// cloning clears both the payload and the identity so the clone gets an
// independent device instead of the browser's default.
//
// A profile that ALREADY stores a device keeps it across a browser
// upgrade: nothing here mints a replacement, and its stored payload is
// what the launch applies. The one thing that does replace a stored
// device is the user asking for it - `randomize_fingerprint_on_launch`,
// tested immediately below - which is a deliberate per-profile setting
// and not a consequence of the version.
// The third is the migration to identity-only storage: donutbrowser
// holds no device on disk, and a payload cannot become an identity
// locally, because only the browser mints an id and the id it mints
// derives its own device. That one-time rotation is the cost of the
// payload leaving disk, and it happens once because the minted id is
// persisted below.
let mut updated_profile = profile.clone();
// A profile that stores a whole device BESIDE an identity needs no new
// device, only its payload folded into overrides and location. This runs
// before the launch reads the config, and the migrated shape is what
// gets persisted below.
if crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut wayfern_config) {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut cfg);
updated_profile.wayfern_config = Some(cfg);
log::info!(
"Migrated Wayfern profile {} to identity-only storage",
profile.name
);
}
let randomize_requested = wayfern_config.randomize_fingerprint_on_launch == Some(true);
let needs_device = wayfern_config.fingerprint.is_none();
let migrating_payload = wayfern_config.identity_id.is_none()
&& wayfern_config.fingerprint.is_some()
&& crate::wayfern_manager::supports_identity_api(&profile.version);
let needs_device = migrating_payload
|| (wayfern_config.fingerprint.is_none() && wayfern_config.identity_id.is_none());
if randomize_requested || needs_device {
if needs_device && !randomize_requested {
if migrating_payload && !randomize_requested {
log::info!(
"Migrating Wayfern profile {} from a stored device to an identity",
profile.name
);
} else if needs_device && !randomize_requested {
log::info!(
"No stored device for Wayfern profile {}; generating one",
profile.name
@@ -530,16 +552,29 @@ impl BrowserRunner {
generated.identity_id
);
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(generated.fingerprint.clone());
// Update the config with the new device for launching. An identity
// stores the id and the location only; a legacy browser stores the
// whole payload.
let is_identity = generated.identity_id.is_some();
wayfern_config.identity_id = generated.identity_id.clone();
wayfern_config.identity_baseline = generated.identity_baseline.clone();
wayfern_config.location = generated.location.clone();
wayfern_config.identity_baseline = None;
wayfern_config.fingerprint = if is_identity {
None
} else {
Some(generated.fingerprint.clone())
};
// Save the updated fingerprint to the profile so it persists.
// Save the updated device to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(generated.fingerprint);
updated_wayfern_config.identity_id = generated.identity_id;
updated_wayfern_config.identity_baseline = generated.identity_baseline;
updated_wayfern_config.location = generated.location;
updated_wayfern_config.identity_baseline = None;
updated_wayfern_config.fingerprint = if is_identity {
None
} else {
Some(generated.fingerprint)
};
// Preserve the randomize flag so it persists across launches
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
@@ -710,31 +745,6 @@ impl BrowserRunner {
guard.worker_id = None;
}
// The apply command echoes back the device the browser actually used,
// which may differ from the stored one. Persist it so the next launch
// starts from that value — saved below via
// save_process_info(&updated_profile).
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
let baseline_changed = wayfern_result.used_identity_baseline.is_some()
&& cfg.identity_baseline != wayfern_result.used_identity_baseline;
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) || baseline_changed {
log::info!(
"Persisting applied fingerprint echoed by Wayfern for profile: {} (len {})",
profile.name,
used_fp.len()
);
cfg.fingerprint = Some(used_fp);
// The baseline must move with the fingerprint it was computed
// against, or the next launch diffs the two apart and invents
// overrides the user never asked for.
if let Some(baseline) = wayfern_result.used_identity_baseline.clone() {
cfg.identity_baseline = Some(baseline);
}
updated_profile.wayfern_config = Some(cfg);
}
}
// Update profile with the process info
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
+28 -13
View File
@@ -146,12 +146,13 @@ fn language_matches_country(cc: &str, language: &str) -> Option<bool> {
crate::geolocation::locale_selector()?.region_speaks(cc, language)
}
/// Extract (timezone, language) from a profile's stored fingerprint JSON.
/// Extract (timezone, language) from a profile's stored location, or from its
/// legacy fingerprint payload when it still stores one.
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
let Some(config) = &profile.wayfern_config else {
return (None, None);
};
let Some(fp_str) = &config.fingerprint else {
let Some(fp_str) = config.location.as_ref().or(config.fingerprint.as_ref()) else {
return (None, None);
};
let Ok(fp) = serde_json::from_str::<serde_json::Value>(fp_str) else {
@@ -363,20 +364,34 @@ pub async fn match_profile_fingerprint_to_exit(
let mut config = profile
.wayfern_config
.clone()
.filter(|c| c.fingerprint.is_some())
.filter(|c| c.fingerprint.is_some() || c.identity_id.is_some())
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
let fingerprint = config.fingerprint.clone().unwrap();
let geoip_override = serde_json::Value::String(exit_ip);
let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
&fingerprint,
None,
Some(&geoip_override),
)
.await
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
config.fingerprint = Some(refreshed);
if let Some(fingerprint) = config.fingerprint.clone() {
// Legacy payload: the location lives inside the stored device.
let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
&fingerprint,
None,
Some(&geoip_override),
)
.await
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
config.fingerprint = Some(refreshed);
} else {
// Identity-backed: only the location object moves; the device stays
// whatever the identity derives.
let location = config.location.clone().unwrap_or_else(|| "{}".to_string());
let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
&location,
None,
Some(&geoip_override),
)
.await
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
config.location = crate::wayfern_manager::WayfernManager::fingerprint_object(&refreshed)
.and_then(|object| crate::wayfern_manager::WayfernManager::location_of(&object));
}
profile.wayfern_config = Some(config);
manager.save_profile(&profile).map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
+22 -8
View File
@@ -76,17 +76,31 @@ fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
save(&prefs);
}
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
/// applying the moment the fingerprint is regenerated or matched to a new exit.
/// Stable digest of the device a profile publishes, so an acknowledgement
/// stops applying the moment that device is regenerated or matched to a new
/// exit.
///
/// An identity-backed profile stores no device at all: the identity, the user's
/// overrides and the exit's location are the whole of it, and the published
/// device moves exactly when one of the three does. The legacy payload is
/// hashed alongside them for a profile that has not been migrated yet.
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
use sha2::{Digest, Sha256};
let fingerprint = profile
.wayfern_config
.as_ref()
.and_then(|c| c.fingerprint.as_deref())
.unwrap_or("");
let mut hasher = Sha256::new();
hasher.update(fingerprint.as_bytes());
if let Some(config) = profile.wayfern_config.as_ref() {
for field in [
config.identity_id.as_deref(),
config.identity_overrides.as_deref(),
config.location.as_deref(),
config.fingerprint.as_deref(),
] {
// Length-prefixed, so moving a boundary between two fields cannot
// produce the digest of a different pair.
let value = field.unwrap_or("");
hasher.update(value.len().to_le_bytes());
hasher.update(value.as_bytes());
}
}
hasher
.finalize()
.iter()
+2 -2
View File
@@ -1304,7 +1304,7 @@ async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
struct SampleFingerprint {
fingerprint: String,
identity_id: Option<String>,
identity_baseline: Option<String>,
location: Option<String>,
}
#[tauri::command]
@@ -1356,7 +1356,7 @@ async fn generate_sample_fingerprint(
.map(|generated| SampleFingerprint {
fingerprint: generated.fingerprint,
identity_id: generated.identity_id,
identity_baseline: generated.identity_baseline,
location: generated.location,
})
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
} else {
+3
View File
@@ -4224,6 +4224,9 @@ impl McpServer {
serde_json::json!({
"browser": "wayfern",
"fingerprint": config.fingerprint,
"identity_id": config.identity_id,
"identity_overrides": config.identity_overrides,
"location": config.location,
"os": config.os,
"randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch,
"screen_max_width": config.screen_max_width,
+75 -12
View File
@@ -181,8 +181,24 @@ impl ProfileManager {
// behavior; for generated ones this comes from the geolocation lookup.
let mut geolocation_applied = true;
// Generate fingerprint if not already provided
if config.fingerprint.is_none() {
// A caller-supplied device is a set of explicit field choices, not a
// payload to store. On a browser with the identity API it becomes the
// identity's overrides and its location, and the device is minted from a
// freshly created identity below like any other profile's. A browser
// without that API has nowhere to put the choices, so there it stays the
// stored payload.
let supplied_device = if crate::wayfern_manager::supports_identity_api(version) {
config
.fingerprint
.take()
.and_then(|json| crate::wayfern_manager::WayfernManager::fingerprint_object(&json))
} else {
None
};
// Generate a device if the profile has neither a legacy payload nor an
// identity.
if config.fingerprint.is_none() && config.identity_id.is_none() {
log::info!("Generating fingerprint for Wayfern profile: {name}");
// Create a temporary profile for fingerprint generation
@@ -224,12 +240,16 @@ impl ProfileManager {
.await
{
Ok(generated) => {
config.fingerprint = Some(generated.fingerprint);
// Set together with the fingerprint they describe. A profile that
// stored one without the other would either lose reproducibility or
// diff its whole device into overrides on the next launch.
// An identity-backed profile stores the id and the location and
// never the device; a legacy browser stores the whole payload.
config.identity_id = generated.identity_id;
config.identity_baseline = generated.identity_baseline;
config.location = generated.location;
config.identity_baseline = None;
config.fingerprint = if config.identity_id.is_some() {
None
} else {
Some(generated.fingerprint)
};
geolocation_applied = generated.geolocation_applied;
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
}
@@ -243,6 +263,19 @@ impl ProfileManager {
log::info!("Using provided fingerprint for Wayfern profile: {name}");
}
if let Some(object) = supplied_device {
let overrides =
crate::wayfern_manager::WayfernManager::overrides_from_explicit_fingerprint(&object);
if !overrides.is_empty() {
config.identity_overrides = serde_json::to_string(&overrides).ok();
}
// A location the caller named wins over the one resolved for the exit;
// whatever it leaves out keeps the resolved value.
if let Some(location) = crate::wayfern_manager::WayfernManager::location_of(&object) {
config.location = Some(location);
}
}
// Record which proxy/geoip the fingerprint's location data was computed
// for. On launch this is compared against the profile's current routing
// so a proxy that was changed after creation triggers a location refresh
@@ -1123,7 +1156,7 @@ impl ProfileManager {
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Donut: a clone must NOT be linkable to its source. The source
// A clone must NOT be linkable to its source. The source
// wayfern_config embeds the persisted fingerprint JSON (including the
// canvas_noise_seed), so copying it verbatim makes the clone emit
// BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device
@@ -1193,12 +1226,42 @@ impl ProfileManager {
// re-mint the device on the next launch and throw the edit away with it,
// which is the opposite of what an override is for. Carry it forward unless
// the caller either supplied its own or cleared the fingerprint outright.
if config.identity_id.is_none() && config.fingerprint.is_some() {
if let Some(stored) = profile.wayfern_config.as_ref() {
if let Some(stored) = profile.wayfern_config.as_ref() {
if config.identity_id.is_none()
&& (config.fingerprint.is_some() || config.identity_overrides.is_some())
{
config.identity_id = stored.identity_id.clone();
config.identity_baseline = stored.identity_baseline.clone();
}
if config.identity_id.is_some() {
if config.location.is_none() {
config.location = stored.location.clone();
}
if config.identity_overrides.is_none() {
config.identity_overrides = stored.identity_overrides.clone();
}
// A WHOLE fingerprint sent for an identity-backed profile (an older UI
// or an API/MCP caller) is an explicit set of fields: it becomes the
// override map and is never stored as a device.
if let Some(fingerprint) = config.fingerprint.take() {
if let Some(object) =
crate::wayfern_manager::WayfernManager::fingerprint_object(&fingerprint)
{
let overrides =
crate::wayfern_manager::WayfernManager::overrides_from_explicit_fingerprint(&object);
config.identity_overrides = if overrides.is_empty() {
None
} else {
serde_json::to_string(&overrides).ok()
};
if config.location.is_none() {
config.location = crate::wayfern_manager::WayfernManager::location_of(&object);
}
}
}
}
}
// The baseline is a legacy field; nothing writes it any more.
config.identity_baseline = None;
// Update the Wayfern configuration
profile.wayfern_config = Some(config);
@@ -2036,7 +2099,7 @@ pub async fn update_wayfern_config(
profile_id: String,
config: WayfernConfig,
) -> Result<(), String> {
if config.fingerprint.is_some()
if (config.fingerprint.is_some() || config.identity_overrides.is_some())
&& !crate::cloud_auth::CLOUD_AUTH
.can_use_cross_os_fingerprints()
.await
+32 -3
View File
@@ -923,6 +923,19 @@ impl ProfileImporter {
let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default();
// A caller-supplied device is a set of explicit field choices, not a
// payload to store: on a browser with the identity API it becomes the
// identity's overrides and its location, and the device is minted from a
// freshly created identity below.
let supplied_device = if crate::wayfern_manager::supports_identity_api(&version) {
config
.fingerprint
.take()
.and_then(|json| crate::wayfern_manager::WayfernManager::fingerprint_object(&json))
} else {
None
};
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
@@ -948,7 +961,7 @@ impl ProfileImporter {
}
}
if config.fingerprint.is_none() {
if config.fingerprint.is_none() && config.identity_id.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
@@ -989,9 +1002,14 @@ impl ProfileImporter {
// geo_proxy_signature is intentionally left unset here: the first
// launch's signature-mismatch refresh verifies the location either way.
Ok(generated) => {
config.fingerprint = Some(generated.fingerprint);
config.identity_id = generated.identity_id;
config.identity_baseline = generated.identity_baseline;
config.location = generated.location;
config.identity_baseline = None;
config.fingerprint = if config.identity_id.is_some() {
None
} else {
Some(generated.fingerprint)
};
}
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
@@ -1007,6 +1025,17 @@ impl ProfileImporter {
}
}
if let Some(object) = supplied_device {
let overrides =
crate::wayfern_manager::WayfernManager::overrides_from_explicit_fingerprint(&object);
if !overrides.is_empty() {
config.identity_overrides = serde_json::to_string(&overrides).ok();
}
if let Some(location) = crate::wayfern_manager::WayfernManager::location_of(&object) {
config.location = Some(location);
}
}
config.proxy = None;
Some(config)
} else {
+1 -1
View File
@@ -1191,7 +1191,7 @@ fn build_reqwest_client_with_proxy(
Proxy::http(upstream_url)?
}
"socks5" => {
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
// Force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
// which resolves the destination hostname on the HOST (getaddrinfo) BEFORE
// connecting — leaking the destination domain to the host's DNS resolver
+1 -1
View File
@@ -3522,7 +3522,7 @@ pub async fn set_profile_sync_mode(
// tokio::spawn here allowed the tombstone-write to land *after* a fast
// user-triggered re-enable's tombstone-clear, re-introducing the
// tombstone and tripping the reconcile-pass deletion of a profile the
// user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20).
// user had just re-enabled.
if old_mode != SyncMode::Disabled {
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
+314 -279
View File
@@ -15,7 +15,11 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WayfernConfig {
#[serde(default)]
/// LEGACY device payload, carried only by a profile whose browser has no
/// identity API. Every other profile is rebuilt from `identity_id`, so this
/// is read from older metadata and from a caller that supplies a whole
/// device, and is never written once the profile has an identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
#[serde(default)]
pub randomize_fingerprint_on_launch: Option<bool>,
@@ -45,15 +49,31 @@ pub struct WayfernConfig {
/// location can be refreshed instead of showing stale data.
#[serde(default)]
pub geo_proxy_signature: Option<String>,
/// Identity handle for this profile, when it has one. `None` means the
/// profile stores a whole fingerprint payload instead.
/// Identity handle for this profile, when it has one. An identity-backed
/// profile stores the id, its `location` and its `identity_overrides` and
/// NOTHING else: the device is rebuilt from the id by the browser on every
/// launch, so no fingerprint payload ever sits on disk to be copied.
/// `None` means a legacy profile that still stores a whole payload in
/// `fingerprint` and is applied with `Wayfern.setFingerprint`.
#[serde(default)]
pub identity_id: Option<String>,
/// The fingerprint as first received for `identity_id`, before geolocation
/// and before any user edit. Diffed against `fingerprint` on launch to
/// recover the user's own edits.
#[serde(default)]
/// LEGACY, read only by `migrate_identity_config`: the derived device an
/// older build snapshotted so the user's edits could be diffed out of the
/// stored payload. Cleared by the migration and never serialized again, so
/// a migrated profile carries no trace of it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_baseline: Option<String>,
/// The user's own edits to an identity-backed device, as a JSON object of
/// fingerprint fields. Sent verbatim as `setIdentity` overrides; everything
/// not listed here comes from the identity. `None` means no edits.
#[serde(default)]
pub identity_overrides: Option<String>,
/// The location the profile's exit resolves to (timezone, timezoneOffset,
/// language, languages, latitude, longitude, accuracy) as a JSON object.
/// It depends on the proxy, not on the identity, which is why it is the one
/// piece of device state an identity-backed profile persists.
#[serde(default)]
pub location: Option<String>,
}
/// First Wayfern version that ships `createIdentity`/`setIdentity`/
@@ -119,12 +139,15 @@ const LOCALE_CARRY_OVER_KEYS: [&str; 7] = [
/// A freshly generated device, plus its identity handle when the browser
/// supports identities.
pub struct GeneratedFingerprint {
/// The fingerprint JSON to store in `WayfernConfig::fingerprint`. Both paths
/// produce a flat camelCase object, so everything that reads the stored
/// fingerprint keeps working either way.
/// The device the browser produced, as a flat camelCase JSON object. For a
/// LEGACY browser this is what `WayfernConfig::fingerprint` stores. For an
/// identity-backed profile it is a VIEW for the caller to show once and
/// discard: only `identity_id` and `location` are persisted.
pub fingerprint: String,
pub identity_id: Option<String>,
pub identity_baseline: Option<String>,
/// `WayfernConfig::location` for the exit this device was generated
/// against, or `None` when no location field was resolved.
pub location: Option<String>,
/// Whether fresh geolocation was resolved and applied. Callers must only
/// stamp `geo_proxy_signature` when this is true.
pub geolocation_applied: bool,
@@ -140,16 +163,6 @@ pub struct WayfernLaunchResult {
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
/// The fingerprint the browser echoed back after applying it. It may differ
/// from what was sent, so it is this value that gets persisted. Internal
/// only — never sent to the frontend.
#[serde(default, skip_serializing)]
pub used_fingerprint: Option<String>,
/// The refreshed baseline to persist alongside `used_fingerprint`. Keeping
/// it in step is what stops an unedited field from being mistaken for a user
/// edit on the next launch. Internal only.
#[serde(default, skip_serializing)]
pub used_identity_baseline: Option<String>,
}
struct WayfernInstance {
@@ -277,7 +290,7 @@ impl WayfernManager {
/// Parse a stored fingerprint JSON into its object, tolerating the legacy
/// `{ "fingerprint": {...} }` wrapper some old profiles carry.
fn fingerprint_object(
pub fn fingerprint_object(
fingerprint_json: &str,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
@@ -285,6 +298,94 @@ impl WayfernManager {
fp.as_object().cloned()
}
/// A stored JSON object field (`identity_overrides`, `location`), or an
/// empty map when absent or unparsable.
pub fn stored_object(json: Option<&str>) -> serde_json::Map<String, serde_json::Value> {
json.and_then(Self::fingerprint_object).unwrap_or_default()
}
/// The exit-derived location fields a device object carries, in the shape
/// `WayfernConfig::location` stores; `None` when it carries none.
pub fn location_of(
device: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
let mut location = serde_json::Map::new();
for key in LOCALE_CARRY_OVER_KEYS {
if let Some(value) = device.get(key) {
if !value.is_null() {
location.insert(key.to_string(), value.clone());
}
}
}
if location.is_empty() {
None
} else {
serde_json::to_string(&location).ok()
}
}
/// Overrides from a WHOLE fingerprint an API or MCP caller supplied for an
/// identity-backed profile: every field it names is taken as an explicit
/// edit, except the provenance keys the browser refuses and the location
/// keys, which travel through `location`.
pub fn overrides_from_explicit_fingerprint(
fingerprint: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
let mut overrides = serde_json::Map::new();
for (key, value) in fingerprint {
if DERIVED_PROVENANCE_KEYS.contains(&key.as_str())
|| GEO_PARAM_KEYS.contains(&key.as_str())
|| LOCALE_CARRY_OVER_KEYS.contains(&key.as_str())
|| value.is_null()
{
continue;
}
overrides.insert(key.clone(), value.clone());
}
overrides
}
/// ONE-TIME MIGRATION to identity-only storage. A profile created by an
/// earlier build stored the whole device in `fingerprint` beside its
/// `identity_id`, with `identity_baseline` recording the derived view so the
/// user's edits could be diffed out. This moves those edits into
/// `identity_overrides`, the exit-derived fields into `location`, and drops
/// the payload and the baseline. Returns whether anything changed.
///
/// Without a baseline nothing can separate an edit from a derived value, so
/// no override is recovered: pinning the whole device would defeat the
/// identity, and the browser rebuilds every field from the id anyway.
pub fn migrate_identity_config(config: &mut WayfernConfig) -> bool {
if config.identity_id.is_none() {
return false;
}
let Some(stored_json) = config.fingerprint.clone() else {
if config.identity_baseline.is_some() {
config.identity_baseline = None;
return true;
}
return false;
};
let stored = Self::fingerprint_object(&stored_json).unwrap_or_default();
let overrides = match config
.identity_baseline
.as_deref()
.and_then(Self::fingerprint_object)
{
Some(baseline) => Self::identity_overrides(&stored, &baseline),
None => serde_json::Map::new(),
};
if config.identity_overrides.is_none() && !overrides.is_empty() {
config.identity_overrides = serde_json::to_string(&overrides).ok();
}
if config.location.is_none() {
config.location = Self::location_of(&stored);
}
config.fingerprint = None;
config.identity_baseline = None;
true
}
/// The user's edits, recovered as the difference between the fingerprint the
/// profile stores and the view the browser derived from the identity.
///
@@ -379,35 +480,6 @@ impl WayfernManager {
locale.split('-').next().unwrap_or(locale)
}
/// The baseline to persist after a successful `setIdentity`.
///
/// For every key the user did NOT override, adopt whatever the browser just
/// derived, so a value that changes on a newer browser flows through instead
/// of reading as a user edit forever. For overridden keys the applied view
/// holds the override rather than the derived value, so the previous baseline
/// is kept as the diff reference.
fn refreshed_identity_baseline(
applied: &serde_json::Map<String, serde_json::Value>,
previous_baseline: &serde_json::Map<String, serde_json::Value>,
overrides: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
let mut baseline = applied.clone();
for key in overrides.keys() {
match previous_baseline.get(key) {
Some(previous) => {
baseline.insert(key.clone(), previous.clone());
}
// The user added a field the baseline never carried, so there is
// nothing to fall back to and the key must stay absent from the
// baseline or the edit would diff away on the next launch.
None => {
baseline.remove(key);
}
}
}
baseline
}
/// The `setIdentity` geolocation parameters carried by a stored fingerprint.
fn geo_params(
fingerprint: &serde_json::Map<String, serde_json::Value>,
@@ -423,34 +495,6 @@ impl WayfernManager {
params
}
/// Fill in any location field the applied device does not already carry.
///
/// `setIdentity` takes the location as its own parameters rather than inside
/// the identity, so the view it echoes back may omit part of it — and donut's
/// stored fingerprint must always carry the whole block, because the launch
/// gate reads it before any browser is running and a stored device with no
/// timezone turns the exit-vs-fingerprint check into a no-op.
///
/// Only ABSENT fields are filled. Anything the browser did send back is its
/// own and is kept: it re-roots the `languages` ladder onto the exit's
/// language, which is a better answer than the two-entry list donut computes.
fn carry_over_locale(
from: &serde_json::Map<String, serde_json::Value>,
into: &mut serde_json::Value,
) {
let Some(target) = into.as_object_mut() else {
return;
};
for key in LOCALE_CARRY_OVER_KEYS {
if target.get(key).is_some_and(|v| !v.is_null()) {
continue;
}
if let Some(value) = from.get(key) {
target.insert(key.to_string(), value.clone());
}
}
}
/// One of Wayfern's five `operatingSystem` names, or `None` for anything
/// else. Unknown names are not guessed at: the caller treats `None` as "donut
/// does not know what this profile claims" and lets the browser decide.
@@ -942,7 +986,7 @@ impl WayfernManager {
}
};
let (fingerprint, identity_id, identity_baseline, geolocation_applied) = match generate_result {
let (fingerprint, identity_id, geolocation_applied) = match generate_result {
Ok(result) => {
// createIdentity returns { identityId, identity }; getFingerprint
// returns { fingerprint: {...} }. A bare object is tolerated so a
@@ -961,15 +1005,6 @@ impl WayfernManager {
// Normalize the fingerprint: convert JSON string fields to proper types
let mut normalized = Self::normalize_fingerprint(fp);
// Snapshot the derived view BEFORE geolocation is applied, so the
// location fields donut writes below are not mistaken for user edits
// when the overrides are recovered on launch.
let identity_baseline = if use_identity_api {
serde_json::to_string(&normalized).ok()
} else {
None
};
// reqwest's SOCKS connector (hyper-util) corrupts its parse buffer
// when a proxy splits a handshake reply across TCP segments, so a
// socks upstream here can fail even though the proxy is healthy.
@@ -1020,12 +1055,7 @@ impl WayfernManager {
let _ = crate::proxy_runner::stop_proxy_process(&worker_id).await;
}
(
normalized,
identity_id,
identity_baseline,
geolocation_applied,
)
(normalized, identity_id, geolocation_applied)
}
Err(e) => {
cleanup().await;
@@ -1075,9 +1105,9 @@ impl WayfernManager {
}
Ok(GeneratedFingerprint {
location: fingerprint.as_object().and_then(Self::location_of),
fingerprint: fingerprint_json,
identity_id,
identity_baseline,
geolocation_applied,
})
}
@@ -1405,10 +1435,85 @@ impl WayfernManager {
let page_targets: Vec<_> = targets.iter().filter(|t| t.target_type == "page").collect();
log::info!("Found {} page targets", page_targets.len());
// Apply fingerprint if configured
let mut used_fingerprint: Option<String> = None;
let mut used_identity_baseline: Option<String> = None;
if let Some(fingerprint_json) = &config.fingerprint {
// An identity-backed profile: the id, the user's overrides and the exit's
// location are all the browser needs, and all the profile stores. The
// device comes back in the response and is deliberately NOT persisted.
let identity_only = supports_identity_api(&profile.version)
&& config.identity_id.is_some()
&& config.fingerprint.is_none();
if identity_only {
let identity_id = config.identity_id.clone().unwrap_or_default();
let overrides = Self::stored_object(config.identity_overrides.as_deref());
let location = Self::stored_object(config.location.as_deref());
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
let mut params = serde_json::Map::new();
params.insert("identityId".to_string(), json!(identity_id));
// The claimed OS travels explicitly as well as inside the id. A Wayfern
// 152 id carries an epoch and a 16-bit check that a 151 browser's decoder
// does not know; without this parameter 151 would read such an id as
// untagged and rebuild the HOST OS. Both releases let the explicit
// parameter win, so this keeps one stored profile portable across them.
if let Some(os) = config.os.as_deref().filter(|os| !os.is_empty()) {
params.insert("operatingSystem".to_string(), json!(os));
}
if !overrides.is_empty() {
params.insert(
"overrides".to_string(),
serde_json::Value::Object(overrides.clone()),
);
}
// Location is a property of the exit, not of the identity, so it travels
// in setIdentity's own parameters rather than as an override.
params.extend(Self::geo_params(&location));
if let Some(ref token) = wayfern_token {
params.insert("wayfernToken".to_string(), json!(token));
}
log::info!(
"Applying Wayfern identity {} with {} override(s): {:?}",
identity_id,
overrides.len(),
overrides.keys().collect::<Vec<_>>()
);
let mut applied_ok = false;
let mut last_apply_error: Option<String> = None;
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
match self
.send_cdp_command(
ws_url,
"Wayfern.setIdentity",
serde_json::Value::Object(params.clone()),
)
.await
{
Ok(_) => {
applied_ok = true;
log::info!("Successfully applied identity to page target");
}
Err(e) => {
log::error!("Failed to apply identity to target: {e}");
last_apply_error = Some(e.to_string());
}
}
}
}
if !applied_ok {
let detail = last_apply_error
.unwrap_or_else(|| "the browser exposed no page target to apply it to".to_string());
log::error!(
"Killing Wayfern (pid {process_id:?}) for profile {}: the identity was never applied: {detail}",
profile.name
);
if let Some(pid) = process_id {
kill_browser_process(pid);
}
return Err(
Self::apply_failure_error(&detail, Self::claimed_operating_system(config, None)).into(),
);
}
} else if let Some(fingerprint_json) = &config.fingerprint {
log::info!(
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
fingerprint_json.len()
@@ -1473,77 +1578,20 @@ impl WayfernManager {
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
// The device as donut holds it: the diff source for the overrides below,
// and the fallback for any location field the echo does not return.
// The device as donut holds it, for the diagnostic below.
let stored = fingerprint_for_cdp.as_object().cloned().unwrap_or_default();
// Which command applies this profile's device. It is a property of the
// PROFILE, not of the browser version, so a profile that stores a whole
// payload keeps being applied with the payload command.
//
// `webglProfileId` is the discriminator: only a whole-payload profile
// carries it, and the browser refuses it as an override. Sending it would
// fail the call on every launch.
let apply_by_identity = supports_identity_api(&profile.version)
&& config.identity_id.is_some()
&& stored.get("webglProfileId").is_none();
// On the identity path only the user's own edits are sent; everything
// else comes from the identity itself.
let (apply_method, apply_params, previous_baseline, overrides) =
match config.identity_id.as_deref().filter(|_| apply_by_identity) {
Some(identity_id) => {
let previous_baseline = config
.identity_baseline
.as_deref()
.and_then(Self::fingerprint_object)
.unwrap_or_default();
let overrides = Self::identity_overrides(&stored, &previous_baseline);
let mut params = serde_json::Map::new();
params.insert("identityId".to_string(), json!(identity_id));
if !overrides.is_empty() {
params.insert(
"overrides".to_string(),
serde_json::Value::Object(overrides.clone()),
);
}
// Location is a property of the exit, not of the identity, so it
// travels in setIdentity's own parameters rather than as an override.
params.extend(Self::geo_params(&stored));
if let Some(ref token) = wayfern_token {
params.insert("wayfernToken".to_string(), json!(token));
}
log::info!(
"Applying Wayfern identity {} with {} override(s): {:?}",
identity_id,
overrides.len(),
overrides.keys().collect::<Vec<_>>()
);
(
"Wayfern.setIdentity",
serde_json::Value::Object(params),
previous_baseline,
overrides,
)
}
None => {
let mut params = fingerprint_for_cdp.clone();
if let Some(ref token) = wayfern_token {
if let Some(obj) = params.as_object_mut() {
obj.insert("wayfernToken".to_string(), json!(token));
}
}
(
"Wayfern.setFingerprint",
params,
serde_json::Map::new(),
serde_json::Map::new(),
)
}
};
// `setFingerprint` is the only command that reproduces a whole payload
// exactly, and on a browser without the identity API it is the only
// command there is. A profile whose browser HAS that API never reaches
// here: the launch path mints it an identity and drops the payload
// first, so a stored device is never sent as a device again.
let mut apply_params = fingerprint_for_cdp.clone();
if let Some(ref token) = wayfern_token {
if let Some(obj) = apply_params.as_object_mut() {
obj.insert("wayfernToken".to_string(), json!(token));
}
}
// An apply that never lands is the worst outcome this launch has: the
// window opens on an unmanaged device while every surface in the app
@@ -1556,62 +1604,12 @@ impl WayfernManager {
if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to page target");
match self
.send_cdp_command(ws_url, apply_method, apply_params.clone())
.send_cdp_command(ws_url, "Wayfern.setFingerprint", apply_params.clone())
.await
{
Ok(result) => {
// The device is on the target. Whether the ECHO parses is a
// separate question — it only decides what we persist.
Ok(_) => {
applied_ok = true;
log::info!("Successfully applied fingerprint to page target");
// Both commands echo back the device the browser actually used,
// which may differ from what we sent. Capture it once, from the
// first target that succeeds, so the caller can persist it.
if used_fingerprint.is_none() {
// setIdentity wraps the object as { identity: {...} },
// setFingerprint as { fingerprint: {...} }; tolerate a bare
// object too.
let applied = result
.get("identity")
.or_else(|| result.get("fingerprint"))
.cloned()
.unwrap_or(result);
if let Some(applied_obj) = applied.as_object() {
if apply_by_identity {
// The baseline is "what the browser derived", so it is
// computed from the untouched response. Move it forward for
// everything the user did not override: leaving it behind
// would make the next launch read a re-derived value as a
// user edit and pin it.
let baseline = Self::refreshed_identity_baseline(
applied_obj,
&previous_baseline,
&overrides,
);
match serde_json::to_string(&baseline) {
Ok(s) => used_identity_baseline = Some(s),
Err(e) => log::warn!("Failed to serialize identity baseline: {e}"),
}
}
let mut persisted = applied;
if apply_by_identity {
// The location travelled as setIdentity parameters rather
// than inside the identity, so make sure it survives into
// what we store. The launch gate and the pre-launch window
// sizing both read the stored fingerprint before any
// browser is running, and a stored device with no timezone
// silently turns the exit-vs-fingerprint check into a no-op.
Self::carry_over_locale(&stored, &mut persisted);
}
match serde_json::to_string(&Self::normalize_fingerprint(persisted)) {
Ok(s) => used_fingerprint = Some(s),
Err(e) => {
log::warn!("Failed to serialize used fingerprint: {e}")
}
}
}
}
}
Err(e) => {
log::error!("Failed to apply fingerprint to target: {e}");
@@ -1703,8 +1701,6 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
used_fingerprint,
used_identity_baseline,
})
}
@@ -1832,8 +1828,6 @@ impl WayfernManager {
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
used_fingerprint: None,
used_identity_baseline: None,
});
} else {
log::info!(
@@ -1876,8 +1870,6 @@ impl WayfernManager {
profilePath: Some(found_profile_path),
url: None,
cdp_port,
used_fingerprint: None,
used_identity_baseline: None,
});
}
@@ -2157,67 +2149,110 @@ mod tests {
}
#[test]
fn baseline_adopts_rederived_values_but_keeps_overridden_ones() {
// The same identity on a newer browser derives 12 cores where it used to
// derive 8, while the user has pinned deviceMemory to 32.
let previous = obj(r#"{"hardwareConcurrency": 8, "deviceMemory": 8}"#);
let overrides = obj(r#"{"deviceMemory": 32}"#);
let applied = obj(r#"{"hardwareConcurrency": 12, "deviceMemory": 32}"#);
fn migration_moves_a_stored_payload_into_overrides_and_location() {
let mut config = WayfernConfig {
identity_id: Some("id-1".to_string()),
identity_baseline: Some(r#"{"hardwareConcurrency": 8, "platform": "Win32"}"#.to_string()),
fingerprint: Some(
r#"{"hardwareConcurrency": 16, "platform": "Win32", "timezone": "Europe/Berlin"}"#
.to_string(),
),
..Default::default()
};
let refreshed = WayfernManager::refreshed_identity_baseline(&applied, &previous, &overrides);
assert!(WayfernManager::migrate_identity_config(&mut config));
assert!(config.fingerprint.is_none());
assert!(config.identity_baseline.is_none());
// Re-derived: adopted, so the next launch does not mistake it for an edit.
assert_eq!(refreshed.get("hardwareConcurrency"), Some(&json!(12)));
// Overridden: the applied view holds the override, so the derived value is
// kept as the diff reference and the override survives.
assert_eq!(refreshed.get("deviceMemory"), Some(&json!(8)));
let overrides = obj(config.identity_overrides.as_deref().unwrap());
assert_eq!(overrides.get("hardwareConcurrency"), Some(&json!(16)));
assert!(overrides.get("platform").is_none());
// Location is the one piece of device state a migrated profile keeps: it
// follows the exit, not the identity.
let location = obj(config.location.as_deref().unwrap());
assert_eq!(location.get("timezone"), Some(&json!("Europe/Berlin")));
let next_overrides = WayfernManager::identity_overrides(&applied, &refreshed);
assert_eq!(next_overrides.len(), 1);
assert_eq!(next_overrides.get("deviceMemory"), Some(&json!(32)));
// Running again must change nothing, because a profile is migrated on
// whichever launch reaches it first and every later launch repeats it.
let after_first = serde_json::to_string(&config).unwrap();
assert!(!WayfernManager::migrate_identity_config(&mut config));
assert_eq!(serde_json::to_string(&config).unwrap(), after_first);
}
#[test]
fn baseline_keeps_a_user_added_key_out_so_the_override_survives() {
// The user set a field the derivation never produces. There is no derived
// value to fall back to, so the key must stay absent from the baseline.
let previous = obj(r#"{"platform": "Win32"}"#);
let overrides = obj(r#"{"doNotTrack": "1"}"#);
let applied = obj(r#"{"platform": "Win32", "doNotTrack": "1"}"#);
fn migration_is_a_no_op_for_an_already_identity_only_profile() {
let mut config = WayfernConfig {
identity_id: Some("id-1".to_string()),
identity_overrides: Some(r#"{"doNotTrack":"1"}"#.to_string()),
location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()),
..Default::default()
};
let refreshed = WayfernManager::refreshed_identity_baseline(&applied, &previous, &overrides);
assert!(refreshed.get("doNotTrack").is_none());
let next_overrides = WayfernManager::identity_overrides(&applied, &refreshed);
assert_eq!(next_overrides.get("doNotTrack"), Some(&json!("1")));
}
#[test]
fn the_launch_echo_only_fills_location_the_browser_left_out() {
// setIdentity carries the location in its own parameters, so the applied
// view may not echo all of it back. The stored fingerprint has to keep it:
// the launch gate reads `timezone` before any browser is running, and a
// stored device without one turns that check into a no-op.
let stored = obj(
r#"{"timezone": "Europe/Berlin", "timezoneOffset": -60,
"language": "de-DE", "languages": ["de-DE", "de"]}"#,
);
// The browser returned its own, richer `languages` ladder and dropped the
// rest.
let mut applied = json!({"languages": ["de-DE", "de", "en-US", "en"]});
WayfernManager::carry_over_locale(&stored, &mut applied);
let applied = applied.as_object().unwrap();
assert_eq!(applied.get("timezone"), Some(&json!("Europe/Berlin")));
assert_eq!(applied.get("timezoneOffset"), Some(&json!(-60)));
assert_eq!(applied.get("language"), Some(&json!("de-DE")));
// What the browser DID return wins: it re-roots the ladder onto the exit's
// language, which is a better answer than the two-entry list donut builds.
assert!(!WayfernManager::migrate_identity_config(&mut config));
assert!(config.fingerprint.is_none());
assert_eq!(
applied.get("languages"),
Some(&json!(["de-DE", "de", "en-US", "en"]))
config.identity_overrides.as_deref(),
Some(r#"{"doNotTrack":"1"}"#)
);
assert_eq!(
config.location.as_deref(),
Some(r#"{"timezone":"Europe/Berlin"}"#)
);
}
#[test]
fn migration_leaves_a_payload_only_profile_for_the_launch_path() {
// A legacy profile has no identity for its edits to sit on, and only the
// browser can mint one. The payload stays until the launch path replaces
// it with a fresh identity, so the profile is never left with neither.
let mut config = WayfernConfig {
fingerprint: Some(r#"{"platform":"Win32"}"#.to_string()),
..Default::default()
};
assert!(!WayfernManager::migrate_identity_config(&mut config));
assert_eq!(config.fingerprint.as_deref(), Some(r#"{"platform":"Win32"}"#));
assert!(config.identity_id.is_none());
assert!(config.identity_overrides.is_none());
}
#[test]
fn migration_has_nothing_to_do_for_a_config_with_neither() {
let mut config = WayfernConfig::default();
assert!(!WayfernManager::migrate_identity_config(&mut config));
assert!(config.fingerprint.is_none());
assert!(config.identity_id.is_none());
assert!(config.identity_overrides.is_none());
assert!(config.location.is_none());
}
#[test]
fn migration_clears_a_baseline_left_behind_without_a_payload() {
let mut config = WayfernConfig {
identity_id: Some("id-1".to_string()),
identity_baseline: Some(r#"{"platform":"Win32"}"#.to_string()),
..Default::default()
};
assert!(WayfernManager::migrate_identity_config(&mut config));
assert!(config.identity_baseline.is_none());
assert!(!WayfernManager::migrate_identity_config(&mut config));
}
#[test]
fn a_migrated_config_writes_no_device_to_disk() {
let mut config = WayfernConfig {
identity_id: Some("id-1".to_string()),
fingerprint: Some(r#"{"platform":"Win32","timezone":"Europe/Berlin"}"#.to_string()),
..Default::default()
};
assert!(WayfernManager::migrate_identity_config(&mut config));
let written = serde_json::to_string(&config).unwrap();
assert!(!written.contains("\"fingerprint\""));
assert!(!written.contains("\"identity_baseline\""));
assert!(written.contains("\"location\""));
}
#[test]
+4 -1
View File
@@ -388,7 +388,10 @@ export function enableProfileSync(profileId: string): Promise<void> {
* the operator happens to be sitting. Falls back to this machine's zone.
*/
export function profileTimezone(profile: BrowserProfile): string {
const raw = profile.wayfern_config?.fingerprint;
// Identity-backed profiles keep the exit's location in `location`; legacy
// ones carry it inside the stored payload.
const raw =
profile.wayfern_config?.location ?? profile.wayfern_config?.fingerprint;
if (raw) {
try {
const parsed = JSON.parse(raw) as WayfernFingerprintConfig;
+5 -1
View File
@@ -31,7 +31,11 @@ import { RippleButton } from "./ui/ripple";
function getScreenSize(
profile: BrowserProfile,
): { w: number; h: number } | null {
const fp = profile.wayfern_config?.fingerprint;
// An identity-backed profile stores no device, only the user's edits, so a
// screen size is available only when the user pinned one.
const fp =
profile.wayfern_config?.fingerprint ??
profile.wayfern_config?.identity_overrides;
if (!fp) return null;
try {
const parsed: WayfernFingerprintConfig = JSON.parse(fp);
+3 -2
View File
@@ -65,9 +65,10 @@ export function WayfernConfigDialog({
const handleSave = async () => {
if (!profile) return;
if (config.fingerprint) {
const storedJson = config.identity_overrides ?? config.fingerprint;
if (storedJson) {
try {
JSON.parse(config.fingerprint);
JSON.parse(storedJson);
} catch (_error) {
const { toast } = await import("sonner");
toast.error(t("wayfernConfigDialog.invalidFingerprint"), {
+24 -13
View File
@@ -55,7 +55,7 @@ const isFingerprintEditingDisabled = (config: WayfernConfig): boolean => {
interface GeneratedFingerprint {
fingerprint: string;
identity_id: string | null;
identity_baseline: string | null;
location: string | null;
}
const getCurrentOS = (): WayfernOS => {
@@ -109,15 +109,17 @@ export function WayfernConfigForm({
configJson,
},
);
onConfigChange("fingerprint", result.fingerprint);
// The identity travels with the fingerprint it produced. Storing one
// without the other leaves a device the launch path cannot reproduce, so
// it would be discarded and re-minted on the next launch.
// An identity-backed profile stores the id, its location and the user's
// edits, never the device: the browser rebuilds the device from the id
// on every launch, so nothing worth copying is ever written to disk. A
// legacy browser without the identity API still stores the payload.
onConfigChange("identity_id", result.identity_id ?? undefined);
onConfigChange("location", result.location ?? undefined);
onConfigChange(
"identity_baseline",
result.identity_baseline ?? undefined,
"fingerprint",
result.identity_id ? undefined : result.fingerprint,
);
onConfigChange("identity_overrides", undefined);
} catch (error) {
console.error("Failed to generate fingerprint:", error);
} finally {
@@ -164,12 +166,16 @@ export function WayfernConfigForm({
onConfigChange,
]);
// What the form edits: the override map for an identity-backed profile
// (only the user's own edits exist on disk), the whole payload for a legacy
// one.
const editedJson = config.identity_id
? config.identity_overrides
: config.fingerprint;
useEffect(() => {
if (config.fingerprint) {
if (editedJson) {
try {
const parsed = JSON.parse(
config.fingerprint,
) as WayfernFingerprintConfig;
const parsed = JSON.parse(editedJson) as WayfernFingerprintConfig;
setFingerprintConfig(parsed);
} catch (error) {
console.error("Failed to parse fingerprint config:", error);
@@ -178,7 +184,7 @@ export function WayfernConfigForm({
} else {
setFingerprintConfig({});
}
}, [config.fingerprint]);
}, [editedJson]);
const updateFingerprintConfig = (
key: keyof WayfernFingerprintConfig,
@@ -200,7 +206,12 @@ export function WayfernConfigForm({
try {
const jsonString = JSON.stringify(newConfig);
onConfigChange("fingerprint", jsonString);
onConfigChange(
config.identity_id ? "identity_overrides" : "fingerprint",
Object.keys(newConfig).length === 0 && config.identity_id
? undefined
: jsonString,
);
} catch (error) {
console.error("Failed to serialize fingerprint config:", error);
}
+4 -8
View File
@@ -74,14 +74,10 @@ export function useGroupEvents() {
void setupListeners();
// Cleanup listeners on unmount.
// NOTE: the previous version stored both unlisten fns by reassigning
// `groupsUnlisten` to a wrapper that called itself, which produced a
// `Maximum call stack size exceeded` crash whenever this effect tore
// down. React's reconciler then bailed out mid-commit and left stale
// overlay nodes in the DOM, blocking every subsequent click in the
// window. Holding the two unlisten fns in separate locals avoids both
// problems.
// Cleanup listeners on unmount. The two unlisten fns stay in separate
// locals: merging them into one wrapper that reassigns the local it then
// calls makes that wrapper call itself, and the stack overflow aborts the
// teardown mid-commit, leaving stale overlay nodes that swallow clicks.
return () => {
if (groupsUnlisten) groupsUnlisten();
if (profilesUnlisten) profilesUnlisten();
+2 -1
View File
@@ -414,7 +414,8 @@ export interface WayfernConfig {
os?: WayfernOS; // Operating system for fingerprint generation
geo_proxy_signature?: string; // Internal: routing the fingerprint's location was computed for
identity_id?: string; // Internal: UUID the device is derived from on browsers with the identity API
identity_baseline?: string; // Internal: derived fingerprint before edits, diffed to recover overrides
identity_overrides?: string; // JSON object of the user's own edits to an identity-backed device
location?: string; // JSON object of the exit-derived location fields (timezone, language, coordinates)
}
// Wayfern fingerprint config - matches the C++ FingerprintData structure