Compare commits

...

34 Commits

Author SHA1 Message Date
github-actions[bot] 202f2c852b chore: update flake.nix for v0.26.0 [skip ci] (#428)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-08 02:02:46 +00:00
github-actions[bot] 5a8864654d docs: update CHANGELOG.md and README.md for v0.26.0 [skip ci] (#427)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-08 02:02:30 +00:00
zhom ba40458216 chore: version bump 2026-06-08 04:38:01 +04:00
zhom 91e6381ba5 chore: linting 2026-06-08 00:44:03 +04:00
zhom 2055108578 feat: add cookie export 2026-06-08 00:06:44 +04:00
zhom fc9a00b97d refactor: deprecate camoufox 2026-06-08 00:06:44 +04:00
zhom 15f3aa03f7 refactor: cleanup 2026-06-08 00:06:44 +04:00
dependabot[bot] 6b31c937ea deps(rust)(deps): bump the rust-dependencies group (#422)
Bumps the rust-dependencies group in /src-tauri with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [log](https://github.com/rust-lang/log) | `0.4.30` | `0.4.32` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [chrono](https://github.com/chronotope/chrono) | `0.4.44` | `0.4.45` |
| [rusqlite](https://github.com/rusqlite/rusqlite) | `0.40.0` | `0.40.1` |
| [serial_test](https://github.com/palfrey/serial_test) | `3.4.0` | `3.5.0` |
| [hashlink](https://github.com/djc/hashlink) | `0.11.0` | `0.12.0` |
| [libfuzzer-sys](https://github.com/rust-fuzz/libfuzzer) | `0.4.12` | `0.4.13` |
| [libsqlite3-sys](https://github.com/rusqlite/rusqlite) | `0.38.0` | `0.38.1` |
| [serde_with](https://github.com/jonasbb/serde_with) | `3.20.0` | `3.21.0` |
| [serde_with_macros](https://github.com/jonasbb/serde_with) | `3.20.0` | `3.21.0` |
| [serial_test_derive](https://github.com/palfrey/serial_test) | `3.4.0` | `3.5.0` |
| [unicode-segmentation](https://github.com/unicode-rs/unicode-segmentation) | `1.13.2` | `1.13.3` |
| [yoke](https://github.com/unicode-org/icu4x) | `0.8.2` | `0.8.3` |


Updates `log` from 0.4.30 to 0.4.32
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.30...0.4.32)

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

Updates `chrono` from 0.4.44 to 0.4.45
- [Release notes](https://github.com/chronotope/chrono/releases)
- [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md)
- [Commits](https://github.com/chronotope/chrono/compare/v0.4.44...v0.4.45)

Updates `rusqlite` from 0.40.0 to 0.40.1
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.40.0...v0.40.1)

Updates `serial_test` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/palfrey/serial_test/releases)
- [Commits](https://github.com/palfrey/serial_test/compare/v3.4.0...v3.5.0)

Updates `hashlink` from 0.11.0 to 0.12.0
- [Release notes](https://github.com/djc/hashlink/releases)
- [Changelog](https://github.com/djc/hashlink/blob/main/CHANGELOG.md)
- [Commits](https://github.com/djc/hashlink/compare/v0.11.0...v0.12.0)

Updates `libfuzzer-sys` from 0.4.12 to 0.4.13
- [Changelog](https://github.com/rust-fuzz/libfuzzer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-fuzz/libfuzzer/compare/0.4.12...0.4.13)

Updates `libsqlite3-sys` from 0.38.0 to 0.38.1
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/commits)

Updates `serde_with` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.20.0...v3.21.0)

Updates `serde_with_macros` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.20.0...v3.21.0)

Updates `serial_test_derive` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/palfrey/serial_test/releases)
- [Commits](https://github.com/palfrey/serial_test/compare/v3.4.0...v3.5.0)

Updates `unicode-segmentation` from 1.13.2 to 1.13.3
- [Commits](https://github.com/unicode-rs/unicode-segmentation/commits)

Updates `yoke` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits/ind/yoke@0.8.3)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bzip2
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: chrono
  dependency-version: 0.4.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rusqlite
  dependency-version: 0.40.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serial_test
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hashlink
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libfuzzer-sys
  dependency-version: 0.4.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libsqlite3-sys
  dependency-version: 0.38.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serde_with
  dependency-version: 3.21.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.21.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serial_test_derive
  dependency-version: 3.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: unicode-segmentation
  dependency-version: 1.13.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: yoke
  dependency-version: 0.8.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  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-06-06 10:28:59 +00:00
dependabot[bot] 96e4f22e38 ci(deps): bump the github-actions group with 3 updates (#421)
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


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

Updates `anomalyco/opencode` from 1.15.13 to 1.16.2
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/385cb694419f98103af0e8fc6187ddcbcbb6eecb...76c631d198f9ff620e15468e45f3457d50481b57)

Updates `crate-ci/typos` from 1.47.0 to 1.47.2
- [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/f8a58b6b53f2279f71eb605f03a4ae4d10608f45...37bb98842b0d8c4ffebdb75301a13db0267cef89)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-06 09:42:21 +00:00
github-actions[bot] ef7af59ef8 chore: update flake.nix for v0.25.3 [skip ci] (#417)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-03 20:25:14 +00:00
github-actions[bot] 3df5bffdf5 docs: update CHANGELOG.md and README.md for v0.25.3 [skip ci] (#416)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-03 20:24:58 +00:00
zhom e98d02a585 chore: version bump 2026-06-03 23:05:21 +04:00
zhom afa2326584 fix: launch wayfern with proper dimentions for mobile devices 2026-06-03 23:05:21 +04:00
github-actions[bot] d25d8549e4 chore: update flake.nix for v0.25.2 [skip ci] (#415)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 01:43:35 +00:00
github-actions[bot] 662b370ed0 docs: update CHANGELOG.md and README.md for v0.25.2 [skip ci] (#414)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 01:43:19 +00:00
zhom b2d16c7be1 chore: simplify linux repo publish 2026-06-02 04:22:38 +04:00
zhom a0244356bf chore: version bump 2026-06-02 04:22:38 +04:00
zhom 14522c75f6 refactor: cleanup 2026-06-02 04:22:38 +04:00
zhom b4624f8e8f chore: copy 2026-06-02 04:22:38 +04:00
github-actions[bot] e5f12884de chore: update flake.nix for v0.25.1 [skip ci] (#413)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-01 20:56:02 +00:00
github-actions[bot] c95b097c93 docs: update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-01 20:55:41 +00:00
andy 742b883090 Merge pull request #411 from zhom/contributors-readme-action-2wao70ioBS
docs(contributor): contributors readme action update
2026-06-01 12:36:22 -07:00
github-actions[bot] 57e068084e docs(contributor): contrib-readme-action has updated readme 2026-06-01 19:35:08 +00:00
zhom e006d56387 chore: version bump 2026-06-01 23:34:21 +04:00
zhom 43f9f02029 chore: update issue validation 2026-06-01 18:05:59 +04:00
zhom 839265de35 chore: cleanup windows ci 2026-06-01 17:37:18 +04:00
zhom 0d85b61c96 chore: add missing keys 2026-06-01 15:09:59 +04:00
andy f581b6ec59 Merge pull request #395 from huy97/feature/add-vietnamese-locale
feat(i18n): add Vietnamese (vi) locale
2026-05-31 16:36:33 -07:00
andy 43c86c2dfb Merge branch 'main' into feature/add-vietnamese-locale 2026-05-31 16:36:22 -07:00
zhom 42067367fd chore: version bump 2026-06-01 02:19:25 +04:00
zhom ce7213dccd chore: bump flake.lock 2026-06-01 01:41:12 +04:00
andy 799df28f61 Merge pull request #402 from zhom/dependabot/cargo/src-tauri/rust-dependencies-1b5fcb4ba9
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 30 updates
2026-05-31 14:40:59 -07:00
huy97 ddfdf68dd1 feat(i18n): add Vietnamese (vi) locale
Add Vietnamese translation with all 1572 keys matching en.json.
Register "vi" in SUPPORTED_LANGUAGES, LANGUAGE_FALLBACKS, and
settings dialog language type assertion. Update locale count
from 8 to 9 in docs (AGENTS.md, CONTRIBUTING.md) to account
for previously-omitted Korean (ko).
2026-05-30 23:13:08 +07:00
dependabot[bot] 2131ca3e3f deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 30 updates:

| Package | From | To |
| --- | --- | --- |
| [log](https://github.com/rust-lang/log) | `0.4.29` | `0.4.30` |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.13.3` | `0.13.4` |
| [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.39.2` | `0.39.3` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.23.1` | `1.23.2` |
| [aes](https://github.com/RustCrypto/block-ciphers) | `0.9.0` | `0.9.1` |
| [hyper](https://github.com/hyperium/hyper) | `1.9.0` | `1.10.1` |
| [rusqlite](https://github.com/rusqlite/rusqlite) | `0.39.0` | `0.40.0` |
| [brotli](https://github.com/dropbox/rust-brotli) | `8.0.2` | `8.0.3` |
| [brotli-decompressor](https://github.com/dropbox/rust-brotli-decompressor) | `5.0.0` | `5.0.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.62` | `1.2.63` |
| [displaydoc](https://github.com/yaahc/displaydoc) | `0.2.5` | `0.2.6` |
| [http](https://github.com/hyperium/http) | `1.4.0` | `1.4.1` |
| [jiff](https://github.com/BurntSushi/jiff) | `0.2.24` | `0.2.28` |
| [jiff-static](https://github.com/BurntSushi/jiff) | `0.2.24` | `0.2.28` |
| libredox | `0.1.16` | `0.1.17` |
| [libsqlite3-sys](https://github.com/rusqlite/rusqlite) | `0.37.0` | `0.38.0` |
| [memchr](https://github.com/BurntSushi/memchr) | `2.8.0` | `2.8.1` |
| [mio](https://github.com/tokio-rs/mio) | `1.2.0` | `1.2.1` |
| [shlex](https://github.com/comex/rust-shlex) | `1.3.0` | `2.0.1` |
| [socket2](https://github.com/rust-lang/socket2) | `0.6.3` | `0.6.4` |
| [sqlite-wasm-rs](https://github.com/Spxg/sqlite-wasm-rs) | `0.5.4` | `0.5.5` |
| [typenum](https://github.com/paholg/typenum) | `1.20.0` | `1.20.1` |
| [zbus](https://github.com/z-galaxy/zbus) | `5.15.0` | `5.16.0` |
| [zbus_macros](https://github.com/z-galaxy/zbus) | `5.15.0` | `5.16.0` |
| [zerocopy](https://github.com/google/zerocopy) | `0.8.48` | `0.8.50` |
| [zerocopy-derive](https://github.com/google/zerocopy) | `0.8.48` | `0.8.50` |
| [zvariant](https://github.com/z-galaxy/zbus) | `5.11.0` | `5.12.0` |
| [zvariant_derive](https://github.com/z-galaxy/zbus) | `5.11.0` | `5.12.0` |
| [zvariant_utils](https://github.com/z-galaxy/zbus) | `3.3.1` | `3.4.0` |


Updates `log` from 0.4.29 to 0.4.30
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.29...0.4.30)

Updates `reqwest` from 0.13.3 to 0.13.4
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.13.3...v0.13.4)

Updates `sysinfo` from 0.39.2 to 0.39.3
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.39.2...v0.39.3)

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

Updates `uuid` from 1.23.1 to 1.23.2
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.1...v1.23.2)

Updates `aes` from 0.9.0 to 0.9.1
- [Commits](https://github.com/RustCrypto/block-ciphers/compare/aes-v0.9.0...aes-v0.9.1)

Updates `hyper` from 1.9.0 to 1.10.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.9.0...v1.10.1)

Updates `rusqlite` from 0.39.0 to 0.40.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.39.0...v0.40.0)

Updates `brotli` from 8.0.2 to 8.0.3
- [Release notes](https://github.com/dropbox/rust-brotli/releases)
- [Commits](https://github.com/dropbox/rust-brotli/commits)

Updates `brotli-decompressor` from 5.0.0 to 5.0.1
- [Commits](https://github.com/dropbox/rust-brotli-decompressor/commits)

Updates `cc` from 1.2.62 to 1.2.63
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.62...cc-v1.2.63)

Updates `displaydoc` from 0.2.5 to 0.2.6
- [Changelog](https://github.com/yaahc/displaydoc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yaahc/displaydoc/compare/v0.2.5...v0.2.6)

Updates `http` from 1.4.0 to 1.4.1
- [Release notes](https://github.com/hyperium/http/releases)
- [Changelog](https://github.com/hyperium/http/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/http/compare/v1.4.0...v1.4.1)

Updates `jiff` from 0.2.24 to 0.2.28
- [Release notes](https://github.com/BurntSushi/jiff/releases)
- [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.24...jiff-static-0.2.28)

Updates `jiff-static` from 0.2.24 to 0.2.28
- [Release notes](https://github.com/BurntSushi/jiff/releases)
- [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.24...jiff-static-0.2.28)

Updates `libredox` from 0.1.16 to 0.1.17

Updates `libsqlite3-sys` from 0.37.0 to 0.38.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.37.0...v0.38.0)

Updates `memchr` from 2.8.0 to 2.8.1
- [Commits](https://github.com/BurntSushi/memchr/compare/2.8.0...2.8.1)

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

Updates `shlex` from 1.3.0 to 2.0.1
- [Changelog](https://github.com/comex/rust-shlex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/comex/rust-shlex/commits)

Updates `socket2` from 0.6.3 to 0.6.4
- [Release notes](https://github.com/rust-lang/socket2/releases)
- [Changelog](https://github.com/rust-lang/socket2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/socket2/commits)

Updates `sqlite-wasm-rs` from 0.5.4 to 0.5.5
- [Release notes](https://github.com/Spxg/sqlite-wasm-rs/releases)
- [Changelog](https://github.com/Spxg/sqlite-wasm-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Spxg/sqlite-wasm-rs/compare/0.5.4...0.5.5)

Updates `typenum` from 1.20.0 to 1.20.1
- [Release notes](https://github.com/paholg/typenum/releases)
- [Changelog](https://github.com/paholg/typenum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/paholg/typenum/compare/v1.20.0...v1.20.1)

Updates `zbus` from 5.15.0 to 5.16.0
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zbus-5.15.0...zbus-5.16.0)

Updates `zbus_macros` from 5.15.0 to 5.16.0
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zbus_macros-5.15.0...zbus_macros-5.16.0)

Updates `zerocopy` from 0.8.48 to 0.8.50
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/commits)

Updates `zerocopy-derive` from 0.8.48 to 0.8.50
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/commits)

Updates `zvariant` from 5.11.0 to 5.12.0
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zvariant-5.11.0...zvariant-5.12.0)

Updates `zvariant_derive` from 5.11.0 to 5.12.0
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zvariant_derive-5.11.0...zvariant_derive-5.12.0)

Updates `zvariant_utils` from 3.3.1 to 3.4.0
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zvariant_utils-3.3.1...zvariant_utils-3.4.0)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: reqwest
  dependency-version: 0.13.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sysinfo
  dependency-version: 0.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bzip2
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: uuid
  dependency-version: 1.23.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: aes
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hyper
  dependency-version: 1.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rusqlite
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: brotli
  dependency-version: 8.0.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: brotli-decompressor
  dependency-version: 5.0.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.63
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: displaydoc
  dependency-version: 0.2.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: http
  dependency-version: 1.4.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: jiff
  dependency-version: 0.2.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: jiff-static
  dependency-version: 0.2.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.17
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libsqlite3-sys
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: memchr
  dependency-version: 2.8.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: mio
  dependency-version: 1.2.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: shlex
  dependency-version: 2.0.1
  dependency-type: indirect
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: socket2
  dependency-version: 0.6.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sqlite-wasm-rs
  dependency-version: 0.5.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: typenum
  dependency-version: 1.20.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zbus
  dependency-version: 5.16.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zbus_macros
  dependency-version: 5.16.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zerocopy
  dependency-version: 0.8.50
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy-derive
  dependency-version: 0.8.50
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zvariant
  dependency-version: 5.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zvariant_derive
  dependency-version: 5.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zvariant_utils
  dependency-version: 3.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 09:52:19 +00:00
65 changed files with 3983 additions and 1735 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
-108
View File
@@ -1,108 +0,0 @@
name: Compliance Close
on:
schedule:
# Every 30 minutes; the actual close decision uses comment age, so the cron
# cadence only bounds how stale the closure can get past the 24-hour mark.
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-non-compliant:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 24 hours
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: items } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'needs:compliance',
state: 'open',
per_page: 100,
});
if (items.length === 0) {
core.info('No open issues/PRs with needs:compliance label');
return;
}
const now = Date.now();
const window_ms = 24 * 60 * 60 * 1000;
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
});
// Use the OLDEST compliance sentinel as the start of the 24-hour
// window so back-and-forth edits don't reset the clock.
const sentinel = comments
.filter(c => c.body && c.body.includes('<!-- issue-compliance -->'))
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0];
if (!sentinel) {
core.info(`${kind} #${item.number} has needs:compliance label but no compliance comment; skipping`);
continue;
}
const age_ms = now - new Date(sentinel.created_at).getTime();
if (age_ms < window_ms) {
const hours = (age_ms / (60 * 60 * 1000)).toFixed(1);
core.info(`${kind} #${item.number} still within 24-hour window (${hours}h elapsed)`);
continue;
}
const closeMessage = isPR
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
body: closeMessage,
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {
core.info(`Could not remove needs:compliance label from #${item.number}: ${e.message}`);
}
if (isPR) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: item.number,
state: 'closed',
});
} else {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
core.info(`Closed non-compliant ${kind} #${item.number} after 24-hour window`);
}
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Install Nix
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
+16 -152
View File
@@ -2,7 +2,7 @@ name: Issue Compliance Check
on:
issues:
types: [opened, edited]
types: [opened]
permissions:
contents: read
@@ -13,11 +13,16 @@ env:
jobs:
check-compliance:
if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened'
# Maintainers' own issues are exempt — they open quick tracking issues
# without the template on purpose. Everyone else is checked.
if: >-
github.repository == 'zhom/donutbrowser' &&
github.event.issue.author_association != 'OWNER' &&
github.event.issue.author_association != 'MEMBER'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Gather context
env:
@@ -44,7 +49,7 @@ jobs:
- A feature request gives no use case at all
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative — a non-compliant verdict closes the issue, so only flag a genuine template violation.
## Output schema
{
@@ -83,7 +88,7 @@ jobs:
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
# Strip accidental markdown fences and parse. On parse failure, fall back
# to a noop result so the workflow doesn't fail the issue author's run.
# to a compliant result so a flaky model never closes a legitimate issue.
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
@@ -94,6 +99,7 @@ jobs:
cat /tmp/result.json
- name: Build comment
id: build
run: |
python3 - <<'EOF'
import json, os
@@ -103,167 +109,25 @@ jobs:
parts = []
if not compliant:
parts.append('<!-- issue-compliance -->')
parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).")
parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).")
parts.append('')
parts.append('**What needs to be fixed:**')
parts.append('**What was missing:**')
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
parts.append('')
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
parts.append('If this is a real bug or feature request, please open a new issue using the **Bug Report** or **Feature Request** template and fill in the required fields. Issues that ignore the template are not triaged.')
comment = '\n'.join(parts).strip()
open('/tmp/comment.md', 'w').write(comment)
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
fh.write(f'has_comment={"true" if comment else "false"}\n')
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
EOF
id: build
- name: Post comment
if: steps.build.outputs.has_comment == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
- name: Apply needs:compliance label
- name: Comment and close non-compliant issue
if: steps.build.outputs.non_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "needs:compliance"
recheck-compliance:
# When a flagged issue is edited, re-check. If now compliant: remove label,
# delete the previous compliance comment, and thank the author. If still
# non-compliant: leave label and post an updated note.
if: >
github.repository == 'zhom/donutbrowser' &&
github.event.action == 'edited' &&
contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Gather context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
- name: Build prompt
run: |
cat > /tmp/system.txt <<'PROMPT'
You are re-checking a GitHub issue that was previously flagged as not meeting template requirements. Return ONLY a single JSON object, no prose, no markdown fences.
Project: Donut Browser. There are three valid templates:
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
- Feature Request (description + verification checkbox)
- Question (free form)
## Flag NON-compliant ONLY when at least one of these is true
- The issue body is empty or contains only placeholder text from the template
- The issue is an obvious AI-generated wall of text with no real specifics
- A bug report has no reproduction information or no error description
- A feature request gives no use case at all
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...]
}
PROMPT
- name: Call OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
PAYLOAD=$(jq -n \
--arg model "$MODEL" \
--rawfile system_prompt /tmp/system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user", content: ("Title: " + $title + "\n\nBody:\n" + $body) }
],
response_format: { type: "json_object" }
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; assuming still non-compliant"
echo '{"is_compliant": false, "non_compliance_reasons": ["unable to parse model output"]}' > /tmp/result.json
fi
- name: Resolve compliance state
id: resolve
run: |
IS_COMPLIANT=$(jq -r '.is_compliant // false' /tmp/result.json)
echo "is_compliant=$IS_COMPLIANT" >> "$GITHUB_OUTPUT"
- name: Clear compliance label and acknowledge fix
if: steps.resolve.outputs.is_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "needs:compliance" || true
# Delete the previous <!-- issue-compliance --> sentinel comment so
# the thread is clean once the author has addressed the issue.
COMMENT_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
--jq '[.[] | select(.body | contains("<!-- issue-compliance -->"))][-1].id // empty')
if [ -n "$COMMENT_ID" ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" || true
fi
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--body "Thanks for updating the issue."
- name: Build follow-up comment
if: steps.resolve.outputs.is_compliant != 'true'
run: |
python3 - <<'EOF'
import json
r = json.load(open('/tmp/result.json'))
reasons = r.get('non_compliance_reasons') or []
parts = [
'<!-- issue-compliance -->',
'This issue still does not meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).',
'',
'**What still needs to be fixed:**',
]
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
open('/tmp/comment.md', 'w').write('\n'.join(parts))
EOF
- name: Post follow-up comment
if: steps.resolve.outputs.is_compliant != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Check if first-time contributor
id: check-first-time
@@ -479,7 +479,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Check if first-time contributor
id: check-first-time
@@ -617,10 +617,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Run opencode
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
uses: anomalyco/opencode/github@76c631d198f9ff620e15468e45f3457d50481b57 #v1.16.2
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: main
fetch-depth: 0
+25 -169
View File
@@ -23,6 +23,9 @@ jobs:
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Determine release tag
id: tag
env:
@@ -40,182 +43,35 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi
- name: Configure aws-cli for R2
# aws-cli v2.23+ sends integrity checksums by default; Cloudflare R2
# rejects those headers with `Unauthorized` on ListObjectsV2.
# Also normalise the endpoint URL (must start with https://).
# Both values propagate to later steps via $GITHUB_ENV.
env:
RAW_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
run: |
endpoint="$RAW_ENDPOINT"
if [[ "$endpoint" != https://* && "$endpoint" != http://* ]]; then
endpoint="https://$endpoint"
fi
echo "R2_ENDPOINT=$endpoint" >> "$GITHUB_ENV"
echo "AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
echo "AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
- name: Install tools
run: |
# Mirror the local/Docker setup from CLAUDE.md exactly: the same apt
# packages and the same pip-installed awscli the working local run uses.
sudo apt-get update
sudo apt-get install -y dpkg-dev createrepo-c python3-pip
# Remove pre-installed aws-cli v2 — it sends CRC64NVME checksums
# that Cloudflare R2 rejects with Unauthorized, and the s3transfer
# lib has a confirmed bug where WHEN_REQUIRED is silently ignored
# (boto/s3transfer#327). Install aws-cli v1 via pip instead.
sudo rm -f /usr/local/bin/aws /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli
pip3 install --break-system-packages awscli
# Ensure pip-installed aws is on PATH (pip may install to ~/.local/bin)
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
aws --version
- name: Download packages from GitHub release
- name: Publish DEB & RPM repositories to R2
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
mkdir -p /tmp/packages
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.deb" \
--dir /tmp/packages
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.rpm" \
--dir /tmp/packages
echo "Downloaded packages:"
ls -lh /tmp/packages/
- name: Build DEB repository
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
DEB_DIR="/tmp/repo/deb"
mkdir -p "$DEB_DIR/pool/main"
mkdir -p "$DEB_DIR/dists/stable/main/binary-amd64"
mkdir -p "$DEB_DIR/dists/stable/main/binary-arm64"
# Sync existing pool from R2 (incremental)
aws s3 sync "s3://${R2_BUCKET}/deb/pool" "$DEB_DIR/pool" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || true
# Copy new .deb files into pool
cp /tmp/packages/*.deb "$DEB_DIR/pool/main/" 2>/dev/null || true
# Generate Packages and Packages.gz for each arch
for arch in amd64 arm64; do
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
> "$BINARY_DIR/Packages"
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
echo " $arch: $(grep -c '^Package:' "$BINARY_DIR/Packages" 2>/dev/null || echo 0) package(s)"
done
# Generate Release file
{
echo "Origin: Donut Browser"
echo "Label: Donut Browser"
echo "Suite: stable"
echo "Codename: stable"
echo "Architectures: amd64 arm64"
echo "Components: main"
echo "Date: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
echo "MD5Sum:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
md5=$(md5sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$md5" "$size" "$file"
fi
done
done
echo "SHA256:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
sha256=$(sha256sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$sha256" "$size" "$file"
fi
done
done
} > "$DEB_DIR/dists/stable/Release"
echo "DEB Release file created."
- name: Build RPM repository
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
RPM_DIR="/tmp/repo/rpm"
mkdir -p "$RPM_DIR/x86_64"
mkdir -p "$RPM_DIR/aarch64"
# Sync existing RPMs from R2 (incremental)
aws s3 sync "s3://${R2_BUCKET}/rpm/x86_64" "$RPM_DIR/x86_64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET}/rpm/aarch64" "$RPM_DIR/aarch64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
# Copy new .rpm files into arch directories
for rpm in /tmp/packages/*.rpm; do
[[ -f "$rpm" ]] || continue
filename=$(basename "$rpm")
if [[ "$filename" == *x86_64* ]]; then
cp "$rpm" "$RPM_DIR/x86_64/"
elif [[ "$filename" == *aarch64* ]]; then
cp "$rpm" "$RPM_DIR/aarch64/"
fi
done
# Generate repodata
createrepo_c --update "$RPM_DIR"
echo "RPM repodata created."
- name: Upload to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "Uploading DEB repository..."
aws s3 sync /tmp/repo/deb/dists "s3://${R2_BUCKET}/deb/dists" \
--endpoint-url "$R2_ENDPOINT" --delete
aws s3 sync /tmp/repo/deb/pool "s3://${R2_BUCKET}/deb/pool" \
--endpoint-url "$R2_ENDPOINT"
echo "Uploading RPM repository..."
aws s3 sync /tmp/repo/rpm "s3://${R2_BUCKET}/rpm" \
--endpoint-url "$R2_ENDPOINT"
- name: Verify upload
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
echo "Published repos for $TAG"
echo ""
echo "DEB dists/stable/:"
aws s3 ls "s3://${R2_BUCKET}/deb/dists/stable/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
echo "DEB pool/main/:"
aws s3 ls "s3://${R2_BUCKET}/deb/pool/main/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
echo "RPM repodata/:"
aws s3 ls "s3://${R2_BUCKET}/rpm/repodata/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
# GitHub injects secrets verbatim. If a value was pasted with
# surrounding quotes or a trailing newline — the local .env wraps all
# four R2_* values in double quotes — it reaches the script malformed:
# e.g. an endpoint of https://"host" yields
# `Could not connect to the endpoint URL`, and a quoted key yields
# `Unauthorized`. The local run is unaffected because publish-repo.sh
# sources .env through bash, which strips the quotes; CI has no .env,
# so strip here. No-op when the secrets are already clean. The script
# itself is intentionally left untouched.
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
fetch-depth: 0
+10 -5
View File
@@ -105,7 +105,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
@@ -246,7 +246,12 @@ jobs:
# Copy sidecar binaries
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
# The daemon is currently disabled (no Cargo bin target), so it isn't
# built. Copy it only if a build produced it, so the absent binary
# doesn't fail the job.
if [ -f "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
fi
# Copy WebView2Loader if present
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
@@ -283,7 +288,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
ref: main
fetch-depth: 0
@@ -449,7 +454,7 @@ jobs:
needs: [release, changelog]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
ref: main
fetch-depth: 0
@@ -547,7 +552,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
ref: main
+8 -3
View File
@@ -104,7 +104,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
@@ -247,7 +247,12 @@ jobs:
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
# The daemon is currently disabled (no Cargo bin target), so it isn't
# built. Copy it only if a build produced it, so the absent binary
# doesn't fail the job.
if [ -f "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
fi
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
@@ -279,7 +284,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Generate nightly tag
id: tag
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Spell Check Repo
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
+3
View File
@@ -22,3 +22,6 @@ jobs:
stale-pr-label: "stale"
days-before-stale: 30
days-before-close: 7
# Never let the maintainer's own assigned issues go stale or get
# closed, regardless of inactivity.
exempt-issue-assignees: "zhom"
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
@@ -73,7 +73,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Start MinIO
run: |
+7 -10
View File
@@ -11,7 +11,7 @@ donutbrowser/
│ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, pt, ru, zh)
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
@@ -27,9 +27,7 @@ donutbrowser/
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
│ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence
@@ -60,9 +58,8 @@ donutbrowser/
Three log surfaces, in order of usefulness:
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
@@ -76,12 +73,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
- Adding a new string means adding the key to ALL seven locale files in `src/i18n/locales/` (en, es, fr, ja, pt, ru, zh) — not just `en.json`. The English version alone is incomplete work.
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all seven locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Seven sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
## Backend error codes (mandatory)
@@ -95,7 +92,7 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
```
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
4. Add `backendErrors.fooBar` to all seven locale files.
4. Add `backendErrors.fooBar` to all nine locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
@@ -148,7 +145,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all seven locales.
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
- `matchesGroupDigit(event)` returns 19 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
@@ -158,7 +155,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
4. Add `shortcuts.yourId` (label) to all seven locale files.
4. Add `shortcuts.yourId` (label) to all nine locale files.
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
+72
View File
@@ -1,6 +1,78 @@
# Changelog
## v0.26.0 (2026-06-08)
### Features
- add cookie export
### Refactoring
- deprecate camoufox
- cleanup
### Maintenance
- chore: version bump
- chore: linting
- ci(deps): bump the github-actions group with 3 updates (#421)
- chore: update flake.nix for v0.25.3 [skip ci] (#417)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#422)
## v0.25.3 (2026-06-03)
### Bug Fixes
- launch wayfern with proper dimentions for mobile devices
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.25.2 [skip ci] (#415)
## v0.25.2 (2026-06-02)
### Refactoring
- cleanup
### Documentation
- update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
### Maintenance
- chore: simplify linux repo publish
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.25.1 [skip ci] (#413)
## v0.25.1 (2026-06-01)
### Maintenance
- chore: version bump
- chore: update issue validation
- chore: cleanup windows ci
- chore: add missing keys
## v0.25.0 (2026-06-01)
Note: created manually due to CI issue
- Onboarding added for new users.
- When closing the window, you can choose to minimize to tray or quit.
- Improved feedback for macOS permission grants.
- Cloud login now opens in your external browser.
## v0.24.4 (2026-05-26)
### Refactoring
+1 -1
View File
@@ -73,7 +73,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
## Key Rules
- **Translations**: Any UI text changes must be reflected in all 7 locale files (`src/i18n/locales/`)
- **Translations**: Any UI text changes must be reflected in all 9 locale files (`src/i18n/locales/`)
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
+15 -15
View File
@@ -26,7 +26,7 @@
## Features
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), with advanced fingerprint spoofing
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- **VPN support** — WireGuard configs per profile
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.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.24.4/Donut_0.24.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -149,6 +149,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>yb403</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
@@ -156,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>drunkod</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/JorySeverijnse">
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
@@ -163,21 +172,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Jory Severijnse</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/ThiagoMafra-Integrare">
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
<br />
<sub><b>Thiago Mafra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td>
</tr>
<tbody>
+30 -3
View File
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import {
type CanActivate,
type ExecutionContext,
@@ -10,6 +11,13 @@ import type { Request } from "express";
import * as jwt from "jsonwebtoken";
import type { UserContext } from "./user-context.interface.js";
/** Constant-time string compare; false on length mismatch (no early return). */
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
@Injectable()
export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
@@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate {
// Try SYNC_TOKEN first (self-hosted mode)
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
if (expectedToken && token === expectedToken) {
if (expectedToken && safeEqual(token, expectedToken)) {
(request as unknown as Record<string, unknown>).user = {
mode: "self-hosted",
prefix: "",
@@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate {
algorithms: ["RS256"],
}) as jwt.JwtPayload;
// Validate the scope claims' SHAPE before trusting them as S3 key
// prefixes. An empty/over-broad prefix would make validateKeyAccess
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
// bug or permissive claim must not silently widen scope.
const prefix = decoded.prefix || `users/${decoded.sub}/`;
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
}
const teamPrefix =
decoded.teamPrefix === undefined || decoded.teamPrefix === null
? null
: decoded.teamPrefix;
if (
teamPrefix !== null &&
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
) {
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
}
(request as unknown as Record<string, unknown>).user = {
mode: "cloud",
prefix: decoded.prefix || `users/${decoded.sub}/`,
teamPrefix: decoded.teamPrefix || null,
prefix,
teamPrefix,
profileLimit: decoded.profileLimit || 0,
teamProfileLimit: decoded.teamProfileLimit || 0,
} satisfies UserContext;
+9 -1
View File
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import {
Body,
Controller,
@@ -9,6 +10,13 @@ import {
import { ConfigService } from "@nestjs/config";
import { SyncService } from "./sync.service.js";
/** Constant-time string compare; false on length mismatch. */
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
@Controller("v1/internal")
export class InternalController {
private readonly internalKey: string | undefined;
@@ -26,7 +34,7 @@ export class InternalController {
@Headers("x-internal-key") key: string,
@Body() body: { userId: string; maxProfiles: number },
) {
if (!this.internalKey || key !== this.internalKey) {
if (!this.internalKey || !key || !safeEqual(key, this.internalKey)) {
throw new UnauthorizedException("Invalid internal key");
}
+34 -5
View File
@@ -54,6 +54,29 @@ import type {
*/
const MANIFEST_KEY = ".donut-sync-manifest";
/** Max presigned-URL lifetime. The client requests ~1h; never mint a URL that
* outlives this, regardless of a (possibly hostile) client-supplied expiresIn. */
const MAX_PRESIGN_EXPIRES_IN = 3600;
/** Clamp a client-supplied expiresIn to a sane positive range. */
function clampExpiresIn(requested: number | undefined): number {
const v = typeof requested === "number" && requested > 0 ? requested : 3600;
return Math.min(v, MAX_PRESIGN_EXPIRES_IN);
}
/** Only this metadata key is meaningful to sync (LWW conflict resolution).
* Whitelisting prevents a client from signing arbitrary x-amz-meta-* values. */
function sanitizeMetadata(
metadata: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!metadata) return undefined;
const out: Record<string, string> = {};
if (typeof metadata["updated-at"] === "string") {
out["updated-at"] = metadata["updated-at"];
}
return Object.keys(out).length > 0 ? out : undefined;
}
@Injectable()
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
@@ -286,16 +309,19 @@ export class SyncService implements OnModuleInit {
await this.checkProfileLimit(ctx);
}
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
// Whitelist metadata to the single key sync relies on, so a client can't
// sign arbitrary x-amz-meta-* values into its objects.
const metadata = sanitizeMetadata(dto.metadata);
const command = new PutCmd({
Bucket: this.bucket,
Key: key,
ContentType: dto.contentType || "application/octet-stream",
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
// exactly these headers on the PUT, so we echo them in the response.
Metadata: dto.metadata,
Metadata: metadata,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
@@ -313,6 +339,9 @@ export class SyncService implements OnModuleInit {
return {
url,
expiresAt: expiresAt.toISOString(),
// Echo the metadata we actually signed so the client sends matching
// x-amz-meta-* headers on the PUT (S3 rejects unsigned ones).
metadata,
};
}
@@ -323,7 +352,7 @@ export class SyncService implements OnModuleInit {
const key = this.scopeKey(ctx, dto.key);
this.validateKeyAccess(ctx, key);
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
const command = new GetObjectCommand({
@@ -438,7 +467,7 @@ export class SyncService implements OnModuleInit {
await this.checkProfileLimit(ctx);
}
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
const items = await Promise.all(
@@ -491,7 +520,7 @@ export class SyncService implements OnModuleInit {
dto: PresignDownloadBatchRequestDto,
ctx: UserContext,
): Promise<PresignDownloadBatchResponseDto> {
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
const items = await Promise.all(
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1767767207,
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.24.4";
releaseVersion = "0.26.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage";
hash = "sha256-YNXPed96GmuMhJVERxa2gYtiaQoMfdB0az5O5J0b/No=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage";
hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage";
hash = "sha256-kdEzMO53bCUH7E8GPDewnIDLRIO5pWlO8B4TdpLAQIg=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
}
else
null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.4",
"version": "0.26.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+99 -111
View File
@@ -31,9 +31,9 @@ dependencies = [
[[package]]
name = "aes"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
dependencies = [
"cipher 0.5.2",
"cpubits",
@@ -169,7 +169,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -745,9 +745,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "8.0.2"
version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -756,9 +756,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -971,9 +971,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.62"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1068,9 +1068,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -1709,7 +1709,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1726,9 +1726,9 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
@@ -1784,9 +1784,9 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.24.4"
version = "0.26.0"
dependencies = [
"aes 0.9.0",
"aes 0.9.1",
"aes-gcm",
"argon2",
"async-socks5",
@@ -1827,7 +1827,7 @@ dependencies = [
"quick-xml 0.40.1",
"rand 0.10.1",
"regex-lite",
"reqwest 0.13.3",
"reqwest 0.13.4",
"resvg",
"ring",
"rusqlite",
@@ -1838,6 +1838,7 @@ dependencies = [
"sha2 0.11.0",
"shadowsocks",
"smoltcp",
"subtle",
"sys-locale",
"sysinfo",
"tar",
@@ -2097,7 +2098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2862,14 +2863,17 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.11.0"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
dependencies = [
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -2936,9 +2940,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@@ -2996,9 +3000,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -3429,9 +3433,9 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.24"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [
"jiff-static",
"log",
@@ -3442,9 +3446,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.24"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [
"proc-macro2",
"quote",
@@ -3621,9 +3625,9 @@ dependencies = [
[[package]]
name = "libfuzzer-sys"
version = "0.4.12"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
@@ -3647,18 +3651,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.16"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
dependencies = [
"cc",
"pkg-config",
@@ -3688,9 +3692,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
dependencies = [
"value-bag",
]
@@ -3799,9 +3803,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
@@ -3849,9 +3853,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
@@ -3910,7 +3914,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4447,7 +4451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5323,9 +5327,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.13.3"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -5496,9 +5500,9 @@ dependencies = [
[[package]]
name = "rusqlite"
version = "0.39.0"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
@@ -5561,7 +5565,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5636,15 +5640,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -5711,12 +5706,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "seahash"
version = "4.1.0"
@@ -5916,9 +5905,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -5936,9 +5925,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"proc-macro2",
@@ -5961,24 +5950,23 @@ dependencies = [
[[package]]
name = "serial_test"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
dependencies = [
"futures-executor",
"futures-util",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
dependencies = [
"proc-macro2",
"quote",
@@ -6111,9 +6099,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
@@ -6225,12 +6213,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6302,9 +6290,9 @@ dependencies = [
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
@@ -6446,9 +6434,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.39.2"
version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
"libc",
"memchr",
@@ -6598,7 +6586,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.3",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -6952,7 +6940,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -6980,7 +6968,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7482,7 +7470,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7542,9 +7530,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
@@ -7554,7 +7542,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7642,9 +7630,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-vo"
@@ -7802,9 +7790,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -8212,7 +8200,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8738,7 +8726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9018,9 +9006,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yoke"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -9041,9 +9029,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [
"async-broadcast",
"async-executor",
@@ -9076,9 +9064,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -9102,18 +9090,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
@@ -9309,9 +9297,9 @@ dependencies = [
[[package]]
name = "zvariant"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
dependencies = [
"endi",
"enumflags2",
@@ -9323,9 +9311,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -9336,9 +9324,9 @@ dependencies = [
[[package]]
name = "zvariant_utils"
version = "3.3.1"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
dependencies = [
"proc-macro2",
"quote",
+5 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.4"
version = "0.26.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -81,9 +81,10 @@ aes-gcm = "0.10"
aes = "0.9"
cbc = "0.2"
ring = "0.17"
subtle = "2"
sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
hyper = { version = "1.8", features = ["full"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
clap = { version = "4", features = ["derive"] }
@@ -94,7 +95,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
# Wayfern CDP integration
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
rusqlite = { version = "0.39", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
serde_yaml = "0.9"
toml = "1.1"
thiserror = "2.0"
@@ -139,7 +140,7 @@ windows = { version = "0.62", features = [
[dev-dependencies]
tempfile = "3.24.0"
wiremock = "0.6"
hyper = { version = "1.8", features = ["full"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
tower = "0.5"
+164 -36
View File
@@ -58,13 +58,25 @@ pub struct ApiProfileResponse {
pub struct CreateProfileRequest {
pub name: String,
pub browser: String,
pub version: String,
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
/// version of the chosen browser. A concrete version must already be
/// downloaded; the create path does not fetch new versions.
#[serde(default)]
pub version: Option<String>,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
/// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`.
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
/// generated automatically at creation. Provide a `fingerprint` field to
/// pin a specific one.
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
/// generated automatically at creation. Provide a `fingerprint` field to
/// pin a specific one.
#[schema(value_type = Object)]
pub wayfern_config: Option<serde_json::Value>,
pub group_id: Option<String>,
@@ -74,7 +86,9 @@ pub struct CreateProfileRequest {
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
pub name: Option<String>,
pub browser: Option<String>,
// No `browser` field: a profile's engine is fixed at creation (changing it
// would invalidate the generated fingerprint and on-disk profile dir).
// Accepting it here only to silently ignore it misled API clients.
pub version: Option<String>,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
@@ -405,6 +419,9 @@ impl ApiServer {
let api = ApiDoc::openapi();
let v1_routes = v1_routes
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
.layer(middleware::from_fn(rate_limit_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
@@ -508,8 +525,14 @@ async fn auth_middleware(
}
};
// Compare tokens
if token != stored_token {
// Constant-time comparison so the auth check doesn't leak the shared-prefix
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
// lengths simply compare unequal.
use subtle::ConstantTimeEq;
let token_bytes = token.as_bytes();
let stored_bytes = stored_token.as_bytes();
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
if !matches {
log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED);
}
@@ -550,6 +573,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
response
}
/// Chokepoint for the future per-hour automation request limit. The limit
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
/// this middleware is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated requests per rolling hour and
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
async fn rate_limit_middleware(
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
Ok(next.run(request).await)
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
@@ -586,22 +623,12 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
Ok(server_guard.get_port())
}
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response,
/// dropping the `fingerprint` field unless the user has an active paid plan.
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated
/// API/MCP callers) must never receive it. `is_paid` is resolved once per
/// handler via `has_active_paid_subscription()`.
fn config_to_api_value<T: serde::Serialize>(
config: Option<&T>,
is_paid: bool,
) -> Option<serde_json::Value> {
let mut value = serde_json::to_value(config?).ok()?;
if !is_paid {
if let Some(obj) = value.as_object_mut() {
obj.remove("fingerprint");
}
}
Some(value)
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response.
/// Viewing a profile's fingerprint is available to every API caller; only
/// editing it (via `update_profile`) and launching/killing profiles
/// programmatically require an active paid plan.
fn config_to_api_value<T: serde::Serialize>(config: Option<&T>) -> Option<serde_json::Value> {
serde_json::to_value(config?).ok()
}
// API Handlers - Profiles
@@ -620,9 +647,6 @@ fn config_to_api_value<T: serde::Serialize>(
)]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
let api_profiles: Vec<ApiProfile> = profiles
@@ -637,7 +661,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -677,9 +701,6 @@ async fn get_profile(
State(_state): State<ApiServerState>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
@@ -694,7 +715,7 @@ async fn get_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -710,14 +731,24 @@ async fn get_profile(
}
}
/// Create a profile.
///
/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected
/// with 400.
/// - `version` is optional: omit it or pass `"latest"` to use the newest
/// already-downloaded version of that browser. The version must be present
/// locally (this endpoint does not download new versions); 400 if none is.
/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an
/// empty object `{}`, generates a fresh fingerprint automatically.
#[utoipa::path(
post,
path = "/v1/profiles",
request_body = CreateProfileRequest,
responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Bad request"),
(status = 400, description = "Invalid browser, or no downloaded version available"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Selected proxy requires payment"),
(status = 500, description = "Internal server error")
),
security(
@@ -730,9 +761,34 @@ async fn create_profile(
Json(request): Json<CreateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
// (fingerprint generation, launch, run) supports nothing else. Reject anything
// else up front — otherwise the profile is created with no fingerprint and an
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
// create_profile validation.
if request.browser != "wayfern" && request.browser != "camoufox" {
return Err(StatusCode::BAD_REQUEST);
}
// Resolve the version. Omitted, empty, or "latest" means "newest version
// already downloaded for this browser". The create path generates the
// fingerprint by launching that binary, so the version must be present
// locally — we don't fetch new versions here. 400 if none is downloaded.
let version = match request.version.as_deref() {
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
_ => {
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let mut versions = registry.get_downloaded_versions(&request.browser);
// browsers is a HashMap, so keys are unordered — sort newest-first by
// semver before taking the latest.
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
match versions.into_iter().next() {
Some(v) => v,
None => return Err(StatusCode::BAD_REQUEST),
}
}
};
// Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config {
@@ -766,7 +822,7 @@ async fn create_profile(
&state.app_handle,
&request.name,
&request.browser,
&request.version,
&version,
request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(),
request.vpn_id.clone(),
@@ -809,7 +865,7 @@ async fn create_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type,
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
@@ -914,6 +970,14 @@ async fn update_profile(
}
if let Some(camoufox_config) = request.camoufox_config {
// Editing a profile's fingerprint config is part of the cross-OS fingerprint
// capability (GUI, API, MCP). Viewing it is free; mutating it is not.
if !crate::cloud_auth::CLOUD_AUTH
.can_use_cross_os_fingerprints()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
match config {
Ok(config) => {
@@ -1732,7 +1796,7 @@ async fn run_profile(
Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
@@ -1818,7 +1882,7 @@ async fn open_url_in_profile(
Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
@@ -1844,6 +1908,7 @@ async fn open_url_in_profile(
responses(
(status = 204, description = "Browser process killed successfully"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan required"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
@@ -1856,6 +1921,15 @@ async fn kill_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
// Programmatically launching and stopping profiles is a paid feature; the
// run/open-url handlers gate the same way.
if !crate::cloud_auth::CLOUD_AUTH
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
@@ -2091,3 +2165,57 @@ async fn refresh_wayfern_token(
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token }))
}
#[cfg(test)]
mod tests {
use super::*;
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
// `browser` values on create, must NOT make the API reject requests that
// carry extra/unknown fields — old clients still send them. serde ignores
// unknown fields by default; these tests lock that in so a future
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
#[test]
fn update_profile_request_ignores_unknown_fields() {
// `browser` is no longer a field, plus a wholly unknown field. Both must
// be accepted and ignored, not rejected.
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
let parsed: UpdateProfileRequest =
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
assert_eq!(parsed.name.as_deref(), Some("p"));
}
#[test]
fn create_profile_request_ignores_unknown_fields() {
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
let parsed: CreateProfileRequest =
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
assert_eq!(parsed.browser, "wayfern");
}
#[test]
fn create_profile_request_allows_omitting_version_and_configs() {
// Minimal body: no version, no wayfern_config/camoufox_config. Must
// deserialize (version resolves to latest-downloaded at the handler; an
// absent config triggers fresh-fingerprint generation).
let json = r#"{"name": "p", "browser": "wayfern"}"#;
let parsed: CreateProfileRequest =
serde_json::from_str(json).expect("version and configs are optional");
assert_eq!(parsed.browser, "wayfern");
assert!(parsed.version.is_none());
assert!(parsed.wayfern_config.is_none());
assert!(parsed.camoufox_config.is_none());
}
#[test]
fn create_profile_browser_validation_matches_supported_engines() {
// The handler rejects anything that isn't a launchable engine; this is the
// same predicate it uses, kept in lockstep with MCP's create_profile.
let is_valid = |b: &str| b == "wayfern" || b == "camoufox";
assert!(is_valid("wayfern"));
assert!(is_valid("camoufox"));
assert!(!is_valid("chromium"));
assert!(!is_valid("firefox"));
assert!(!is_valid(""));
}
}
+189 -23
View File
@@ -21,6 +21,76 @@ use crate::sync;
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
/// rate-limit chokepoints in api_server / mcp_server.
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
/// Capability + limit set the account is entitled to, derived from its plan.
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
/// flags instead of a single "is paid?" boolean, so a plan like the future
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
/// data here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entitlements {
#[serde(default)]
pub active: bool,
#[serde(rename = "browserAutomation", default)]
pub browser_automation: bool,
#[serde(rename = "crossOsFingerprints", default)]
pub cross_os_fingerprints: bool,
#[serde(rename = "cloudBackup", default)]
pub cloud_backup: bool,
#[serde(rename = "teamCollaboration", default)]
pub team_collaboration: bool,
#[serde(rename = "profileLimit", default)]
pub profile_limit: i64,
#[serde(rename = "requestsPerHour", default)]
pub requests_per_hour: i64,
}
/// Local fallback mirror of the backend plan -> capability matrix, used only when
/// the server hasn't sent an entitlements object (older cached state / backend).
fn derive_entitlements(
plan: &str,
plan_period: Option<&str>,
subscription_status: &str,
profile_limit: i64,
) -> Entitlements {
let active =
plan != "free" && (subscription_status == "active" || plan_period == Some("lifetime"));
if !active {
return Entitlements {
active: false,
browser_automation: false,
cross_os_fingerprints: false,
cloud_backup: false,
team_collaboration: false,
profile_limit: 0,
requests_per_hour: 0,
};
}
// pro and any unrecognized paid plan -> pro-level (never team).
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
"starter" => (false, true, true, false),
"team" | "enterprise" => (true, true, true, true),
_ => (true, true, true, false),
};
Entitlements {
active,
browser_automation,
cross_os_fingerprints,
cloud_backup,
team_collaboration,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
} else {
0
},
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudUser {
pub id: String,
@@ -56,6 +126,26 @@ pub struct CloudUser {
pub device_count: Option<i64>,
#[serde(rename = "isPrimaryDevice", default)]
pub is_primary_device: Option<bool>,
/// Capability/limit set derived from the plan by the backend. `default` (None)
/// keeps older login/state payloads deserializing; resolve via `entitlements()`.
#[serde(default)]
pub entitlements: Option<Entitlements>,
}
impl CloudUser {
/// Authoritative entitlements: the server-sent set when present, else derived
/// locally from the plan fields (keeps older cached state / backends working).
pub fn entitlements(&self) -> Entitlements {
if let Some(e) = &self.entitlements {
return e.clone();
}
derive_entitlements(
&self.plan,
self.plan_period.as_deref(),
&self.subscription_status,
self.profile_limit,
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -658,39 +748,83 @@ impl CloudAuthManager {
state.is_some()
}
pub async fn has_active_paid_subscription(&self) -> bool {
/// Resolve this session's entitlements (server-sent or locally derived).
pub async fn entitlements(&self) -> Option<Entitlements> {
let state = self.state.lock().await;
match &*state {
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
}
state.as_ref().map(|auth| auth.user.entitlements())
}
/// Account is in a paid/active state. Used for the "any active plan" gates
/// (sync token, wayfern token); per-feature access uses the capability helpers.
pub async fn has_active_paid_subscription(&self) -> bool {
self.entitlements().await.map(|e| e.active).unwrap_or(false)
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() {
Ok(state) => match &*state {
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
},
Ok(state) => state
.as_ref()
.map(|auth| auth.user.entitlements().active)
.unwrap_or(false),
Err(_) => false,
}
}
/// Launch/drive profiles programmatically (local API + MCP automation).
pub async fn can_use_browser_automation(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.browser_automation)
.unwrap_or(false)
}
/// Edit fingerprints / use a non-native OS fingerprint.
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.cross_os_fingerprints)
.unwrap_or(false)
}
/// Cloud profile sync / backup (async).
pub async fn can_use_cloud_backup(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.cloud_backup)
.unwrap_or(false)
}
/// Cloud profile sync / backup (non-async, try_lock; false if unavailable).
pub fn can_use_cloud_backup_sync(&self) -> bool {
match self.state.try_lock() {
Ok(state) => state
.as_ref()
.map(|auth| auth.user.entitlements().cloud_backup)
.unwrap_or(false),
Err(_) => false,
}
}
/// Per-hour cap on automation requests (0 when automation is unavailable).
/// Carried for the future local rate limiter; read by the inert chokepoints.
pub async fn requests_per_hour(&self) -> i64 {
self
.entitlements()
.await
.map(|e| e.requests_per_hour)
.unwrap_or(0)
}
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
let host_os = crate::profile::types::get_host_os();
match fingerprint_os {
None => true,
Some(os) if os == host_os => true,
Some(_) => self.has_active_paid_subscription().await,
Some(_) => self.can_use_cross_os_fingerprints().await,
}
}
@@ -1016,7 +1150,7 @@ impl CloudAuthManager {
return Ok(());
}
let token = self
let result = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
// Bound the request: without a timeout, an unreachable
@@ -1050,7 +1184,31 @@ impl CloudAuthManager {
Ok(result.token)
}
})
.await?;
.await;
let token = match result {
Ok(token) => token,
Err(e) => {
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
// token-reuse throttle, "active subscription required", and the
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
// This is distinct from a 401 (dead access token) — the session is still
// valid, the user is just temporarily/conditionally not entitled. So we
// do NOT invalidate the session. Instead: drop the stale wayfern token so
// no browser launches half-authenticated, re-fetch the profile so the
// cached plan reflects the backend's real state (it may have changed),
// and signal the UI so the user learns why automation stopped working.
if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403): {e}");
self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
}
let _ = crate::events::emit_empty("wayfern-paid-blocked");
}
return Err(e);
}
};
let mut wt = self.wayfern_token.lock().await;
*wt = Some(token);
@@ -1184,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
app_handle: tauri::AppHandle,
code: String,
) -> Result<CloudAuthState, String> {
let state = CLOUD_AUTH.exchange_device_code(&code).await?;
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
log::info!(
@@ -1219,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
let _ = crate::events::emit_empty("cloud-auth-changed");
let _ = &app_handle;
state.user.entitlements = Some(state.user.entitlements());
Ok(state)
}
#[tauri::command]
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
Ok(CLOUD_AUTH.get_user().await)
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
// Always hand the frontend a resolved entitlements object so it never has to
// derive capabilities itself (covers older cached state with no entitlements).
state.user.entitlements = Some(state.user.entitlements());
state
}))
}
#[tauri::command]
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
CLOUD_AUTH.fetch_profile().await
let mut user = CLOUD_AUTH.fetch_profile().await?;
user.entitlements = Some(user.entitlements());
Ok(user)
}
#[tauri::command]
+135 -32
View File
@@ -152,11 +152,11 @@ impl McpServer {
self.is_running.load(Ordering::SeqCst)
}
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
// Log the failed gate so customer logs explain why an MCP tool returned
// an error. Include enough state (logged-in vs not, plan, status) for
// support to diagnose without leaking secrets.
/// Gate an MCP tool on a capability the caller already resolved (e.g.
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
/// with enough state for support to diagnose, without leaking secrets.
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
if !allowed {
let summary = match CLOUD_AUTH.get_user().await {
Some(state) => format!(
"logged_in=true plan={} status={} period={:?}",
@@ -164,10 +164,10 @@ impl McpServer {
),
None => "logged_in=false".to_string(),
};
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
return Err(McpError {
code: -32000,
message: format!("{feature} requires an active paid subscription"),
message: format!("{feature} requires a plan that includes this feature"),
});
}
Ok(())
@@ -286,6 +286,9 @@ impl McpServer {
.delete(Self::handle_mcp_delete),
)
.route("/health", get(Self::handle_health))
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
.layer(middleware::from_fn(Self::rate_limit_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
Self::auth_middleware,
@@ -316,6 +319,17 @@ impl McpServer {
}
}
/// Chokepoint for the future per-hour automation request limit, mirroring the
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
/// entitlements; this is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated tool calls per rolling hour
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
Ok(next.run(req).await)
}
async fn auth_middleware(
State(state): State<McpHttpState>,
req: Request<Body>,
@@ -339,8 +353,16 @@ impl McpServer {
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
let valid =
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
// Constant-time comparison to avoid leaking the token prefix via timing.
use subtle::ConstantTimeEq;
let expected = state.token.as_bytes();
let ct_eq = |t: Option<&str>| {
t.is_some_and(|t| {
let b = t.as_bytes();
b.len() == expected.len() && b.ct_eq(expected).into()
})
};
let valid = ct_eq(path_token) || ct_eq(header_token);
if !valid {
return Err(StatusCode::UNAUTHORIZED);
@@ -508,7 +530,7 @@ impl McpServer {
},
McpTool {
name: "run_profile".to_string(),
description: "Launch a browser profile with an optional URL".to_string(),
description: "Launch a browser profile with an optional URL. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -530,7 +552,7 @@ impl McpServer {
},
McpTool {
name: "kill_profile".to_string(),
description: "Stop a running browser profile".to_string(),
description: "Stop a running browser profile. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -1639,10 +1661,21 @@ impl McpServer {
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_run_profile(arguments).await
}
"kill_profile" => self.handle_kill_profile(arguments).await,
"kill_profile" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_kill_profile(arguments).await
}
"create_profile" => self.handle_create_profile(arguments).await,
"update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_profile(arguments).await,
@@ -1671,13 +1704,16 @@ impl McpServer {
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management — viewing and editing both require a paid plan.
"get_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_get_profile_fingerprint(arguments).await
}
// Fingerprint management — viewing is free everywhere (matches the REST
// API and the get_profile tool, which already expose the config); only
// editing requires a paid plan.
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
Self::require_capability(
"Fingerprint editing",
CLOUD_AUTH.can_use_cross_os_fingerprints().await,
)
.await?;
self.handle_update_profile_fingerprint(arguments).await
}
"update_profile_proxy_bypass_rules" => {
@@ -1706,7 +1742,11 @@ impl McpServer {
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
// Synchronizer tools
"start_sync_session" => {
Self::require_paid_subscription("Synchronizer").await?;
Self::require_capability(
"Synchronizer",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_start_sync_session(arguments).await
}
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
@@ -1714,43 +1754,83 @@ impl McpServer {
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
// Browser interaction tools (require paid subscription)
"navigate" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_navigate(arguments).await
}
"screenshot" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_screenshot(arguments).await
}
"evaluate_javascript" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_evaluate_javascript(arguments).await
}
"click_element" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_click_element(arguments).await
}
"type_text" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_text(arguments).await
}
"get_page_content" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_page_content(arguments).await
}
"get_page_info" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_page_info(arguments).await
}
"get_interactive_elements" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_interactive_elements(arguments).await
}
"click_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_click_by_index(arguments).await
}
"type_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_by_index(arguments).await
}
_ => Err(McpError {
@@ -1829,6 +1909,13 @@ impl McpServer {
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Launching profiles programmatically requires the automation capability.
Self::require_capability(
"Launching a profile",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
@@ -1910,6 +1997,13 @@ impl McpServer {
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Stopping profiles programmatically requires the automation capability.
Self::require_capability(
"Killing a profile",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
@@ -2586,6 +2680,15 @@ impl McpServer {
message: "Missing proxy_type".to_string(),
})?;
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
// enforce it here so a bad value can't produce a non-functional proxy.
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
});
}
let host = arguments
.get("host")
.and_then(|v| v.as_str())
@@ -3237,10 +3340,10 @@ impl McpServer {
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
if !CLOUD_AUTH.can_use_cross_os_fingerprints().await {
return Err(McpError {
code: -32000,
message: "Fingerprint editing requires an active Pro subscription".to_string(),
message: "Fingerprint editing requires a plan that includes it".to_string(),
});
}
+38 -19
View File
@@ -3,6 +3,42 @@ use crate::profile::BrowserProfile;
use std::path::Path;
use std::process::Command;
/// True if a process command line refers to `profile_path` as a real browser
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
/// force-killed unrelated processes that happened to mention the path (editors,
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
///
/// Only the macOS and Linux process-kill paths use this; Windows has no
/// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
for (i, arg) in args.iter().enumerate() {
// Exact argument equality (Firefox/Camoufox: `-profile <path>`; some launchers
// pass the path as its own arg).
if *arg == profile_path {
return true;
}
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
if let Some(val) = arg
.strip_prefix("--user-data-dir=")
.or_else(|| arg.strip_prefix("-profile="))
{
if val == profile_path {
return true;
}
}
// Flag followed by the path as the next argument.
if (*arg == "-profile" || *arg == "--user-data-dir")
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
{
return true;
}
}
false
}
// Platform-specific modules
#[cfg(target_os = "macos")]
#[allow(dead_code)]
@@ -215,16 +251,7 @@ pub mod macos {
continue;
}
// Check if any command line argument contains the profile path
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
}
@@ -832,15 +859,7 @@ pub mod linux {
continue;
}
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
}
+18 -3
View File
@@ -1035,7 +1035,7 @@ impl ProfileManager {
fs::create_dir_all(&dest_dir)?;
}
let new_profile = BrowserProfile {
let mut new_profile = BrowserProfile {
id: new_id,
name: clone_name,
browser: source.browser,
@@ -1071,6 +1071,21 @@ impl ProfileManager {
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Donut: 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
// signals as the source — trivially linkable if both run concurrently. Clear
// the fingerprint so the launch path mints a fresh one (a new
// canvas_noise_seed via RandBytes + an independent device fingerprint),
// exactly as create_profile does when fingerprint.is_none(). NOTE: the
// user-data-dir copy above still duplicates cookies/localStorage/TLS state —
// a separate storage-linkage vector the user must clear if they want full
// isolation between a clone and its source.
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
cfg.fingerprint = None;
}
self.save_profile(&new_profile)?;
if let Err(e) = events::emit_empty("profiles-changed") {
@@ -2501,7 +2516,7 @@ pub async fn update_camoufox_config(
) -> Result<(), String> {
if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_cross_os_fingerprints()
.await
{
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
@@ -2529,7 +2544,7 @@ pub async fn update_wayfern_config(
) -> Result<(), String> {
if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_cross_os_fingerprints()
.await
{
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
+18 -415
View File
@@ -2,7 +2,7 @@ use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use std::path::Path;
use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
}
fn map_browser_type(browser: &str) -> &str {
// Firefox-based sources map to the now-deprecated Camoufox. They are no longer
// detected for import; the mapping is kept only so the import command can
// recognize and REJECT them. Everything else maps to Wayfern.
match browser {
"firefox" | "firefox-developer" | "zen" => "camoufox",
"chromium" | "brave" => "wayfern",
"camoufox" => "camoufox",
"wayfern" => "wayfern",
"firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
_ => "wayfern",
}
}
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
profile_manager: &'static ProfileManager,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
@@ -44,7 +43,6 @@ impl ProfileImporter {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
profile_manager: ProfileManager::instance(),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
}
}
@@ -58,12 +56,12 @@ impl ProfileImporter {
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new();
detected_profiles.extend(self.detect_firefox_profiles()?);
// Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox,
// which is deprecated — they can no longer be imported. Only Chromium-based
// sources (mapping to Wayfern) are detected.
detected_profiles.extend(self.detect_chrome_profiles()?);
detected_profiles.extend(self.detect_brave_profiles()?);
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
detected_profiles.extend(self.detect_chromium_profiles()?);
detected_profiles.extend(self.detect_zen_browser_profiles()?);
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
@@ -74,80 +72,6 @@ impl ProfileImporter {
Ok(unique_profiles)
}
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let firefox_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
let local_app_data = self.base_dirs.data_local_dir();
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
if firefox_local_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
}
}
#[cfg(target_os = "linux")]
{
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
Ok(profiles)
}
fn detect_firefox_developer_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let firefox_dev_alt_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox Developer Edition/Profiles");
if firefox_dev_alt_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
}
}
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
}
#[cfg(target_os = "linux")]
{
let firefox_dev_dir = self
.base_dirs
.home_dir()
.join(".mozilla/firefox-dev-edition");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
}
Ok(profiles)
}
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -235,191 +159,6 @@ impl ProfileImporter {
Ok(profiles)
}
fn detect_zen_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let zen_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
let zen_dir = app_data.join("Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[cfg(target_os = "linux")]
{
let zen_dir = self.base_dirs.home_dir().join(".zen");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
Ok(profiles)
}
fn scan_firefox_profiles_dir(
&self,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
if !profiles_dir.exists() {
return Ok(profiles);
}
let profiles_ini = profiles_dir
.parent()
.unwrap_or(profiles_dir)
.join("profiles.ini");
if profiles_ini.exists() {
if let Ok(content) = fs::read_to_string(&profiles_ini) {
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
}
}
if let Ok(entries) = fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let prefs_file = path.join("prefs.js");
if prefs_file.exists() {
let profile_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown Profile");
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
if !already_added {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} Profile - {}",
self.get_browser_display_name(browser_type),
profile_name
),
path: path.to_string_lossy().to_string(),
description: format!("Profile folder: {profile_name}"),
});
}
}
}
}
}
Ok(profiles)
}
fn parse_firefox_profiles_ini(
&self,
content: &str,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
let mut current_section = String::new();
let mut profile_name = String::new();
let mut profile_path = String::new();
let mut is_relative = true;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
current_section = line[1..line.len() - 1].to_string();
profile_name.clear();
profile_path.clear();
is_relative = true;
} else if line.contains('=') {
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() == 2 {
let key = parts[0].trim();
let value = parts[1].trim();
match key {
"Name" => profile_name = value.to_string(),
"Path" => profile_path = value.to_string(),
"IsRelative" => is_relative = value == "1",
_ => {}
}
}
}
}
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
Ok(profiles)
}
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
@@ -493,7 +232,7 @@ impl ProfileImporter {
browser_type: &str,
new_profile_name: &str,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
_camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
let source_path = Path::new(source_path);
@@ -529,88 +268,9 @@ impl ProfileImporter {
let version = self.get_default_version_for_browser(mapped)?;
let final_camoufox_config = if mapped == "camoufox" {
let mut config = camoufox_config.unwrap_or_default();
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
launch_hook: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
.camoufox_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
// Camoufox import is removed; only Wayfern profiles are imported now, so the
// imported profile never carries a Camoufox config.
let final_camoufox_config: Option<CamoufoxConfig> = None;
let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default();
@@ -806,6 +466,12 @@ pub async fn import_browser_profile(
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), String> {
// Camoufox is deprecated — Firefox-based profiles (which map to Camoufox) can
// no longer be imported. Reject them before doing any work.
if map_browser_type(&browser_type) == "camoufox" {
return Err(serde_json::json!({ "code": "CAMOUFOX_IMPORT_DEPRECATED" }).to_string());
}
let fingerprint_os = camoufox_config
.as_ref()
.and_then(|c| c.os.as_deref())
@@ -897,24 +563,6 @@ mod tests {
let _profiles = result.unwrap();
}
#[test]
fn test_scan_firefox_profiles_dir_nonexistent() {
let (importer, temp_dir) = create_test_profile_importer();
let nonexistent_dir = temp_dir.path().join("nonexistent");
let result = importer.scan_firefox_profiles_dir(&nonexistent_dir, "firefox");
assert!(
result.is_ok(),
"Should handle nonexistent directory gracefully"
);
let profiles = result.unwrap();
assert!(
profiles.is_empty(),
"Should return empty vector for nonexistent directory"
);
}
#[test]
fn test_scan_chrome_profiles_dir_nonexistent() {
let (importer, temp_dir) = create_test_profile_importer();
@@ -933,51 +581,6 @@ mod tests {
);
}
#[test]
fn test_parse_firefox_profiles_ini_empty() {
let (importer, _temp_dir) = create_test_profile_importer();
let empty_content = "";
let profiles_dir = Path::new("/tmp");
let result = importer.parse_firefox_profiles_ini(empty_content, profiles_dir, "firefox");
assert!(result.is_ok(), "Should handle empty profiles.ini");
let profiles = result.unwrap();
assert!(
profiles.is_empty(),
"Should return empty vector for empty content"
);
}
#[test]
fn test_parse_firefox_profiles_ini_valid() {
let (importer, temp_dir) = create_test_profile_importer();
let profiles_dir = temp_dir.path().join("profiles");
let profile_dir = profiles_dir.join("test.profile");
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
let prefs_file = profile_dir.join("prefs.js");
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
let profiles_ini_content = r#"
[Profile0]
Name=Test Profile
IsRelative=1
Path=test.profile
"#;
let result =
importer.parse_firefox_profiles_ini(profiles_ini_content, &profiles_dir, "firefox");
assert!(result.is_ok(), "Should parse valid profiles.ini");
let profiles = result.unwrap();
assert_eq!(profiles.len(), 1, "Should find one profile");
assert_eq!(profiles[0].name, "Firefox - Test Profile");
assert_eq!(profiles[0].browser, "firefox");
assert_eq!(profiles[0].mapped_browser, "camoufox");
}
#[test]
fn test_copy_directory_recursive() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
+33 -11
View File
@@ -774,6 +774,17 @@ impl ProxyManager {
list
}
/// Insert/replace a stored proxy in the in-memory map. Used by sync's
/// download_proxy after it writes the file to disk, mirroring how
/// download_group/download_vpn/download_extension keep their managers'
/// in-memory state in sync. Without this, get_stored_proxies (which reads
/// only the map) never sees a downloaded proxy until restart, so sync keeps
/// re-downloading it indefinitely.
pub fn upsert_stored_proxy(&self, proxy: StoredProxy) {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(proxy.id.clone(), proxy);
}
// Get a stored proxy by ID
// Update a stored proxy
@@ -1730,12 +1741,18 @@ impl ProxyManager {
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
// We still return Ok since we've already removed the proxy from our tracking
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
// not panic the cleanup task — the proxy is already removed from tracking,
// so degrade gracefully like the non-success branch below.
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
}
// Clear profile-to-proxy mapping if it references this proxy
@@ -1795,11 +1812,16 @@ impl ProxyManager {
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
// Don't panic if the sidecar can't be spawned — still clear the mapping.
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
}
// Clear profile-to-proxy mapping
+25 -41
View File
@@ -509,47 +509,20 @@ async fn handle_http_via_socks4(
}
};
// Resolve target host to IP (SOCKS4 requires IP addresses)
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
match addr.ip() {
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
std::net::IpAddr::V6(_) => {
log::error!("SOCKS4 does not support IPv6");
let mut response = Response::new(Full::new(Bytes::from(
"SOCKS4 does not support IPv6 addresses",
)));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
} else {
log::error!("Failed to resolve target host: {}", target_host);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
target_host
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
Err(e) => {
log::error!("Failed to resolve target host {}: {}", target_host, e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
};
// Build SOCKS4 CONNECT request
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
// hostname locally: tokio::net::lookup_host would call the HOST resolver
// (getaddrinfo), leaking the destination domain to the host's DNS server and
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
// HTTP — prefer SOCKS5 there.)
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
socks_request.extend_from_slice(&target_port.to_be_bytes());
socks_request.extend_from_slice(&target_ip);
socks_request.push(0); // NULL terminator for userid
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
socks_request.push(0); // empty userid, NULL-terminated
socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve
socks_request.push(0); // NULL-terminated hostname
// Send SOCKS4 CONNECT request
if let Err(e) = socks_stream.write_all(&socks_request).await {
@@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy(
Proxy::http(upstream_url)?
}
"socks5" => {
// For SOCKS5, reqwest supports it directly
Proxy::all(upstream_url)?
// Donut: 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
// and defeating the per-profile proxy. The `socks5h` scheme maps to
// DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks.
// (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's
// AddrKind::Domain.)
let remote_dns_url = match upstream_url.strip_prefix("socks5://") {
Some(rest) => format!("socks5h://{rest}"),
None => upstream_url.to_string(),
};
Proxy::all(remote_dns_url)?
}
"socks4" => {
// SOCKS4 is handled manually in handle_http_via_socks4
+11 -1
View File
@@ -294,7 +294,10 @@ impl SyncProgressTracker {
/// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool {
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
// Cloud backup is a plan capability. Every paid plan (incl. the future
// "starter" tier) grants it, but gating on the capability — not just "is paid"
// — keeps this correct if a plan without cloud backup is ever added.
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
return true;
}
let manager = SettingsManager::instance();
@@ -1597,6 +1600,13 @@ impl SyncEngine {
))
})?;
// Keep the in-memory cache in sync with disk. Without this, get_stored_proxies
// (which reads only the in-memory map) never sees the downloaded proxy until
// restart, so check_for_missing_synced_entities/sync_proxy treat it as
// missing every pass and re-download it forever. Mirrors download_group/
// download_vpn/download_extension.
proxy_manager.upsert_stored_proxy(proxy.clone());
// Emit event for UI update
if let Some(_handle) = app_handle {
let _ = events::emit("stored-proxies-changed", ());
+127 -1
View File
@@ -138,6 +138,46 @@ impl WayfernManager {
fingerprint
}
/// Derive the on-screen window size Chromium should open at, from the stored
/// fingerprint. `Wayfern.setFingerprint` only spoofs what the page *reports*
/// for `windowOuterWidth`/`screenWidth`/etc.; it does not move or resize the
/// real top-level window. Without `--window-size` the OS window keeps
/// Chromium's default, so the visible window contradicts the reported
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
/// window matches the fingerprint.
///
/// Keys are the camelCase fields Wayfern uses in its fingerprint
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
/// Camoufox-style keys. Preference order, matching how the fingerprint
/// describes the window:
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
/// 3. `screenWidth` / `screenHeight` — full screen.
///
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
/// Chromium's default untouched. The fingerprint JSON may be the bare object
/// or the legacy `{ "fingerprint": {...} }` wrapper.
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
let obj = fp.as_object()?;
// Accept both numeric and stringified numbers (Wayfern emits numbers, but a
// CDP echo or older saved fingerprint may stringify them).
let read = |key: &str| -> Option<u32> {
let v = obj.get(key)?;
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
.filter(|n| *n > 0)
.map(|n| n as u32)
};
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
pair("windowOuterWidth", "windowOuterHeight")
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
.or_else(|| pair("screenWidth", "screenHeight"))
}
async fn wait_for_cdp_ready(
&self,
port: u16,
@@ -611,13 +651,30 @@ impl WayfernManager {
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
// re-enabled by an imported/synced network_prediction_options pref (which a
// compile-time pref default could be).
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
"--use-mock-keychain".to_string(),
"--password-store=basic".to_string(),
];
if headless {
args.push("--headless=new".to_string());
} else if let Some((w, h)) = config
.fingerprint
.as_deref()
.and_then(Self::window_size_from_fingerprint)
{
// Size the real OS window to match the fingerprint so the visible window
// agrees with the reported windowOuterWidth/screen dimensions. Anchor at
// 0,0 so the window also fits within the spoofed screen origin. Skipped in
// headless mode, where there is no on-screen window.
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
args.push(format!("--window-size={w},{h}"));
args.push("--window-position=0,0".to_string());
}
#[cfg(target_os = "linux")]
@@ -1198,3 +1255,72 @@ impl WayfernManager {
lazy_static::lazy_static! {
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_size_prefers_outer_window_dimensions() {
// Field names + values mirror a real Wayfern fingerprint (camelCase).
let fp = r#"{"windowOuterWidth": 1268, "windowOuterHeight": 764,
"windowInnerWidth": 1253, "windowInnerHeight": 630,
"screenAvailWidth": 1280, "screenAvailHeight": 775,
"screenWidth": 1280, "screenHeight": 800}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(fp),
Some((1268, 764))
);
}
#[test]
fn window_size_falls_back_to_avail_then_full_screen() {
let avail = r#"{"screenAvailWidth": 1280, "screenAvailHeight": 775,
"screenWidth": 1280, "screenHeight": 800}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(avail),
Some((1280, 775))
);
let full = r#"{"screenWidth": 2560, "screenHeight": 1440}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(full),
Some((2560, 1440))
);
}
#[test]
fn window_size_handles_wrapper_and_stringified_numbers() {
let wrapped = r#"{"fingerprint": {"windowOuterWidth": "1366", "windowOuterHeight": "768"}}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(wrapped),
Some((1366, 768))
);
}
#[test]
fn window_size_none_when_missing_or_invalid() {
// No dimensions at all.
assert_eq!(
WayfernManager::window_size_from_fingerprint(r#"{"userAgent": "x"}"#),
None
);
// A width with no matching height is not a usable pair.
assert_eq!(
WayfernManager::window_size_from_fingerprint(r#"{"windowOuterWidth": 1268}"#),
None
);
// Zero is rejected as a degenerate size.
assert_eq!(
WayfernManager::window_size_from_fingerprint(
r#"{"windowOuterWidth": 0, "windowOuterHeight": 0}"#
),
None
);
// Not valid JSON.
assert_eq!(
WayfernManager::window_size_from_fingerprint("not json"),
None
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.4",
"version": "0.26.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+24 -9
View File
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
import { CommandPalette } from "@/components/command-palette";
@@ -59,6 +60,7 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import {
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
@@ -225,10 +227,7 @@ export default function Home() {
// Cloud auth for cross-OS unlock
const { user: cloudUser } = useCloudAuth();
const crossOsUnlocked =
cloudUser?.plan !== "free" &&
(cloudUser?.subscriptionStatus === "active" ||
cloudUser?.planPeriod === "lifetime");
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
useState(false);
@@ -1168,11 +1167,14 @@ export default function Home() {
profileId: profile.id,
syncMode: enabling ? "Regular" : "Disabled",
});
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
description: enabling
? "Profile sync has been enabled"
: "Profile sync has been disabled",
});
showSuccessToast(
t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
{
description: t(
enabling ? "sync.enabledDescription" : "sync.disabledDescription",
),
},
);
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast(t("errors.updateSyncSettingsFailed"));
@@ -1325,6 +1327,7 @@ export default function Home() {
let unlistenStarted: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
let unlistenCompleted: (() => void) | undefined;
let unlistenWayfernBlocked: (() => void) | undefined;
void (async () => {
unlistenRequired = await listen(
@@ -1386,6 +1389,16 @@ export default function Home() {
duration: 5000,
});
});
unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => {
showToast({
id: "wayfern-paid-blocked",
type: "error",
title: t("wayfernBlocked.title"),
description: t("wayfernBlocked.description"),
duration: 15000,
});
});
})();
return () => {
@@ -1393,6 +1406,7 @@ export default function Home() {
unlistenStarted?.();
unlistenProgress?.();
unlistenCompleted?.();
unlistenWayfernBlocked?.();
};
}, [t]);
@@ -1512,6 +1526,7 @@ export default function Home() {
return (
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<CloseConfirmDialog />
<CamoufoxDeprecationDialog profiles={profiles} />
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
searchQuery={searchQuery}
+3 -2
View File
@@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { SyncSettings } from "@/types";
@@ -298,7 +299,7 @@ export function AccountPage({
{isLoggedIn &&
user &&
user.plan !== "free" &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
@@ -306,7 +307,7 @@ export function AccountPage({
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
@@ -0,0 +1,78 @@
"use client";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BrowserProfile } from "@/types";
import { RippleButton } from "./ui/ripple";
interface CamoufoxDeprecationDialogProps {
profiles: BrowserProfile[];
}
/**
* Warns users who still have Camoufox profiles that Camoufox support is ending.
* Shown once per app session (this component mounts for the app lifetime), only
* when at least one Camoufox profile exists. Not a toast a blocking dialog so
* the deprecation can't be missed.
*/
export function CamoufoxDeprecationDialog({
profiles,
}: CamoufoxDeprecationDialogProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [shown, setShown] = useState(false);
useEffect(() => {
if (shown) return;
const hasCamoufox = profiles.some((p) => p.browser === "camoufox");
if (hasCamoufox) {
setIsOpen(true);
setShown(true);
}
}, [profiles, shown]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning" />
{t("camoufoxDeprecation.title")}
</DialogTitle>
<DialogDescription>
{t("camoufoxDeprecation.description")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => {
void openUrl(
"https://github.com/zhom/donutbrowser/discussions/426",
);
}}
>
{t("common.buttons.learnMore")}
</RippleButton>
<RippleButton
onClick={() => {
setIsOpen(false);
}}
>
{t("camoufoxDeprecation.acknowledge")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+63 -304
View File
@@ -14,8 +14,6 @@ import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -56,15 +54,9 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type {
BrowserReleaseTypes,
CamoufoxConfig,
CamoufoxOS,
WayfernConfig,
WayfernOS,
} from "@/types";
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
const getCurrentOS = (): CamoufoxOS => {
const getCurrentOS = (): WayfernOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
@@ -86,7 +78,6 @@ interface CreateProfileDialogProps {
releaseType: string;
proxyId?: string;
vpnId?: string;
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
@@ -105,10 +96,6 @@ interface BrowserOption {
}
const browserOptions: BrowserOption[] = [
{
value: "camoufox",
label: "Camoufox",
},
{
value: "wayfern",
label: "Wayfern",
@@ -126,28 +113,24 @@ export function CreateProfileDialog({
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState("");
// Camoufox is deprecated: only Wayfern profiles can be created, so the dialog
// opens straight into the Wayfern config step (no browser-selection screen).
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
>("browser-selection");
>("browser-config");
const [activeTab, setActiveTab] = useState("anti-detect");
// Browser selection states
// Browser selection states. Defaults to Wayfern — the only creatable browser.
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>(null);
useState<BrowserTypeString>("wayfern");
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
const [launchHook, setLaunchHook] = useState("");
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
geoip: true, // Default to automatic geoip
os: getCurrentOS(), // Default to current OS
}));
// Wayfern anti-detect states
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
os: getCurrentOS() as WayfernOS, // Default to current OS
os: getCurrentOS(), // Default to current OS
}));
// Handle browser selection from the initial screen
@@ -156,22 +139,23 @@ export function CreateProfileDialog({
setCurrentStep("browser-config");
};
// Handle back button
const handleBack = () => {
setCurrentStep("browser-selection");
setSelectedBrowser(null);
// Reset the form fields without leaving the Wayfern config step — Camoufox is
// deprecated, so there is no browser-selection screen to go back to.
const resetForm = () => {
setSelectedBrowser("wayfern");
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
// Handle back button
const handleBack = () => {
resetForm();
};
const handleTabChange = (value: string) => {
setActiveTab(value);
setCurrentStep("browser-selection");
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
resetForm();
};
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
@@ -307,16 +291,15 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
// Load downloaded versions for both anti-detect browsers up front so the
// selection-screen availability gate is accurate before either is picked.
// Load downloaded Wayfern versions up front so the availability gate is
// accurate. Camoufox is deprecated and no longer creatable.
void loadDownloadedVersions("wayfern");
void loadDownloadedVersions("camoufox");
// Load release types when a browser is selected
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
}
// Check and download GeoIP database if needed for Camoufox or Wayfern
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
// Wayfern needs the GeoIP database for fingerprint generation.
if (selectedBrowser === "wayfern") {
void checkAndDownloadGeoIPDatabase();
}
}
@@ -417,66 +400,34 @@ export function CreateProfileDialog({
: undefined;
try {
if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected
if (selectedBrowser === "wayfern") {
const bestWayfernVersion = getCreatableVersion("wayfern");
if (!bestWayfernVersion) {
console.error("No Wayfern version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
const finalWayfernConfig = { ...wayfernConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "wayfern" as BrowserTypeString,
version: bestWayfernVersion.version,
releaseType: bestWayfernVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
} else {
// Default to Camoufox
const bestCamoufoxVersion = getCreatableVersion("camoufox");
if (!bestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
// We don't need to generate it here during profile creation
const finalCamoufoxConfig = { ...camoufoxConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
// Camoufox is deprecated — only Wayfern anti-detect profiles are created.
const bestWayfernVersion = getCreatableVersion("wayfern");
if (!bestWayfernVersion) {
console.error("No Wayfern version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
const finalWayfernConfig = { ...wayfernConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "wayfern" as BrowserTypeString,
version: bestWayfernVersion.version,
releaseType: bestWayfernVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
} else {
// Regular browser
if (!selectedBrowser) {
@@ -519,22 +470,19 @@ export function CreateProfileDialog({
// Cancel any ongoing loading
loadingBrowserRef.current = null;
// Reset all states
// Reset all states. Stay on the Wayfern config step — Camoufox is
// deprecated, so the browser-selection screen is gone.
setProfileName("");
setCurrentStep("browser-selection");
setCurrentStep("browser-config");
setActiveTab("anti-detect");
setSelectedBrowser(null);
setSelectedBrowser("wayfern");
setSelectedProxyId(undefined);
setLaunchHook("");
setReleaseTypes({});
setIsLoadingReleaseTypes(false);
setReleaseTypesError(null);
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
os: getCurrentOS(), // Reset to current OS
});
setWayfernConfig({
os: getCurrentOS() as WayfernOS, // Reset to current OS
os: getCurrentOS(), // Reset to current OS
});
setEphemeral(false);
setEnablePassword(false);
@@ -544,10 +492,6 @@ export function CreateProfileDialog({
onClose();
};
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
};
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
};
@@ -652,46 +596,14 @@ export function CreateProfileDialog({
</div>
</Button>
{/* Camoufox (Firefox) - Second */}
<Button
onClick={() => {
handleBrowserSelect("camoufox");
}}
disabled={!getCreatableVersion("camoufox")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{isBrowserCurrentlyDownloading("camoufox") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent =
getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-sm text-muted-foreground">
{isBrowserCurrentlyDownloading("camoufox")
? t("createProfile.downloadingSubtitle")
: t("createProfile.firefoxSubtitle")}
</div>
</div>
</Button>
{/* Camoufox is deprecated no longer offered for new
profiles. Only Wayfern can be created. */}
{!getCreatableVersion("wayfern") &&
!getCreatableVersion("camoufox") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
{!getCreatableVersion("wayfern") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
</div>
</TabsContent>
@@ -996,162 +908,9 @@ export function CreateProfileDialog({
profileBrowser="wayfern"
/>
</div>
) : selectedBrowser === "camoufox" ? (
// Camoufox Configuration
<div className="space-y-6">
{/* Camoufox Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
</div>
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
<p className="flex-1 text-sm text-destructive">
{releaseTypesError}
</p>
<RippleButton
onClick={() =>
selectedBrowser &&
loadReleaseTypes(selectedBrowser)
}
size="sm"
variant="outline"
>
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
{t("createProfile.platformUnavailable", {
browser: "Camoufox",
})}
</p>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
!getCreatableVersion("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{t("createProfile.version.needsDownload", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
getCreatableVersion("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Camoufox",
version:
getCreatableVersion("camoufox")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
getCreatableVersion("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</div>
)}
{crossOsUnlocked && (
<Alert className="border-warning/50 bg-warning/10">
<AlertDescription className="text-sm">
{t("createProfile.camoufoxWarning")}
</AlertDescription>
</Alert>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getCreatableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
</div>
) : (
// Regular Browser Configuration (should not happen in anti-detect tab)
// Regular Browser Configuration (should not happen in
// the anti-detect tab; Camoufox creation is removed).
<div className="space-y-4">
{selectedBrowser && (
<div className="space-y-3">
+18 -50
View File
@@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps {
interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
stage?: "downloading" | "extracting" | "verifying" | "completed";
progress?: {
percentage: number;
speed?: string;
@@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps {
browserName?: string;
}
interface TwilightUpdateToastProps extends BaseToastProps {
type: "twilight-update";
browserName?: string;
hasUpdate?: boolean;
}
interface SyncProgressToastProps extends BaseToastProps {
type: "sync-progress";
progress?: {
@@ -138,7 +127,6 @@ type ToastProps =
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps
| TwilightUpdateToastProps
| SyncProgressToastProps;
function formatBytesCompact(bytes: number): string {
@@ -191,10 +179,6 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
@@ -246,7 +230,8 @@ export function UnifiedToast(props: ToastProps) {
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
{progress.eta &&
`${t("toasts.progress.remaining", { time: progress.eta })}`}
</p>
</div>
<div className="w-full bg-muted rounded-full h-1.5">
@@ -264,9 +249,10 @@ export function UnifiedToast(props: ToastProps) {
"current_browser" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground">
{progress.current_browser && (
<>Looking for updates for {progress.current_browser}</>
)}
{progress.current_browser &&
t("versionUpdater.toast.lookingForUpdates", {
browser: progress.current_browser,
})}
</p>
<div className="flex items-center gap-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
@@ -293,7 +279,10 @@ export function UnifiedToast(props: ToastProps) {
{progress.phase === "uploading"
? t("appUpdate.toast.uploading")
: t("appUpdate.toast.downloading")}{" "}
{progress.completed_files}/{progress.total_files} files
{t("toasts.progress.filesProgress", {
completed: progress.completed_files,
total: progress.total_files,
})}
{" \u2022 "}
{formatBytesCompact(progress.completed_bytes)} /{" "}
{formatBytesCompact(progress.total_bytes)}
@@ -304,37 +293,21 @@ export function UnifiedToast(props: ToastProps) {
</>
)}
{progress.eta_seconds > 0 &&
progress.completed_files < progress.total_files && (
<>
{" \u2022 ~"}
{formatEtaCompact(progress.eta_seconds)} remaining
</>
)}
progress.completed_files < progress.total_files &&
` \u2022 ${t("toasts.progress.remaining", {
time: `~${formatEtaCompact(progress.eta_seconds)}`,
})}`}
</p>
{progress.failed_count > 0 && (
<p className="text-xs text-destructive mt-0.5">
{progress.failed_count} file(s) failed
{t("toasts.progress.filesFailed", {
count: progress.failed_count,
})}
</p>
)}
</div>
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
<p className="text-xs text-muted-foreground">
{"hasUpdate" in props && props.hasUpdate
? "New twilight build available for download"
: "Checking for twilight updates..."}
</p>
{props.browserName && (
<p className="mt-1 text-xs text-muted-foreground">
{props.browserName} Rolling Release
</p>
)}
</div>
)}
{/* Description */}
{description && (
<p className="mt-1 text-xs leading-tight text-muted-foreground">
@@ -355,11 +328,6 @@ export function UnifiedToast(props: ToastProps) {
{t("browserDownload.toast.verifying")}
</p>
)}
{stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-muted-foreground">
{t("browserDownload.toast.downloadingRolling")}
</p>
)}
</>
)}
{action &&
+27 -32
View File
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
@@ -34,9 +33,10 @@ import {
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import type { DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
@@ -70,7 +70,6 @@ export function ImportProfileDialog({
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
"select",
);
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
@@ -91,7 +90,11 @@ export function ImportProfileDialog({
useBrowserSupport();
const { storedProxies } = useProxyEvents();
const importableBrowsers = supportedBrowsers;
// Firefox-based browsers map to the deprecated Camoufox and can no longer be
// imported (the backend rejects them); only offer Chromium-family sources.
const importableBrowsers = supportedBrowsers.filter(
(browser) => getMappedBrowser(browser) === "wayfern",
);
const loadDetectedProfiles = useCallback(async () => {
setIsLoading(true);
@@ -176,7 +179,7 @@ export function ImportProfileDialog({
const mappedBrowser =
importMode === "auto-detect" && selectedProfile
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
? getMappedBrowser(selectedProfile.mapped_browser)
: getMappedBrowser(browserType);
setIsImporting(true);
@@ -186,7 +189,8 @@ export function ImportProfileDialog({
browserType,
newProfileName,
proxyId: selectedProxyId ?? null,
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
// Camoufox import is deprecated/blocked; only Wayfern configs are sent.
camoufoxConfig: null,
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
});
@@ -199,7 +203,10 @@ export function ImportProfileDialog({
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes("No downloaded versions found")) {
if (parseBackendError(error)) {
// Structured backend error (e.g. CAMOUFOX_IMPORT_DEPRECATED) — localize.
toast.error(translateBackendError(t, error));
} else if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(browserType);
toast.error(
t("importProfile.notInstalled", { browser: browserDisplayName }),
@@ -222,7 +229,6 @@ export function ImportProfileDialog({
manualProfilePath,
manualProfileName,
selectedProxyId,
camoufoxConfig,
wayfernConfig,
onClose,
selectedProfile,
@@ -231,7 +237,6 @@ export function ImportProfileDialog({
const handleClose = () => {
setCurrentStep("select");
setCamoufoxConfig({});
setWayfernConfig({});
setSelectedProxyId(undefined);
setSelectedDetectedProfile(null);
@@ -262,10 +267,10 @@ export function ImportProfileDialog({
const currentMappedBrowser = useMemo(() => {
if (importMode === "auto-detect" && selectedProfile) {
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
return getMappedBrowser(selectedProfile.mapped_browser);
}
if (importMode === "manual" && manualBrowserType) {
return manualBrowserType as "camoufox" | "wayfern";
return getMappedBrowser(manualBrowserType);
}
return null;
}, [importMode, selectedProfile, manualBrowserType]);
@@ -577,27 +582,17 @@ export function ImportProfileDialog({
</Select>
</div>
{currentMappedBrowser === "camoufox" ? (
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={(key, value) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
) : (
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
)}
{/* Only Wayfern profiles are importable now (Camoufox/Firefox
import is deprecated and blocked). */}
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</div>
)}
</div>
+4 -4
View File
@@ -2039,12 +2039,12 @@ export function ProfilesDataTable({
if (isDisabled) {
const tooltipMessage = isRunning
? "Can't modify running profile"
? t("profiles.table.cantModifyRunning")
: isLaunching
? "Can't modify profile while launching"
? t("profiles.table.cantModifyLaunching")
: isStopping
? "Can't modify profile while stopping"
: "Can't modify profile while browser is updating";
? t("profiles.table.cantModifyStopping")
: t("profiles.table.cantModifyUpdating");
return (
<Tooltip>
+114 -19
View File
@@ -2,6 +2,8 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
@@ -11,6 +13,7 @@ import {
LuClipboardCheck,
LuCookie,
LuCopy,
LuDownload,
LuFingerprint,
LuGlobe,
LuGroup,
@@ -39,6 +42,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -263,9 +272,9 @@ export function ProfileInfoDialog({
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
: null;
const networkLabel = vpnName
? `VPN: ${vpnName}`
? t("profileInfo.network.vpnLabel", { name: vpnName })
: proxyName
? `Proxy: ${proxyName}`
? t("profileInfo.network.proxyLabel", { name: proxyName })
: t("profileInfo.values.none");
const syncStatus = syncStatuses[profile.id];
@@ -299,6 +308,10 @@ export function ProfileInfoDialog({
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
// a navigation hub.
interface ActionItem {
// Stable, language-independent key used to map sidebar sections to actions.
// The sidebar must NOT match on `label` — labels are translated, so English
// substring matching hides sections for every non-English user.
id?: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
@@ -311,6 +324,7 @@ export function ProfileInfoDialog({
const actions: ActionItem[] = [
{
id: "network",
icon: <LuGlobe className="size-4" />,
label: t("profiles.actions.viewNetwork"),
onClick: () => {
@@ -319,6 +333,7 @@ export function ProfileInfoDialog({
disabled: isCrossOs,
},
{
id: "sync",
icon: <LuRefreshCw className="size-4" />,
label: t("profiles.actions.syncSettings"),
onClick: () => {
@@ -337,6 +352,7 @@ export function ProfileInfoDialog({
runningBadge: isRunning,
},
{
id: "fingerprint",
icon: <LuFingerprint className="size-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => {
@@ -359,6 +375,7 @@ export function ProfileInfoDialog({
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
id: "cookiesCopy",
icon: <LuCopy className="size-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => {
@@ -372,6 +389,7 @@ export function ProfileInfoDialog({
!onCopyCookiesToProfile,
},
{
id: "cookiesManage",
icon: <LuCookie className="size-4" />,
label: t("profileInfo.actions.manageCookies"),
onClick: () => {
@@ -395,6 +413,7 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
id: "extension",
icon: <LuPuzzle className="size-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => {
@@ -419,6 +438,7 @@ export function ProfileInfoDialog({
},
},
{
id: "hook",
icon: <LuLink className="size-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
@@ -461,6 +481,7 @@ export function ProfileInfoDialog({
destructive: true,
},
{
id: "delete",
icon: <LuTrash2 className="size-4" />,
label: t("profiles.actions.delete"),
onClick: () => {
@@ -534,6 +555,7 @@ interface ProfileInfoLayoutProps {
onCloneProfile?: (profile: BrowserProfile) => void;
onKillProfile?: (profile: BrowserProfile) => void;
visibleActions: {
id?: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
@@ -579,22 +601,23 @@ function ProfileInfoLayout({
}: ProfileInfoLayoutProps) {
const [section, setSection] = React.useState<ProfileSection>("overview");
// Map sidebar items to existing action labels, so clicking a section
// simply triggers the existing dialog handler.
// Map sidebar items to existing actions by their stable, language-independent
// `id`, so clicking a section triggers the existing dialog handler. Matching
// on `label` would break for every non-English locale (the labels are
// translated) and hide whole sections.
const findAction = React.useCallback(
(substr: string) =>
visibleActions.find((a) => a.label.toLowerCase().includes(substr)),
(id: string) => visibleActions.find((a) => a.id === id),
[visibleActions],
);
const deleteAction = findAction("delete");
const fingerprintAction = findAction("fingerprint");
const cookiesManageAction = findAction("manage cookies");
const cookiesCopyAction = findAction("copy cookies");
const cookiesManageAction = findAction("cookiesManage");
const cookiesCopyAction = findAction("cookiesCopy");
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
const extensionAction = findAction("extension");
const syncAction = findAction("sync");
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
const _launchHookAction = findAction("hook");
const _networkAction = findAction("network");
// Password actions are no longer routed via the legacy action handlers —
// SecuritySectionInline writes directly to the backend instead.
@@ -1149,7 +1172,7 @@ function SyncSectionInline({
syncMode: mode,
});
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1192,7 +1215,9 @@ function SyncSectionInline({
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("profileInfo.fields.syncStatus")}
</p>
<p className="text-sm mt-0.5">{syncStatus.status}</p>
<p className="text-sm mt-0.5">
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
</p>
{syncStatus.error && (
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
)}
@@ -1246,7 +1271,7 @@ function NetworkSectionInline({
setProxyId(nextId);
if (nextId !== null) setVpnId(null);
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1264,7 +1289,7 @@ function NetworkSectionInline({
setVpnId(nextId);
if (nextId !== null) setProxyId(null);
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1370,7 +1395,7 @@ function ExtensionsSectionInline({
);
if (mounted) setGroups(data);
} catch (e) {
if (mounted) setError(String(e));
if (mounted) setError(translateBackendError(t as never, e));
}
};
void load();
@@ -1384,7 +1409,7 @@ function ExtensionsSectionInline({
mounted = false;
unlisten?.();
};
}, []);
}, [t]);
const onChange = async (value: string) => {
const next = value === "__none__" ? null : value;
@@ -1397,7 +1422,7 @@ function ExtensionsSectionInline({
});
setGroupId(next);
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1495,6 +1520,41 @@ function CookiesSectionInline({
};
}, [profile.id, isRunning, t]);
const [isExporting, setIsExporting] = React.useState(false);
// Export all of this profile's cookies in one of the same formats import
// accepts (JSON or Netscape). The backend formats every cookie; we just pick
// a destination file.
const handleExport = React.useCallback(
async (format: "json" | "netscape") => {
setIsExporting(true);
try {
const content = await invoke<string>("export_profile_cookies", {
profileId: profile.id,
format,
});
const ext = format === "json" ? "json" : "txt";
const filePath = await save({
defaultPath: `${profile.name}_cookies.${ext}`,
filters: [
{
name: format === "json" ? "JSON" : "Text",
extensions: [ext],
},
],
});
if (!filePath) return;
await writeTextFile(filePath, content);
showSuccessToast(t("cookies.export.success"));
} catch (e) {
showErrorToast(translateBackendError(t as never, e));
} finally {
setIsExporting(false);
}
},
[profile.id, profile.name, t],
);
const domains = stats?.domains ?? [];
return (
@@ -1505,6 +1565,41 @@ function CookiesSectionInline({
{t("profileInfo.sections.cookies")}
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
disabled={
isDisabled ||
isRunning ||
isExporting ||
!stats ||
stats.total_count === 0
}
>
<LuDownload className="size-3.5" />
{t("common.buttons.export")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
void handleExport("json");
}}
>
{t("cookies.export.json")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
void handleExport("netscape");
}}
>
{t("cookies.export.netscape")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onImportCookies && (
<Button
variant="outline"
@@ -1514,7 +1609,7 @@ function CookiesSectionInline({
onClick={onImportCookies}
>
<LuUpload className="size-3.5" />
{t("cookies.import.title")}
{t("common.buttons.import")}
</Button>
)}
{onCopyCookies && (
@@ -1526,7 +1621,7 @@ function CookiesSectionInline({
onClick={onCopyCookies}
>
<LuCopy className="size-3.5" />
{t("profiles.actions.copyCookies")}
{t("common.buttons.copy")}
</Button>
)}
</div>
@@ -1684,7 +1779,7 @@ function FingerprintSectionInline({
// Close the dialog once the fingerprint is saved.
onSaved();
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
+1 -1
View File
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
<div className="flex flex-col gap-3">
{(mode === "set" || mode === "change") && (
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
<p className="font-medium text-warning-foreground">
<p className="font-medium text-warning">
{t("profilePassword.warnings.forgetWarningTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
+2 -5
View File
@@ -17,6 +17,7 @@ import {
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
import { isSyncEnabled } from "@/types";
@@ -36,11 +37,7 @@ export function ProfileSyncDialog({
}: ProfileSyncDialogProps) {
const { t } = useTranslation();
const { user: cloudUser } = useCloudAuth();
const isCloudSyncEligible =
cloudUser != null &&
cloudUser.plan !== "free" &&
(cloudUser.subscriptionStatus === "active" ||
cloudUser.planPeriod === "lifetime");
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
// Encryption available to everyone except team members who aren't owners
const canUseEncryption =
cloudUser == null ||
+2 -1
View File
@@ -483,7 +483,8 @@ export function SettingsDialog({
| "zh"
| "ja"
| "ko"
| "ru"),
| "ru"
| "vi"),
);
setOriginalLanguage(selectedLanguage);
}
+4
View File
@@ -8,6 +8,7 @@ import ja from "./locales/ja.json";
import ko from "./locales/ko.json";
import pt from "./locales/pt.json";
import ru from "./locales/ru.json";
import vi from "./locales/vi.json";
import zh from "./locales/zh.json";
export const SUPPORTED_LANGUAGES = [
@@ -19,6 +20,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
{ code: "ru", name: "Russian", nativeName: "Русский" },
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
@@ -36,6 +38,7 @@ export const LANGUAGE_FALLBACKS: Record<string, string[]> = {
"es-ES": ["es", "en"],
"fr-CA": ["fr", "en"],
"fr-FR": ["fr", "en"],
"vi-VN": ["vi", "en"],
};
export function getLanguageWithFallback(systemLocale: string): string {
@@ -65,6 +68,7 @@ const resources = {
ja: { translation: ja },
ko: { translation: ko },
ru: { translation: ru },
vi: { translation: vi },
};
i18n.use(initReactI18next).init({
+41 -9
View File
@@ -239,7 +239,11 @@
"extDefault": "Default",
"dnsLevel": "DNS blocklist: {{level}}",
"extSearch": "Search groups…",
"extEmpty": "No extension groups"
"extEmpty": "No extension groups",
"cantModifyRunning": "Can't modify running profile",
"cantModifyLaunching": "Can't modify profile while launching",
"cantModifyStopping": "Can't modify profile while stopping",
"cantModifyUpdating": "Can't modify profile while browser is updating"
},
"actions": {
"launch": "Launch",
@@ -639,7 +643,11 @@
"profileSynced": "Profile '{{name}}' synced successfully",
"profileSyncFailed": "Failed to sync profile '{{name}}'",
"profileSyncFailedWithError": "Failed to sync profile '{{name}}': {{error}}"
}
},
"enabledToast": "Sync enabled",
"disabledToast": "Sync disabled",
"enabledDescription": "Profile sync has been enabled",
"disabledDescription": "Profile sync has been disabled"
},
"integrations": {
"title": "Integrations",
@@ -918,6 +926,11 @@
"syncingProfile": "Syncing profile '{{name}}'...",
"syncingProfileWithProgress": "{{count}} files ({{size}})",
"updatingVersions": "Updating browser versions..."
},
"progress": {
"remaining": "{{time}} remaining",
"filesProgress": "{{completed}}/{{total}} files",
"filesFailed": "{{count}} file(s) failed"
}
},
"errors": {
@@ -1136,7 +1149,9 @@
"addRule": "Add Rule",
"rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local",
"noRules": "No bypass rules configured.",
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns.",
"vpnLabel": "VPN: {{name}}",
"proxyLabel": "Proxy: {{name}}"
},
"launchHook": {
"title": "Launch Hook URL",
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.",
"lockedTitle": "Fingerprint is a Pro feature",
"lockedDescription": "Viewing and editing a profile's fingerprint requires an active paid plan. Upgrade to unlock fingerprint protection."
"lockedTitle": "Viewing & editing the fingerprint is a Pro feature",
"lockedDescription": "Fingerprint protection is included on every plan. Viewing and editing a profile's fingerprint values is what requires an active paid plan."
},
"syncStatusValue": {
"waiting": "Waiting",
"syncing": "Syncing",
"synced": "Synced",
"error": "Error"
}
},
"extensions": {
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "Version {{version}} download will begin shortly. Browser launch is disabled until update completes.",
"downloadStarting": "Starting {{browser}} {{version}} download",
"downloadProgressBelow": "Download progress will be shown below...",
"autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below."
"autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below.",
"lookingForUpdates": "Looking for updates for {{browser}}"
}
},
"profilePassword": {
@@ -1807,10 +1829,11 @@
"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.",
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.",
"fingerprintRequiresPro": "Fingerprint protection requires an active paid plan.",
"fingerprintRequiresPro": "Viewing or editing the fingerprint requires an active paid plan. Protection is included on all plans.",
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created."
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created.",
"camoufoxImportDeprecated": "Importing Firefox-based (Camoufox) profiles is no longer supported. Camoufox is being deprecated — please use Wayfern instead."
},
"rail": {
"profiles": "Profiles",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "Browser support ending soon",
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern or Camoufox profiles."
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern profiles."
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2 weeks free",
"commercialDesc": "Free for a 2-week evaluation. After that, a paid plan keeps the project maintained and thriving."
}
},
"wayfernBlocked": {
"title": "Browser automation paused",
"description": "Your account was temporarily restricted from Pro browser features, usually from signing in on multiple devices at once. Sign out of other devices, then relaunch the profile to restore it."
},
"camoufoxDeprecation": {
"title": "Camoufox support is ending",
"description": "Support for Camoufox profiles is ending on July 8, 2026. You have one or more Camoufox profiles. Please migrate them to Wayfern before then — after that date, Camoufox profiles may stop working.",
"acknowledge": "Got it"
}
}
+41 -9
View File
@@ -239,7 +239,11 @@
"extDefault": "Predet.",
"dnsLevel": "Lista DNS: {{level}}",
"extSearch": "Buscar grupos…",
"extEmpty": "Sin grupos de extensiones"
"extEmpty": "Sin grupos de extensiones",
"cantModifyRunning": "No se puede modificar un perfil en ejecución",
"cantModifyLaunching": "No se puede modificar el perfil mientras se inicia",
"cantModifyStopping": "No se puede modificar el perfil mientras se detiene",
"cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador"
},
"actions": {
"launch": "Iniciar",
@@ -639,7 +643,11 @@
"profileSynced": "Perfil '{{name}}' sincronizado correctamente",
"profileSyncFailed": "Error al sincronizar el perfil '{{name}}'",
"profileSyncFailedWithError": "Error al sincronizar el perfil '{{name}}': {{error}}"
}
},
"enabledToast": "Sincronización activada",
"disabledToast": "Sincronización desactivada",
"enabledDescription": "Se ha activado la sincronización del perfil",
"disabledDescription": "Se ha desactivado la sincronización del perfil"
},
"integrations": {
"title": "Integraciones",
@@ -918,6 +926,11 @@
"syncingProfile": "Sincronizando perfil '{{name}}'...",
"syncingProfileWithProgress": "{{count}} archivos ({{size}})",
"updatingVersions": "Actualizando versiones de navegadores..."
},
"progress": {
"remaining": "{{time}} restante",
"filesProgress": "{{completed}}/{{total}} archivos",
"filesFailed": "{{count}} archivo(s) con error"
}
},
"errors": {
@@ -1136,7 +1149,9 @@
"addRule": "Agregar Regla",
"rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local",
"noRules": "No hay reglas de omisión configuradas.",
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex.",
"vpnLabel": "VPN: {{name}}",
"proxyLabel": "Proxy: {{name}}"
},
"launchHook": {
"title": "URL del hook de inicio",
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.",
"lockedTitle": "La huella digital es una función Pro",
"lockedDescription": "Ver y editar la huella digital de un perfil requiere un plan de pago activo. Mejora tu plan para desbloquear la protección de huella digital."
"lockedTitle": "Ver y editar la huella digital es una función Pro",
"lockedDescription": "La protección de huella digital está incluida en todos los planes. Ver y editar los valores de la huella digital de un perfil es lo que requiere un plan de pago activo."
},
"syncStatusValue": {
"waiting": "Esperando",
"syncing": "Sincronizando",
"synced": "Sincronizado",
"error": "Error"
}
},
"extensions": {
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "La descarga de la versión {{version}} comenzará en breve. El inicio del navegador está deshabilitado hasta que finalice la actualización.",
"downloadStarting": "Iniciando la descarga de {{browser}} {{version}}",
"downloadProgressBelow": "El progreso de la descarga se mostrará a continuación...",
"autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación."
"autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación.",
"lookingForUpdates": "Buscando actualizaciones de {{browser}}"
}
},
"profilePassword": {
@@ -1807,10 +1829,11 @@
"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.",
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.",
"fingerprintRequiresPro": "La protección de huella digital requiere un plan de pago activo.",
"fingerprintRequiresPro": "Ver o editar la huella digital requiere un plan de pago activo. La protección está incluida en todos los planes.",
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil."
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil.",
"camoufoxImportDeprecated": "La importación de perfiles basados en Firefox (Camoufox) ya no es compatible. Camoufox está en desuso; usa Wayfern en su lugar."
},
"rail": {
"profiles": "Perfiles",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "El soporte del navegador finalizará pronto",
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern o Camoufox."
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern."
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2 semanas gratis",
"commercialDesc": "Gratis durante una evaluación de 2 semanas. Después, un plan de pago mantiene el proyecto en buen estado y próspero."
}
},
"wayfernBlocked": {
"title": "Automatización del navegador en pausa",
"description": "Tu cuenta fue restringida temporalmente de las funciones Pro del navegador, normalmente por iniciar sesión en varios dispositivos a la vez. Cierra sesión en los demás dispositivos y vuelve a iniciar el perfil para restaurarla."
},
"camoufoxDeprecation": {
"title": "El soporte para Camoufox está terminando",
"description": "El soporte para los perfiles de Camoufox terminará el 8 de julio de 2026. Tienes uno o más perfiles de Camoufox. Migra a Wayfern antes de esa fecha; después, los perfiles de Camoufox podrían dejar de funcionar.",
"acknowledge": "Entendido"
}
}
+41 -9
View File
@@ -239,7 +239,11 @@
"extDefault": "Défaut",
"dnsLevel": "Liste DNS : {{level}}",
"extSearch": "Rechercher des groupes…",
"extEmpty": "Aucun groupe dextensions"
"extEmpty": "Aucun groupe dextensions",
"cantModifyRunning": "Impossible de modifier un profil en cours d'exécution",
"cantModifyLaunching": "Impossible de modifier le profil pendant le lancement",
"cantModifyStopping": "Impossible de modifier le profil pendant l'arrêt",
"cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur"
},
"actions": {
"launch": "Lancer",
@@ -639,7 +643,11 @@
"profileSynced": "Profil '{{name}}' synchronisé avec succès",
"profileSyncFailed": "Échec de la synchronisation du profil '{{name}}'",
"profileSyncFailedWithError": "Échec de la synchronisation du profil '{{name}}' : {{error}}"
}
},
"enabledToast": "Synchronisation activée",
"disabledToast": "Synchronisation désactivée",
"enabledDescription": "La synchronisation du profil a été activée",
"disabledDescription": "La synchronisation du profil a été désactivée"
},
"integrations": {
"title": "Intégrations",
@@ -918,6 +926,11 @@
"syncingProfile": "Synchronisation du profil '{{name}}'...",
"syncingProfileWithProgress": "{{count}} fichiers ({{size}})",
"updatingVersions": "Mise à jour des versions de navigateurs..."
},
"progress": {
"remaining": "{{time}} restant",
"filesProgress": "{{completed}}/{{total}} fichiers",
"filesFailed": "Échec de {{count}} fichier(s)"
}
},
"errors": {
@@ -1136,7 +1149,9 @@
"addRule": "Ajouter une Règle",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
"noRules": "Aucune règle de contournement configurée.",
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières.",
"vpnLabel": "VPN : {{name}}",
"proxyLabel": "Proxy : {{name}}"
},
"launchHook": {
"title": "URL du hook de lancement",
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern.",
"lockedTitle": "L'empreinte est une fonctionnalité Pro",
"lockedDescription": "Afficher et modifier l'empreinte d'un profil nécessite un forfait payant actif. Passez à un forfait supérieur pour débloquer la protection contre le fingerprinting."
"lockedTitle": "Afficher et modifier l'empreinte est une fonctionnalité Pro",
"lockedDescription": "La protection contre le fingerprinting est incluse dans tous les forfaits. C'est l'affichage et la modification des valeurs de l'empreinte d'un profil qui nécessitent un forfait payant actif."
},
"syncStatusValue": {
"waiting": "En attente",
"syncing": "Synchronisation",
"synced": "Synchronisé",
"error": "Erreur"
}
},
"extensions": {
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "Le téléchargement de la version {{version}} va bientôt commencer. Le lancement du navigateur est désactivé jusqu'à la fin de la mise à jour.",
"downloadStarting": "Démarrage du téléchargement de {{browser}} {{version}}",
"downloadProgressBelow": "La progression du téléchargement sera affichée ci-dessous...",
"autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous."
"autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous.",
"lookingForUpdates": "Recherche de mises à jour pour {{browser}}"
}
},
"profilePassword": {
@@ -1807,10 +1829,11 @@
"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.",
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé.",
"fingerprintRequiresPro": "La protection contre le fingerprinting nécessite un forfait payant actif.",
"fingerprintRequiresPro": "Afficher ou modifier l'empreinte nécessite un forfait payant actif. La protection est incluse dans tous les forfaits.",
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
"proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.",
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé."
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
"camoufoxImportDeprecated": "L'importation de profils basés sur Firefox (Camoufox) n'est plus prise en charge. Camoufox est en cours d'abandon — utilisez Wayfern à la place."
},
"rail": {
"profiles": "Profils",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "La prise en charge du navigateur prend bientôt fin",
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern ou Camoufox."
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern."
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2 semaines gratuites",
"commercialDesc": "Gratuit pendant une évaluation de 2 semaines. Ensuite, un forfait payant permet de maintenir et de faire prospérer le projet."
}
},
"wayfernBlocked": {
"title": "Automatisation du navigateur en pause",
"description": "Votre compte a été temporairement privé des fonctionnalités Pro du navigateur, généralement à cause d'une connexion sur plusieurs appareils à la fois. Déconnectez-vous des autres appareils, puis relancez le profil pour la rétablir."
},
"camoufoxDeprecation": {
"title": "La prise en charge de Camoufox prend fin",
"description": "La prise en charge des profils Camoufox prendra fin le 8 juillet 2026. Vous avez un ou plusieurs profils Camoufox. Migrez-les vers Wayfern avant cette date — après quoi, les profils Camoufox pourraient cesser de fonctionner.",
"acknowledge": "Compris"
}
}
+41 -9
View File
@@ -239,7 +239,11 @@
"extDefault": "既定",
"dnsLevel": "DNS ブロックリスト: {{level}}",
"extSearch": "グループを検索…",
"extEmpty": "拡張機能グループがありません"
"extEmpty": "拡張機能グループがありません",
"cantModifyRunning": "実行中のプロファイルは変更できません",
"cantModifyLaunching": "起動中はプロファイルを変更できません",
"cantModifyStopping": "停止中はプロファイルを変更できません",
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません"
},
"actions": {
"launch": "起動",
@@ -639,7 +643,11 @@
"profileSynced": "プロファイル '{{name}}' を同期しました",
"profileSyncFailed": "プロファイル '{{name}}' の同期に失敗しました",
"profileSyncFailedWithError": "プロファイル '{{name}}' の同期に失敗しました: {{error}}"
}
},
"enabledToast": "同期を有効化しました",
"disabledToast": "同期を無効化しました",
"enabledDescription": "プロファイルの同期が有効になりました",
"disabledDescription": "プロファイルの同期が無効になりました"
},
"integrations": {
"title": "統合",
@@ -918,6 +926,11 @@
"syncingProfile": "プロファイル '{{name}}' を同期中...",
"syncingProfileWithProgress": "{{count}} ファイル ({{size}})",
"updatingVersions": "ブラウザバージョンを更新中..."
},
"progress": {
"remaining": "残り {{time}}",
"filesProgress": "{{completed}}/{{total}} ファイル",
"filesFailed": "{{count}} 件のファイルが失敗しました"
}
},
"errors": {
@@ -1136,7 +1149,9 @@
"addRule": "ルールを追加",
"rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local",
"noRules": "バイパスルールは設定されていません。",
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。",
"vpnLabel": "VPN: {{name}}",
"proxyLabel": "Proxy: {{name}}"
},
"launchHook": {
"title": "起動フックURL",
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。",
"lockedTitle": "フィンガープリントは Pro 機能です",
"lockedDescription": "プロファイルのフィンガープリントの表示編集には有効な有料プランが必要です。アップグレードしてフィンガープリント保護をご利用ください。"
"lockedTitle": "フィンガープリントの表示と編集は Pro 機能です",
"lockedDescription": "フィンガープリント保護はすべてのプランに含まれています。プロファイルのフィンガープリントの値を表示編集するには有効な有料プランが必要です。"
},
"syncStatusValue": {
"waiting": "待機中",
"syncing": "同期中",
"synced": "同期済み",
"error": "エラー"
}
},
"extensions": {
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "バージョン {{version}} のダウンロードがまもなく開始されます。更新が完了するまでブラウザの起動は無効になります。",
"downloadStarting": "{{browser}} {{version}} のダウンロードを開始しています",
"downloadProgressBelow": "ダウンロードの進行状況は下に表示されます...",
"autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。"
"autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。",
"lookingForUpdates": "{{browser}} の更新を確認しています"
}
},
"profilePassword": {
@@ -1807,10 +1829,11 @@
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。",
"fingerprintRequiresPro": "フィンガープリント保護には有効な有料プランが必要です。",
"fingerprintRequiresPro": "フィンガープリントの表示または編集には有効な有料プランが必要です。保護機能はすべてのプランに含まれています。",
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。"
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
"camoufoxImportDeprecated": "Firefox ベース(Camoufox)のプロファイルのインポートはサポートされなくなりました。Camoufox は廃止予定です。代わりに Wayfern をご利用ください。"
},
"rail": {
"profiles": "プロファイル",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "ブラウザのサポートが間もなく終了します",
"endingSoonDescription": "のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern または Camoufox のプロファイルに移行してください。"
"endingSoonDescription": "以下のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern プロファイルに移行してください。"
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2週間無料",
"commercialDesc": "2週間の評価期間は無料です。その後は有料プランが必要で、これによりプロジェクトの維持と発展が支えられます。"
}
},
"wayfernBlocked": {
"title": "ブラウザの自動化が一時停止しました",
"description": "通常は複数のデバイスで同時にサインインしたことが原因で、アカウントのProブラウザ機能が一時的に制限されました。他のデバイスからサインアウトし、プロファイルを再起動すると復元されます。"
},
"camoufoxDeprecation": {
"title": "Camoufox のサポートが終了します",
"description": "Camoufox プロファイルのサポートは 2026 年 7 月 8 日に終了します。Camoufox プロファイルが 1 つ以上あります。それまでに Wayfern へ移行してください。その後、Camoufox プロファイルは動作しなくなる可能性があります。",
"acknowledge": "了解しました"
}
}
+58 -26
View File
@@ -131,12 +131,12 @@
"title": "기본 브라우저",
"setAsDefault": "기본 브라우저로 설정",
"alreadyDefault": "이미 기본 브라우저입니다",
"description": "기본 브라우저로 설정하면 도넛 브라우저가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다."
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다."
},
"permissions": {
"title": "시스템 권한",
"loading": "권한 불러오는 중...",
"description": "이 권한은 도넛 브라우저에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
"description": "이 권한은 Donut Browser에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
"microphone": "마이크",
"microphoneDescription": "브라우저 애플리케이션의 마이크 액세스",
"camera": "카메라",
@@ -180,7 +180,7 @@
"trialExpired": "체험판이 만료되었습니다",
"trialExpiredDescription": "개인 사용은 무료로 유지됩니다. 상업적 사용에는 라이선스가 필요합니다.",
"subscriptionActive": "구독 중 — {{plan}} 플랜",
"subscriptionActiveDescription": "도넛 브라우저 구독이 활성 상태입니다. 플랜 기간 동안 상업적 사용이 라이선스됩니다."
"subscriptionActiveDescription": "Donut Browser 구독이 활성 상태입니다. 플랜 기간 동안 상업적 사용이 라이선스됩니다."
},
"advanced": {
"title": "고급",
@@ -193,7 +193,7 @@
"copyLogsDescription": "최신 로그 파일(최대 5MB)을 클립보드에 묶어 버그 보고서에서 공유할 수 있도록 합니다."
},
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
"disableAutoUpdatesDescription": "도넛 브라우저 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다."
},
@@ -212,7 +212,7 @@
"extensions": "확장 프로그램"
},
"newProfile": "새로 만들기",
"donutLogo": "도넛 브라우저 로고",
"donutLogo": "Donut Browser 로고",
"scrollGroupsLeft": "그룹 왼쪽으로 스크롤",
"scrollGroupsRight": "그룹 오른쪽으로 스크롤"
},
@@ -239,7 +239,11 @@
"extDefault": "기본값",
"dnsLevel": "DNS 차단 목록: {{level}}",
"extSearch": "그룹 검색…",
"extEmpty": "확장 프로그램 그룹이 없습니다"
"extEmpty": "확장 프로그램 그룹이 없습니다",
"cantModifyRunning": "실행 중인 프로필은 수정할 수 없습니다",
"cantModifyLaunching": "실행하는 동안 프로필을 수정할 수 없습니다",
"cantModifyStopping": "중지하는 동안 프로필을 수정할 수 없습니다",
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다"
},
"actions": {
"launch": "실행",
@@ -639,7 +643,11 @@
"profileSynced": "프로필 '{{name}}'이(가) 동기화되었습니다",
"profileSyncFailed": "프로필 '{{name}}' 동기화 실패",
"profileSyncFailedWithError": "프로필 '{{name}}' 동기화 실패: {{error}}"
}
},
"enabledToast": "동기화 사용",
"disabledToast": "동기화 사용 안 함",
"enabledDescription": "프로필 동기화가 활성화되었습니다",
"disabledDescription": "프로필 동기화가 비활성화되었습니다"
},
"integrations": {
"title": "통합",
@@ -918,6 +926,11 @@
"syncingProfile": "프로필 '{{name}}' 동기화 중...",
"syncingProfileWithProgress": "{{count}}개 파일 ({{size}})",
"updatingVersions": "브라우저 버전 업데이트 중..."
},
"progress": {
"remaining": "{{time}} 남음",
"filesProgress": "{{completed}}/{{total}} 파일",
"filesFailed": "{{count}}개 파일 실패"
}
},
"errors": {
@@ -1136,12 +1149,14 @@
"addRule": "규칙 추가",
"rulePlaceholder": "예: example.com, 192.168.1.*, .*\\.local",
"noRules": "구성된 우회 규칙이 없습니다.",
"ruleTypes": "호스트 이름, IP 주소 및 정규식 패턴을 지원합니다."
"ruleTypes": "호스트 이름, IP 주소 및 정규식 패턴을 지원합니다.",
"vpnLabel": "VPN: {{name}}",
"proxyLabel": "Proxy: {{name}}"
},
"launchHook": {
"title": "실행 후크 URL",
"label": "실행 후크 URL",
"description": "도넛 브라우저는 프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다.",
"description": "Donut Browser는 프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다.",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "유효한 http:// 또는 https:// URL을 입력하세요."
},
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.",
"lockedTitle": "핑거프린트 Pro 기능입니다",
"lockedDescription": "프로필의 핑거프린트 보고 편집하려면 활성 유료 요금제가 필요합니다. 업그레이드하여 핑거프린트 보호를 잠금 해제하세요."
"lockedTitle": "핑거프린트 보기 및 편집은 Pro 기능입니다",
"lockedDescription": "핑거프린트 보호는 모든 요금제에 포함되어 있습니다. 프로필의 핑거프린트 값을 보고 편집하려면 활성 유료 요금제가 필요합니다."
},
"syncStatusValue": {
"waiting": "대기 중",
"syncing": "동기화 중",
"synced": "동기화됨",
"error": "오류"
}
},
"extensions": {
@@ -1535,7 +1556,7 @@
},
"wayfernTerms": {
"title": "Wayfern 이용 약관",
"description": "도넛 브라우저를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.",
"description": "Donut Browser를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.",
"reviewLabel": "다음 위치에서 이용 약관을 검토하세요:",
"agreeNotice": "\"동의함\"을 클릭하면 이 약관에 동의하는 것입니다.",
"acceptButton": "동의함",
@@ -1546,7 +1567,7 @@
"commercialTrial": {
"title": "상업용 체험판 만료됨",
"description": "2주 상업용 체험판 기간이 종료되었습니다.",
"body": "도넛 브라우저를 비즈니스 용도로 사용하는 경우 계속 사용하려면 상업용 라이선스를 구매해야 합니다. 개인 용도로는 계속 무료로 사용할 수 있습니다.",
"body": "Donut Browser를 비즈니스 용도로 사용하는 경우 계속 사용하려면 상업용 라이선스를 구매해야 합니다. 개인 용도로는 계속 무료로 사용할 수 있습니다.",
"understandButton": "이해했습니다",
"failed": "확인 저장 실패",
"tryAgain": "다시 시도하세요"
@@ -1554,10 +1575,10 @@
"permissionDialog": {
"titleMicrophone": "마이크 액세스가 필요합니다",
"titleCamera": "카메라 액세스가 필요합니다",
"descMicrophone": "도넛 브라우저는 웹 브라우저에서 마이크 기능을 활성화하기 위해 마이크에 액세스해야 합니다. 마이크를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
"descCamera": "도넛 브라우저는 웹 브라우저에서 카메라 기능을 활성화하기 위해 카메라에 액세스해야 합니다. 카메라를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
"grantedMicrophone": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 마이크에 액세스할 수 있습니다.",
"grantedCamera": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 카메라에 액세스할 수 있습니다.",
"descMicrophone": "Donut Browser는 웹 브라우저에서 마이크 기능을 활성화하기 위해 마이크에 액세스해야 합니다. 마이크를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
"descCamera": "Donut Browser는 웹 브라우저에서 카메라 기능을 활성화하기 위해 카메라에 액세스해야 합니다. 카메라를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
"grantedMicrophone": "권한이 허용되었습니다! 이제 Donut Browser에서 실행된 브라우저가 마이크에 액세스할 수 있습니다.",
"grantedCamera": "권한이 허용되었습니다! 이제 Donut Browser에서 실행된 브라우저가 카메라에 액세스할 수 있습니다.",
"notGrantedMicrophone": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 마이크에 대한 액세스를 요청하세요.",
"notGrantedCamera": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 카메라에 대한 액세스를 요청하세요.",
"doneButton": "완료",
@@ -1670,7 +1691,7 @@
},
"appUpdate": {
"toast": {
"updateFailed": "도넛 브라우저 업데이트 실패",
"updateFailed": "Donut Browser 업데이트 실패",
"restartFailed": "재시작 실패",
"updateReady": "업데이트 준비됨, 적용하려면 재시작하세요",
"manualDownloadRequired": "수동 다운로드 필요",
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "버전 {{version}} 다운로드가 곧 시작됩니다. 업데이트가 완료될 때까지 브라우저 실행이 비활성화됩니다.",
"downloadStarting": "{{browser}} {{version}} 다운로드를 시작하는 중",
"downloadProgressBelow": "다운로드 진행 상황이 아래에 표시됩니다...",
"autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다."
"autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다.",
"lookingForUpdates": "{{browser}} 업데이트를 확인하는 중"
}
},
"profilePassword": {
@@ -1762,7 +1784,7 @@
},
"warnings": {
"forgetWarningTitle": "중요: 이 비밀번호는 복구할 수 없습니다",
"forgetWarningBody": "도넛 브라우저는 이 비밀번호를 재설정, 복구 또는 우회할 수 없습니다. 잊어버리면 이 프로필의 데이터에 영구적으로 액세스할 수 없게 됩니다."
"forgetWarningBody": "Donut Browser는 이 비밀번호를 재설정, 복구 또는 우회할 수 없습니다. 잊어버리면 이 프로필의 데이터에 영구적으로 액세스할 수 없게 됩니다."
},
"modes": {
"set": "설정",
@@ -1806,11 +1828,12 @@
"invalidLaunchHookUrl": "잘못된 실행 후크 URL입니다. 전체 http:// 또는 https:// URL을 사용하세요.",
"cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.",
"cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.",
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.",
"fingerprintRequiresPro": "핑거프린트 보호에는 활성 유료 요금제가 필요합니다.",
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 Donut 계정에서 로그아웃하세요.",
"fingerprintRequiresPro": "핑거프린트거나 편집하려면 활성 유료 요금제가 필요합니다. 보호 기능은 모든 요금제에 포함되어 있습니다.",
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다."
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
"camoufoxImportDeprecated": "Firefox 기반(Camoufox) 프로필 가져오기는 더 이상 지원되지 않습니다. Camoufox는 지원 종료 예정입니다. 대신 Wayfern을 사용하세요."
},
"rail": {
"profiles": "프로필",
@@ -1885,8 +1908,8 @@
},
"selfHosted": {
"title": "자체 호스팅 동기화 서버",
"description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 도넛을 자체 donut-sync 서버로 연결하세요.",
"disabledWhileLoggedIn": "도넛 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.",
"description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 Donut을 자체 donut-sync 서버로 연결하세요.",
"disabledWhileLoggedIn": "Donut 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.",
"connectionStatus": "연결:",
"statusUnknown": "테스트 안 됨",
"testConnection": "연결 테스트",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "브라우저 지원이 곧 종료됩니다",
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 또는 Camoufox 프로필로 마이그레이션하세요."
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 프로필로 이전하세요."
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2주 무료",
"commercialDesc": "2주간의 평가 기간 동안 무료입니다. 이후에는 유료 요금제가 필요하며, 이를 통해 프로젝트가 유지되고 발전할 수 있습니다."
}
},
"wayfernBlocked": {
"title": "브라우저 자동화가 일시 중지됨",
"description": "보통 여러 기기에서 동시에 로그인하여 계정의 Pro 브라우저 기능이 일시적으로 제한되었습니다. 다른 기기에서 로그아웃한 후 프로필을 다시 실행하면 복원됩니다."
},
"camoufoxDeprecation": {
"title": "Camoufox 지원이 종료됩니다",
"description": "Camoufox 프로필 지원이 2026년 7월 8일에 종료됩니다. Camoufox 프로필이 하나 이상 있습니다. 그 전에 Wayfern으로 이전하세요. 이후에는 Camoufox 프로필이 작동하지 않을 수 있습니다.",
"acknowledge": "확인"
}
}
+41 -9
View File
@@ -239,7 +239,11 @@
"extDefault": "Padrão",
"dnsLevel": "Lista DNS: {{level}}",
"extSearch": "Pesquisar grupos…",
"extEmpty": "Sem grupos de extensões"
"extEmpty": "Sem grupos de extensões",
"cantModifyRunning": "Não é possível modificar um perfil em execução",
"cantModifyLaunching": "Não é possível modificar o perfil durante a inicialização",
"cantModifyStopping": "Não é possível modificar o perfil durante a parada",
"cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado"
},
"actions": {
"launch": "Iniciar",
@@ -639,7 +643,11 @@
"profileSynced": "Perfil '{{name}}' sincronizado com sucesso",
"profileSyncFailed": "Falha ao sincronizar o perfil '{{name}}'",
"profileSyncFailedWithError": "Falha ao sincronizar o perfil '{{name}}': {{error}}"
}
},
"enabledToast": "Sincronização ativada",
"disabledToast": "Sincronização desativada",
"enabledDescription": "A sincronização do perfil foi ativada",
"disabledDescription": "A sincronização do perfil foi desativada"
},
"integrations": {
"title": "Integrações",
@@ -918,6 +926,11 @@
"syncingProfile": "Sincronizando perfil '{{name}}'...",
"syncingProfileWithProgress": "{{count}} arquivos ({{size}})",
"updatingVersions": "Atualizando versões de navegadores..."
},
"progress": {
"remaining": "{{time}} restante",
"filesProgress": "{{completed}}/{{total}} arquivos",
"filesFailed": "{{count}} arquivo(s) com falha"
}
},
"errors": {
@@ -1136,7 +1149,9 @@
"addRule": "Adicionar Regra",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
"noRules": "Nenhuma regra de bypass configurada.",
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex.",
"vpnLabel": "VPN: {{name}}",
"proxyLabel": "Proxy: {{name}}"
},
"launchHook": {
"title": "URL do hook de inicialização",
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern.",
"lockedTitle": "A impressão digital é um recurso Pro",
"lockedDescription": "Visualizar e editar a impressão digital de um perfil requer um plano pago ativo. Faça upgrade para desbloquear a proteção contra fingerprint."
"lockedTitle": "Visualizar e editar a impressão digital é um recurso Pro",
"lockedDescription": "A proteção contra fingerprint está incluída em todos os planos. Visualizar e editar os valores da impressão digital de um perfil é o que requer um plano pago ativo."
},
"syncStatusValue": {
"waiting": "Aguardando",
"syncing": "Sincronizando",
"synced": "Sincronizado",
"error": "Erro"
}
},
"extensions": {
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "O download da versão {{version}} começará em breve. O início do navegador está desativado até a atualização ser concluída.",
"downloadStarting": "Iniciando o download do {{browser}} {{version}}",
"downloadProgressBelow": "O progresso do download será mostrado abaixo...",
"autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo."
"autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo.",
"lookingForUpdates": "Procurando atualizações para {{browser}}"
}
},
"profilePassword": {
@@ -1807,10 +1829,11 @@
"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.",
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado.",
"fingerprintRequiresPro": "A proteção contra fingerprint requer um plano pago ativo.",
"fingerprintRequiresPro": "Visualizar ou editar a impressão digital requer um plano pago ativo. A proteção está incluída em todos os planos.",
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
"proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.",
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado."
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado.",
"camoufoxImportDeprecated": "A importação de perfis baseados em Firefox (Camoufox) não é mais suportada. O Camoufox está sendo descontinuado — use o Wayfern."
},
"rail": {
"profiles": "Perfis",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "O suporte ao navegador terminará em breve",
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern ou Camoufox."
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern."
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2 semanas grátis",
"commercialDesc": "Gratuito durante uma avaliação de 2 semanas. Depois, um plano pago mantém o projeto ativo e próspero."
}
},
"wayfernBlocked": {
"title": "Automação do navegador pausada",
"description": "Sua conta foi temporariamente restringida dos recursos Pro do navegador, geralmente por entrar em vários dispositivos ao mesmo tempo. Saia dos outros dispositivos e reinicie o perfil para restaurá-la."
},
"camoufoxDeprecation": {
"title": "O suporte ao Camoufox está terminando",
"description": "O suporte aos perfis do Camoufox terminará em 8 de julho de 2026. Você tem um ou mais perfis do Camoufox. Migre-os para o Wayfern antes dessa data — depois disso, os perfis do Camoufox podem parar de funcionar.",
"acknowledge": "Entendi"
}
}
+41 -9
View File
@@ -239,7 +239,11 @@
"extDefault": "По умолч.",
"dnsLevel": "DNS-блок-лист: {{level}}",
"extSearch": "Поиск групп…",
"extEmpty": "Нет групп расширений"
"extEmpty": "Нет групп расширений",
"cantModifyRunning": "Нельзя изменить запущенный профиль",
"cantModifyLaunching": "Нельзя изменить профиль во время запуска",
"cantModifyStopping": "Нельзя изменить профиль во время остановки",
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера"
},
"actions": {
"launch": "Запустить",
@@ -639,7 +643,11 @@
"profileSynced": "Профиль '{{name}}' успешно синхронизирован",
"profileSyncFailed": "Не удалось синхронизировать профиль '{{name}}'",
"profileSyncFailedWithError": "Не удалось синхронизировать профиль '{{name}}': {{error}}"
}
},
"enabledToast": "Синхронизация включена",
"disabledToast": "Синхронизация отключена",
"enabledDescription": "Синхронизация профиля включена",
"disabledDescription": "Синхронизация профиля отключена"
},
"integrations": {
"title": "Интеграции",
@@ -918,6 +926,11 @@
"syncingProfile": "Синхронизация профиля '{{name}}'...",
"syncingProfileWithProgress": "{{count}} файлов ({{size}})",
"updatingVersions": "Обновление версий браузеров..."
},
"progress": {
"remaining": "осталось {{time}}",
"filesProgress": "{{completed}}/{{total}} файлов",
"filesFailed": "Ошибка в {{count}} файле(ах)"
}
},
"errors": {
@@ -1136,7 +1149,9 @@
"addRule": "Добавить правило",
"rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local",
"noRules": "Правила обхода не настроены.",
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений.",
"vpnLabel": "VPN: {{name}}",
"proxyLabel": "Proxy: {{name}}"
},
"launchHook": {
"title": "URL хука запуска",
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.",
"lockedTitle": "Отпечаток — функция Pro",
"lockedDescription": "Для просмотра и редактирования отпечатка профиля требуется активный платный план. Оформите подписку, чтобы разблокировать защиту от отпечатков."
"lockedTitle": "Просмотр и редактирование отпечатка — функция Pro",
"lockedDescription": "Защита от отпечатков включена во все планы. Активный платный план требуется именно для просмотра и редактирования значений отпечатка профиля."
},
"syncStatusValue": {
"waiting": "Ожидание",
"syncing": "Синхронизация",
"synced": "Синхронизировано",
"error": "Ошибка"
}
},
"extensions": {
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "Загрузка версии {{version}} скоро начнётся. Запуск браузера отключён до завершения обновления.",
"downloadStarting": "Запуск загрузки {{browser}} {{version}}",
"downloadProgressBelow": "Прогресс загрузки будет показан ниже...",
"autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже."
"autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже.",
"lookingForUpdates": "Поиск обновлений для {{browser}}"
}
},
"profilePassword": {
@@ -1807,10 +1829,11 @@
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.",
"fingerprintRequiresPro": "Для защиты от отпечатков требуется активный платный план.",
"fingerprintRequiresPro": "Для просмотра или редактирования отпечатка требуется активный платный план. Защита включена во все планы.",
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан."
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox (Camoufox) больше не поддерживается. Camoufox выводится из эксплуатации — используйте Wayfern."
},
"rail": {
"profiles": "Профили",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "Поддержка браузера скоро завершится",
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 г.: {{profiles}}. Перейдите на профили Wayfern или Camoufox."
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 года: {{profiles}}. Перейдите на профили Wayfern."
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2 недели бесплатно",
"commercialDesc": "Бесплатно в течение 2-недельного ознакомительного периода. После этого требуется платный план, что помогает поддерживать и развивать проект."
}
},
"wayfernBlocked": {
"title": "Автоматизация браузера приостановлена",
"description": "Доступ вашей учётной записи к Pro-функциям браузера временно ограничен — обычно из-за входа сразу на нескольких устройствах. Выйдите из аккаунта на других устройствах и перезапустите профиль, чтобы восстановить доступ."
},
"camoufoxDeprecation": {
"title": "Поддержка Camoufox прекращается",
"description": "Поддержка профилей Camoufox прекращается 8 июля 2026 года. У вас есть один или несколько профилей Camoufox. Перенесите их на Wayfern до этой даты — после неё профили Camoufox могут перестать работать.",
"acknowledge": "Понятно"
}
}
File diff suppressed because it is too large Load Diff
+41 -9
View File
@@ -239,7 +239,11 @@
"extDefault": "默认",
"dnsLevel": "DNS 屏蔽列表: {{level}}",
"extSearch": "搜索分组…",
"extEmpty": "没有扩展组"
"extEmpty": "没有扩展组",
"cantModifyRunning": "无法修改正在运行的配置文件",
"cantModifyLaunching": "启动期间无法修改配置文件",
"cantModifyStopping": "停止期间无法修改配置文件",
"cantModifyUpdating": "浏览器更新期间无法修改配置文件"
},
"actions": {
"launch": "启动",
@@ -639,7 +643,11 @@
"profileSynced": "配置文件 '{{name}}' 同步成功",
"profileSyncFailed": "同步配置文件 '{{name}}' 失败",
"profileSyncFailedWithError": "同步配置文件 '{{name}}' 失败: {{error}}"
}
},
"enabledToast": "已启用同步",
"disabledToast": "已禁用同步",
"enabledDescription": "已启用配置文件同步",
"disabledDescription": "已禁用配置文件同步"
},
"integrations": {
"title": "集成",
@@ -918,6 +926,11 @@
"syncingProfile": "正在同步配置文件 '{{name}}'...",
"syncingProfileWithProgress": "{{count}} 个文件 ({{size}})",
"updatingVersions": "正在更新浏览器版本..."
},
"progress": {
"remaining": "剩余 {{time}}",
"filesProgress": "{{completed}}/{{total}} 个文件",
"filesFailed": "{{count}} 个文件失败"
}
},
"errors": {
@@ -1136,7 +1149,9 @@
"addRule": "添加规则",
"rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local",
"noRules": "未配置绕过规则。",
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
"ruleTypes": "支持主机名、IP地址和正则表达式模式。",
"vpnLabel": "VPN{{name}}",
"proxyLabel": "代理:{{name}}"
},
"launchHook": {
"title": "启动钩子 URL",
@@ -1196,8 +1211,14 @@
},
"fingerprint": {
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。",
"lockedTitle": "指纹是 Pro 功能",
"lockedDescription": "查看和编辑配置文件的指纹需要有效的付费方案。升级后即可解锁指纹保护。"
"lockedTitle": "查看和编辑指纹是 Pro 功能",
"lockedDescription": "所有方案都包含指纹保护。查看和编辑配置文件的指纹数值才需要有效的付费方案。"
},
"syncStatusValue": {
"waiting": "等待中",
"syncing": "同步中",
"synced": "已同步",
"error": "错误"
}
},
"extensions": {
@@ -1718,7 +1739,8 @@
"updateStartedDescription": "版本 {{version}} 即将开始下载。更新完成前浏览器启动将被禁用。",
"downloadStarting": "正在开始下载 {{browser}} {{version}}",
"downloadProgressBelow": "下载进度将显示在下方...",
"autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。"
"autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。",
"lookingForUpdates": "正在检查 {{browser}} 的更新"
}
},
"profilePassword": {
@@ -1807,10 +1829,11 @@
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。",
"fingerprintRequiresPro": "指纹保护需要有效的付费方案。",
"fingerprintRequiresPro": "查看或编辑指纹需要有效的付费方案。所有方案均包含指纹保护。",
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。"
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的 (Camoufox) 配置文件。Camoufox 即将停用——请改用 Wayfern。"
},
"rail": {
"profiles": "配置文件",
@@ -1939,7 +1962,7 @@
},
"browserSupport": {
"endingSoonTitle": "浏览器支持即将结束",
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 或 Camoufox 配置文件。"
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 配置文件。"
},
"onboarding": {
"steps": {
@@ -2017,5 +2040,14 @@
"trialBadge": "2 周免费",
"commercialDesc": "在 2 周评估期内免费。之后需要付费方案,这有助于本项目的持续维护与发展。"
}
},
"wayfernBlocked": {
"title": "浏览器自动化已暂停",
"description": "您的账户暂时被限制使用 Pro 浏览器功能,通常是因为同时在多台设备上登录。请退出其他设备的登录,然后重新启动配置文件即可恢复。"
},
"camoufoxDeprecation": {
"title": "Camoufox 支持即将结束",
"description": "Camoufox 配置文件的支持将于 2026 年 7 月 8 日结束。您有一个或多个 Camoufox 配置文件。请在此之前迁移到 Wayfern——之后 Camoufox 配置文件可能会停止工作。",
"acknowledge": "知道了"
}
}
+3
View File
@@ -32,6 +32,7 @@ export type BackendErrorCode =
| "PROXY_NOT_WORKING"
| "PROXY_PAYMENT_REQUIRED"
| "VPN_NOT_WORKING"
| "CAMOUFOX_IMPORT_DEPRECATED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -132,6 +133,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.proxyPaymentRequired");
case "VPN_NOT_WORKING":
return t("backendErrors.vpnNotWorking");
case "CAMOUFOX_IMPORT_DEPRECATED":
return t("backendErrors.camoufoxImportDeprecated");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+86
View File
@@ -0,0 +1,86 @@
import type { CloudUser, Entitlements } from "@/types";
const DEFAULT_REQUESTS_PER_HOUR = 100;
interface Capabilities {
browserAutomation: boolean;
crossOsFingerprints: boolean;
cloudBackup: boolean;
teamCollaboration: boolean;
}
const NONE: Entitlements = {
active: false,
browserAutomation: false,
crossOsFingerprints: false,
cloudBackup: false,
teamCollaboration: false,
profileLimit: 0,
requestsPerHour: 0,
};
// Mirror of PLAN_CAPABILITIES in apps/backend/src/plans/entitlements.ts. Keep in
// sync — a new plan must be declared here too, or it falls back to DEFAULT_PAID.
const PLAN_CAPABILITIES: Record<string, Capabilities> = {
starter: {
browserAutomation: false,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: false,
},
pro: {
browserAutomation: true,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: false,
},
team: {
browserAutomation: true,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: true,
},
enterprise: {
browserAutomation: true,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: true,
},
};
// Unknown paid plan -> pro-level (never team), matching the backend default.
const DEFAULT_PAID: Capabilities = {
browserAutomation: true,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: false,
};
/**
* The user's effective entitlements. Prefers the backend-resolved object the
* desktop attaches to CloudUser; only falls back to deriving from the plan
* fields when it's missing (older cached state). The fallback mirrors the
* backend matrix in `apps/backend/src/plans/entitlements.ts`.
*/
export function getEntitlements(
user: CloudUser | null | undefined,
): Entitlements {
if (user?.entitlements) return user.entitlements;
if (!user) return NONE;
const active =
user.plan !== "free" &&
(user.subscriptionStatus === "active" || user.planPeriod === "lifetime");
if (!active) return NONE;
const caps = PLAN_CAPABILITIES[user.plan] ?? DEFAULT_PAID;
return {
active: true,
browserAutomation: caps.browserAutomation,
crossOsFingerprints: caps.crossOsFingerprints,
cloudBackup: caps.cloudBackup,
teamCollaboration: caps.teamCollaboration,
profileLimit: user.profileLimit,
requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0,
};
}
+17 -24
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import React from "react";
import { type ExternalToast, toast as sonnerToast } from "sonner";
import { UnifiedToast } from "@/components/custom-toast";
import i18n from "@/i18n";
interface BaseToastProps {
id?: string;
@@ -29,12 +30,7 @@ interface ErrorToastProps extends BaseToastProps {
interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
stage?: "downloading" | "extracting" | "verifying" | "completed";
progress?: {
percentage: number;
speed?: string;
@@ -159,25 +155,19 @@ export function showToast(props: ToastProps & { id?: string }) {
export function showDownloadToast(
browserName: string,
version: string,
stage:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)",
stage: "downloading" | "extracting" | "verifying" | "completed",
progress?: { percentage: number; speed?: string; eta?: string },
options?: { suppressCompletionToast?: boolean; onCancel?: () => void },
) {
const tParams = { browser: browserName, version };
const title =
stage === "completed"
? `${browserName} ${version} downloaded successfully!`
? i18n.t("toasts.success.downloadComplete", tParams)
: stage === "downloading"
? `Downloading ${browserName} ${version}`
? i18n.t("toasts.loading.downloading", tParams)
: stage === "extracting"
? `Extracting ${browserName} ${version}`
: stage === "downloading (twilight rolling release)"
? `Downloading ${browserName} ${version}`
: `Verifying ${browserName} ${version}`;
? i18n.t("toasts.loading.extracting", tParams)
: i18n.t("toasts.loading.verifying", tParams);
// Don't show completion toast if suppressed (for auto-update scenarios)
if (stage === "completed" && options?.suppressCompletionToast) {
@@ -186,9 +176,7 @@ export function showDownloadToast(
}
// Only show cancel button during active downloading, not for completed/extracting/verifying
const showCancel =
stage === "downloading" ||
stage === "downloading (twilight rolling release)";
const showCancel = stage === "downloading";
return showToast({
type: "download",
@@ -241,10 +229,15 @@ export function showAutoUpdateToast(
) {
return showToast({
type: "loading",
title: `${browserName} update started`,
title: i18n.t("versionUpdater.toast.updateStarted", {
browser: browserName,
}),
description:
options?.description ??
`Automatically downloading ${browserName} ${version}. Progress will be shown in download notifications.`,
i18n.t("versionUpdater.toast.autoDownloadStarted", {
browser: browserName,
version,
}),
id: options?.id ?? `auto-update-${browserName.toLowerCase()}-${version}`,
duration: options?.duration ?? 4000,
});
@@ -270,7 +263,7 @@ export function showSyncProgressToast(
) {
return showToast({
type: "sync-progress",
title: `Syncing profile '${profileName}'...`,
title: i18n.t("toasts.loading.syncingProfile", { name: profileName }),
progress,
id: options?.id,
duration: Number.POSITIVE_INFINITY,
+21
View File
@@ -75,6 +75,24 @@ export interface SyncSettings {
sync_token?: string;
}
/**
* Capability/limit set derived from the plan by the backend. Features are gated
* on these flags instead of a single "is paid?" check, so a plan like the future
* "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
* data. Mirrors `apps/backend/src/plans/entitlements.ts`. Resolve via
* `getEntitlements()` the desktop populates it, but it stays optional for
* safety on older state.
*/
export interface Entitlements {
active: boolean;
browserAutomation: boolean;
crossOsFingerprints: boolean;
cloudBackup: boolean;
teamCollaboration: boolean;
profileLimit: number;
requestsPerHour: number;
}
export interface CloudUser {
id: string;
email: string;
@@ -95,6 +113,9 @@ export interface CloudUser {
deviceOrdinal?: number | null;
deviceCount?: number | null;
isPrimaryDevice?: boolean | null;
// Plan-derived capabilities. The desktop resolves this before handing CloudUser
// to the UI; optional to stay safe on older cached state.
entitlements?: Entitlements;
}
export interface ProfileLockInfo {