Compare commits

..

60 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
andy e501e7a260 Merge pull request #401 from zhom/dependabot/github_actions/github-actions-c1330d3724
ci(deps): bump the github-actions group across 1 directory with 3 updates
2026-05-31 14:40:46 -07:00
dependabot[bot] 801bd3fe90 ci(deps): bump the github-actions group across 1 directory with 3 updates
Bumps the github-actions group with 3 updates in the / directory: [anomalyco/opencode](https://github.com/anomalyco/opencode), [actions/ai-inference](https://github.com/actions/ai-inference) and [crate-ci/typos](https://github.com/crate-ci/typos).


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

Updates `actions/ai-inference` from 2.1.0 to 2.1.1
- [Release notes](https://github.com/actions/ai-inference/releases)
- [Commits](https://github.com/actions/ai-inference/compare/17ff458cb182449bbb2e43701fcd98f6af8f6570...a7805884c80886efc241e94a5351df715968a0ad)

Updates `crate-ci/typos` from 1.46.2 to 1.47.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/aca895bf05aec0cb7dffa6f94495e923224d9f17...f8a58b6b53f2279f71eb605f03a4ae4d10608f45)

---
updated-dependencies:
- dependency-name: actions/ai-inference
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.15.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-31 21:11:20 +00:00
andy b4074c1ee6 Merge pull request #403 from zhom/contributors-readme-action-pHvR07AJ6G
docs(contributor): contributors readme action update
2026-05-31 14:07:43 -07:00
github-actions[bot] 08cde9c0dc docs(contributor): contrib-readme-action has updated readme 2026-05-31 21:06:50 +00:00
zhom 98f1c7452a feat: add onboarding 2026-06-01 01:05:35 +04: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
zhom 3a3f201065 fix: nix missing dependency 2026-05-29 07:50:38 +04:00
zhom ecafb5e1c0 refactor: cleanup 2026-05-29 06:31:42 +04:00
huy97 17e33aa53f chore: add huy97 as the contributor 2026-05-29 06:11:50 +04:00
zhom 4436b69bf9 chore: ignore CHANGELOG.md 2026-05-29 04:54:26 +04:00
zhom 3bc9127c06 refactor: unify browser launch logic 2026-05-29 04:54:26 +04:00
andy 072cb24e5b Merge pull request #391 from huy97/feature/close-confirm-tray-dialog
feat: confirm minimize-to-tray or quit when closing the window
2026-05-28 17:54:09 -07:00
andy 3224faa2da Merge pull request #393 from zhom/contributors-readme-action-zmoTG4UlSP
docs(contributor): contributors readme action update
2026-05-28 17:51:16 -07:00
github-actions[bot] d067920392 docs(contributor): contrib-readme-action has updated readme 2026-05-29 00:46:45 +00:00
andy 9656f3f426 Merge pull request #388 from webees/fix/macos-permission-grant-feedback
fix: improve macOS permission grant feedback
2026-05-28 17:46:34 -07:00
JockLee f730fd958d fix: improve macOS permission grant feedback 2026-05-29 05:09:56 +07:00
andy 2310292b35 Merge pull request #392 from zhom/contributors-readme-action-7y5xf5xfO7
docs(contributor): contributors readme action update
2026-05-28 10:54:19 -07:00
github-actions[bot] 0b6af0cb10 docs(contributor): contrib-readme-action has updated readme 2026-05-28 17:50:27 +00:00
andy b78ee14cbe Merge pull request #389 from webees/fix/cloud-login-external-browser
Fix cloud login opening in profile selector
2026-05-28 10:47:36 -07:00
Huy Le Tien fdecf445ec feat: confirm minimize-to-tray or quit when closing the window
Intercept the main window CloseRequested event so the user can choose
between minimizing the app to the system tray and quitting, instead of
the close button immediately tearing the process down.

- Add an on_window_event handler that prevents close, emits
  close-confirm-requested, and lets the next CloseRequested through
  once confirm_quit flips a QUIT_CONFIRMED flag.
- Add a TrayIconBuilder in the main process with Show / Quit menu items
  and a left-click handler that restores the window. Tray icon is
  decoded via the image crate so the donut glyph renders on every
  platform.
- Add hide_to_tray command used by the dialog's Minimize action.
- New CloseConfirmDialog React component mounted in app/page.tsx.
- Enable Tauri features tray-icon and image-png.
- Add closeConfirm strings across all eight locale files.

The existing standalone donut-daemon tray binary is left untouched.
2026-05-29 00:10:48 +07:00
JockLee d5f260bd7e fix: open cloud login in external browser 2026-05-27 20:09:00 +07:00
github-actions[bot] 56c547d7e0 chore: update flake.nix for v0.24.4 [skip ci] (#385)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-26 02:21:50 +00:00
github-actions[bot] 4396754cbd docs: update CHANGELOG.md and README.md for v0.24.4 [skip ci] (#384)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-26 02:21:29 +00:00
zhom 60c7c72036 chore: versiom bump 2026-05-26 04:42:31 +04:00
zhom f81e8b6162 refactor: more robust camoufox proxy handling 2026-05-26 04:40:19 +04:00
github-actions[bot] e4ecd0d18a chore: update flake.nix for v0.24.3 [skip ci] (#383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-25 00:02:17 +00:00
github-actions[bot] 8bc2dc3102 docs: update CHANGELOG.md and README.md for v0.24.3 [skip ci] (#382)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-25 00:01:55 +00:00
126 changed files with 8057 additions and 4652 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
+9 -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
@@ -47,3 +47,11 @@ jobs:
- name: Run flake info app
run: nix run .#info
# `nix flake show` above only evaluates the flake. This step actually
# compiles the app inside the Nix environment, which is what catches a
# missing build-time dependency — in particular libayatana-appindicator
# (required by libappindicator-sys for the Linux system tray). The build
# fails here if that dependency is dropped from the flake.
- name: Build the app via the flake
run: nix run .#build
+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@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
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 -5
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
@@ -88,7 +88,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --release
cargo build --bin donut-daemon --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -97,12 +96,9 @@ jobs:
HOST_TARGET="${{ steps.host_target.outputs.target }}"
if [[ "$HOST_TARGET" == *"windows"* ]]; then
cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
cp src-tauri/target/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${HOST_TARGET}.exe
else
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
cp src-tauri/target/release/donut-daemon src-tauri/binaries/donut-daemon-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-daemon-${HOST_TARGET}
fi
- name: Run rustfmt check
+2 -2
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
@@ -126,7 +126,7 @@ jobs:
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
+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
@@ -82,7 +82,7 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
+10 -9
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
@@ -162,7 +162,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -170,12 +169,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
@@ -250,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
@@ -287,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
@@ -453,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
@@ -551,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 -7
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
@@ -161,7 +161,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -169,12 +168,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
@@ -251,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/"
@@ -283,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@aca895bf05aec0cb7dffa6f94495e923224d9f17 #v1.46.2
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: |
+66 -8
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
@@ -56,6 +54,15 @@ donutbrowser/
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
## Logs (when debugging a running app)
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 `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.
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.
## Code Quality
- Don't leave comments that don't add value
@@ -66,12 +73,12 @@ donutbrowser/
- 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)
@@ -85,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.
@@ -138,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.
@@ -148,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.
@@ -206,6 +213,57 @@ The `.github/workflows/publish-repos.yml` workflow runs automatically after stab
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
## Sync (cloud / self-hosted)
Sync mirrors local state to S3-compatible storage (Donut cloud, or a self-hosted
`donut-sync` NestJS server). Two distinct mechanisms live in `src-tauri/src/sync/`:
- **Profile browser files** (the Chromium/Firefox profile directory): a
**content-hash manifest** (`manifest.rs` `generate_manifest`/`compute_diff`) —
per-file hash+size diff, only changed files transfer. `sync_profile` in
`engine.rs`.
- **Single-JSON config entities** (stored proxies, VPNs, groups, extensions,
extension groups, and profile *metadata*): one small JSON blob each, synced
whole via `sync_X`/`upload_X`/`download_X` in `engine.rs`.
### Conflict resolution — one rule everywhere: `updated_at` last-write-wins
Every config entity carries `updated_at: Option<u64>` (unix seconds;
`extension_manager` uses a non-Optional `u64`). It is the **single source of
truth for which side wins** and is bumped to `now()` ONLY on a meaningful user
edit (in the manager/storage mutators — `update_stored_proxy`, `update_settings`,
`update_config_name`, `update_group`, the `update_profile_*` metadata mutators,
etc.), NEVER by sync bookkeeping. Use `crate::proxy_manager::now_secs()`.
`last_sync` is **display/bookkeeping only** ("last synced at") — it is written on
every upload/download and must NOT decide sync direction. (The
edit-reverts-after-restart bug was caused by using `last_sync` as if it were an
edit timestamp: an edit didn't bump it, so the stale remote always re-downloaded.)
Reconcile (`engine.rs::remote_updated_at` + each `sync_X`):
1. `stat` (HEAD) the remote object. Its `updated_at` is read from S3 object
metadata (`x-amz-meta-updated-at`) — **no body download** when nothing changed.
2. Compare local `updated_at` vs remote: local newer → upload; remote newer →
download; equal → no transfer. Legacy objects with no timestamp resolve to 0,
so any real edit wins.
3. **Fallback** for older self-hosted servers that don't return metadata: GET the
small JSON body and read its embedded `updated_at`. Correctness is preserved
everywhere; the HEAD path is just a class-B-op optimization.
Uploads go through `engine.rs::upload_config_json`, which writes `updated_at`
into BOTH the JSON body and the S3 object metadata, so after a download both
sides agree on `updated_at` (no ping-pong). Adding a new synced config field?
Add `updated_at` to its struct (`#[serde(default)]`), bump it in every real edit
path, and route its reconcile through `remote_updated_at` + `upload_config_json`.
### Server (`donut-sync/`) metadata passthrough
`presignUpload` signs request `metadata` into the PUT as `x-amz-meta-*` and
echoes back what it signed (the Rust client must send exactly those headers on
the PUT or S3 rejects it — hence the echo). `stat` returns `response.Metadata`.
Older servers omit `metadata` → client falls back to the body-GET path. DTOs:
`donut-sync/src/sync/dto/sync.dto.ts`; logic: `sync.service.ts`.
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+124
View File
@@ -1,6 +1,130 @@
# 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
- more robust camoufox proxy handling
### Documentation
- update CHANGELOG.md and README.md for v0.24.3 [skip ci] (#382)
- readme
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.3 [skip ci] (#383)
## v0.24.3 (2026-05-25)
### Features
- add shortcuts
### Bug Fixes
- track gecko_id for extension groups
### Refactoring
- cleanup
- cleanup, korean translation
- reduce token usage
### Maintenance
- chore: version bump
- chore: linting
- chore: update pnpm
- chore: make telegram releases ai-generated
- chore: workflow cleanup
- ci(deps): bump the github-actions group with 6 updates
- chore: use less tokens
- chore: improve issue validation
- ci(deps): bump the github-actions group across 1 directory with 6 updates
- chore: update flake.nix for v0.24.2 [skip ci] (#370)
### Other
- deps(rust)(deps): bump the rust-dependencies group
- deps(rust)(deps): bump the rust-dependencies group
## v0.24.2 (2026-05-16)
### Features
+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
+22 -6
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.2/Donut_0.24.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_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.2/Donut_0.24.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_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.2/Donut_0.24.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_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:
@@ -135,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Hassiy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/webees">
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
<br />
<sub><b>JockLee</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/yb403">
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
@@ -142,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"/>
@@ -149,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"/>
+3 -1
View File
@@ -3,7 +3,9 @@ extend-exclude = [
"src-tauri/src/camoufox/data/*.json",
"src-tauri/src/camoufox/data/*.xml",
"src/i18n/locales/*.json",
"src-tauri/build.rs",
# Auto-generated from commit subjects by release.yml; typos here originate
# in commit messages, which are immutable, so don't spell-check it.
"CHANGELOG.md",
]
[default.extend-words]
+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;
+8
View File
@@ -6,17 +6,25 @@ export class StatResponseDto {
exists: boolean;
lastModified?: string;
size?: number;
// User-defined S3 object metadata (lowercased keys, no `x-amz-meta-` prefix).
// Carries `updated-at` for sync conflict resolution via HEAD (no body GET).
metadata?: Record<string, string>;
}
export class PresignUploadRequestDto {
key: string;
contentType?: string;
expiresIn?: number;
// Object metadata to sign into the presigned PUT as `x-amz-meta-*`.
metadata?: Record<string, string>;
}
export class PresignUploadResponseDto {
url: string;
expiresAt: string;
// Metadata the server actually signed; the client must echo it as
// `x-amz-meta-*` headers on the PUT (older clients/servers omit it).
metadata?: Record<string, string>;
}
export class PresignDownloadRequestDto {
+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");
}
+40 -4
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);
@@ -256,6 +279,10 @@ export class SyncService implements OnModuleInit {
exists: true,
lastModified: response.LastModified?.toISOString(),
size: response.ContentLength,
// S3 returns user metadata with lowercased keys and no `x-amz-meta-`
// prefix. Clients read `updated-at` from here to resolve sync conflicts
// without downloading the object body.
metadata: response.Metadata,
};
} catch (error: unknown) {
if (
@@ -282,13 +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: metadata,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
@@ -306,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,
};
}
@@ -316,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({
@@ -431,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(
@@ -484,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": {
+7 -5
View File
@@ -34,6 +34,7 @@
libsoup_3
glib
gtk3
libayatana-appindicator
cairo
gdk-pixbuf
pango
@@ -84,6 +85,7 @@
pkgs.gdk-pixbuf
pkgs.glib
pkgs.gtk3
pkgs.libayatana-appindicator
pkgs.libsoup_3
pkgs.libxkbcommon
pkgs.openssl
@@ -94,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.24.2";
releaseVersion = "0.26.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage";
hash = "sha256-140PSB/1BLGUB4sI/RgfYe7uUjwRFWXtdSnUZz6Wr0U=";
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.2/Donut_0.24.2_aarch64.AppImage";
hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
}
else
null;
+6 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.3",
"version": "0.26.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -37,6 +37,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.10",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -54,16 +55,19 @@
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"framer-motion": "^12.38.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -78,6 +82,7 @@
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
+65
View File
@@ -33,6 +33,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-portal':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -84,6 +87,9 @@ importers:
ahooks:
specifier: ^3.9.7
version: 3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -99,6 +105,9 @@ importers:
flag-icons:
specifier: ^7.5.0
version: 7.5.0
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
i18next:
specifier: ^26.1.0
version: 26.1.0(typescript@6.0.3)
@@ -114,6 +123,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
onborda:
specifier: ^1.2.5
version: 1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -151,6 +163,9 @@ importers:
'@tauri-apps/cli':
specifier: ~2.11.1
version: 2.11.1
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/color':
specifier: ^4.2.1
version: 4.2.1
@@ -1673,6 +1688,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.10':
resolution: {integrity: sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@@ -2483,6 +2511,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/color-convert@2.0.4':
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
@@ -3012,6 +3043,9 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -4285,6 +4319,15 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
onborda@1.2.5:
resolution: {integrity: sha512-S9EtQpKr8oYz7j0Bmr0w7BdG4Q4ud6QuNxBsSShzcf9khhuLEEjkbhYYMmdMlVa56QK/rXW/9pc8JJvBXUhOeA==}
peerDependencies:
'@radix-ui/react-portal': '>=1.1.1'
framer-motion: '>=11'
next: '>=13'
react: '>=18'
react-dom: '>=18'
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -7002,6 +7045,16 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -7822,6 +7875,8 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.7.0
'@types/canvas-confetti@1.9.0': {}
'@types/color-convert@2.0.4':
dependencies:
'@types/color-name': 1.1.5
@@ -8372,6 +8427,8 @@ snapshots:
caniuse-lite@1.0.30001792: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -9726,6 +9783,14 @@ snapshots:
dependencies:
ee-first: 1.1.1
onborda@1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@radix-ui/react-portal': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
framer-motion: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
once@1.4.0:
dependencies:
wrappy: 1.0.2
+101 -155
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.3"
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,9 +1838,9 @@ dependencies = [
"sha2 0.11.0",
"shadowsocks",
"smoltcp",
"subtle",
"sys-locale",
"sysinfo",
"tao",
"tar",
"tauri",
"tauri-build",
@@ -1861,7 +1861,6 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
"tower",
"tower-http",
"tray-icon 0.24.0",
"url",
"urlencoding",
"utoipa",
@@ -2099,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]]
@@ -2864,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]]
@@ -2938,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",
@@ -2998,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",
@@ -3431,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",
@@ -3444,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",
@@ -3623,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",
@@ -3649,43 +3651,24 @@ 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",
"vcpkg",
]
[[package]]
name = "libxdo"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
dependencies = [
"libxdo-sys",
]
[[package]]
name = "libxdo-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
dependencies = [
"libc",
"x11",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3709,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",
]
@@ -3820,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"
@@ -3870,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",
@@ -3923,7 +3906,6 @@ dependencies = [
"dpi",
"gtk",
"keyboard-types",
"libxdo",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -3932,7 +3914,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4469,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]]
@@ -5345,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",
@@ -5518,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",
@@ -5583,7 +5565,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5658,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"
@@ -5733,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"
@@ -5938,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",
@@ -5958,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",
@@ -5983,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",
@@ -6133,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"
@@ -6247,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]]
@@ -6324,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",
@@ -6468,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",
@@ -6606,6 +6572,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -6619,7 +6586,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.3",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -6632,7 +6599,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon 0.23.1",
"tray-icon",
"url",
"webkit2gtk",
"webview2-com",
@@ -6973,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",
@@ -7001,7 +6968,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7503,28 +7470,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
]
[[package]]
name = "tray-icon"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674"
dependencies = [
"crossbeam-channel",
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7584,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"
@@ -7596,7 +7542,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7684,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"
@@ -7844,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",
@@ -8254,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]]
@@ -8780,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]]
@@ -9060,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",
@@ -9083,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",
@@ -9118,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",
@@ -9144,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",
@@ -9351,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",
@@ -9365,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",
@@ -9378,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",
+7 -12
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.3"
version = "0.26.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -24,10 +24,6 @@ path = "src/main.rs"
name = "donut-proxy"
path = "src/bin/proxy_server.rs"
[[bin]]
name = "donut-daemon"
path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.47"
@@ -35,7 +31,7 @@ resvg = "0.47"
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "test"] }
tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
@@ -85,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"] }
@@ -98,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"
@@ -111,9 +108,7 @@ quick-xml = { version = "0.40", features = ["serialize"] }
boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.24"
tao = "0.35"
# Tray icon decoding (main-process system tray)
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
@@ -145,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"
+5 -11
View File
@@ -5,7 +5,7 @@ fn main() {
// This allows running cargo test without building the frontend first
ensure_dist_folder_exists();
// Generate tray icon PNGs from SVG (macOS template icon format)
// Generate tray icon PNG files from SVG (macOS template icon format)
generate_tray_icons();
#[cfg(target_os = "macos")]
@@ -93,19 +93,13 @@ fn external_binaries_exist() -> bool {
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
// Check for all required external binaries (must match tauri.conf.json externalBin)
let (donut_proxy_name, donut_daemon_name) = if target.contains("windows") {
(
format!("donut-proxy-{}.exe", target),
format!("donut-daemon-{}.exe", target),
)
let donut_proxy_name = if target.contains("windows") {
format!("donut-proxy-{}.exe", target)
} else {
(
format!("donut-proxy-{}", target),
format!("donut-daemon-{}", target),
)
format!("donut-proxy-{}", target)
};
binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&donut_daemon_name).exists()
binaries_dir.join(&donut_proxy_name).exists()
}
fn ensure_dist_folder_exists() {
+11
View File
@@ -21,6 +21,17 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"opener:default",
{
"identifier": "opener:allow-open-url",
"allow": [
{
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
},
{
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera"
}
]
},
"fs:default",
"shell:allow-execute",
"shell:allow-kill",
-1
View File
@@ -77,4 +77,3 @@ function copyBinary(baseName) {
}
copyBinary("donut-proxy");
copyBinary("donut-daemon");
-3
View File
@@ -102,6 +102,3 @@ copy_binary() {
# Copy donut-proxy binary
copy_binary "donut-proxy"
# Copy donut-daemon binary
copy_binary "donut-daemon"
+182 -30
View File
@@ -1,6 +1,5 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::daemon_ws::{ws_handler, WsState};
use crate::events;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
@@ -59,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>,
@@ -75,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>,
@@ -406,22 +419,18 @@ 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,
))
.layer(middleware::from_fn(terms_check_middleware));
// Create WebSocket route with its own state (no auth required for daemon IPC)
let ws_state = WsState::new();
let ws_routes = Router::new()
.route("/events", get(ws_handler))
.with_state(ws_state);
let api_for_v1 = api.clone();
let app = Router::new()
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.route(
"/v1/openapi.json",
@@ -516,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);
}
@@ -558,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()));
@@ -594,6 +623,14 @@ 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.
/// 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
#[utoipa::path(
get,
@@ -624,10 +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: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
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
@@ -681,10 +715,7 @@ async fn get_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
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
@@ -700,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(
@@ -721,6 +762,34 @@ async fn create_profile(
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
// 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 {
serde_json::from_value(config.clone()).ok()
@@ -735,13 +804,25 @@ async fn create_profile(
None
};
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
// (expired proxy subscription) maps to 402; anything else is a 400.
if let Err(err) =
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
{
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
StatusCode::PAYMENT_REQUIRED
} else {
StatusCode::BAD_REQUEST
});
}
// Create profile using the async create_profile_with_group method
match profile_manager
.create_profile_with_group(
&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(),
@@ -784,10 +865,7 @@ async fn create_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type,
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
@@ -892,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) => {
@@ -1710,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);
@@ -1750,13 +1836,15 @@ async fn run_profile(
port
};
// Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging(
// Use the same launch path as the main app, but force a fresh instance with
// remote debugging enabled so the returned port is the one the browser binds.
match crate::browser_runner::launch_browser_profile_impl(
state.app_handle.clone(),
profile.clone(),
url,
Some(remote_debugging_port),
headless,
true,
)
.await
{
@@ -1794,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);
@@ -1820,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")
),
@@ -1832,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()
@@ -2067,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(""));
}
}
+28
View File
@@ -26,6 +26,23 @@ pub fn is_portable() -> bool {
portable_dir().is_some()
}
/// Optional single-root override for all on-disk state. Set
/// `DONUTBROWSER_DATA_ROOT=/path` (e.g. a tmpfs mount) to relocate
/// data/cache/logs under `<root>/{data,cache,logs}` without touching the real
/// dev/prod directories. The more specific `DONUTBROWSER_DATA_DIR` /
/// `DONUTBROWSER_CACHE_DIR` overrides still take precedence over this.
fn data_root() -> Option<PathBuf> {
std::env::var_os("DONUTBROWSER_DATA_ROOT")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
/// otherwise, in which case the platform default app log dir is used.
pub fn log_dir_override() -> Option<PathBuf> {
data_root().map(|root| root.join("logs"))
}
pub fn app_name() -> &'static str {
if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -46,6 +63,10 @@ pub fn data_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("data");
}
if let Some(dir) = portable_dir() {
return dir.join("data");
}
@@ -65,6 +86,10 @@ pub fn cache_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("cache");
}
if let Some(dir) = portable_dir() {
return dir.join("cache");
}
@@ -112,6 +137,9 @@ pub fn dns_blocklist_dir() -> PathBuf {
/// `LogDir` target used in the plugin builder so the path matches what's
/// actually on disk for this OS.
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
if let Some(dir) = log_dir_override() {
return dir;
}
use tauri::Manager;
handle
.path()
+1
View File
@@ -703,6 +703,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
-498
View File
@@ -1,498 +0,0 @@
// Donut Browser Daemon - Background process for tray icon and services
// This runs independently of the main Tauri GUI
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
use tray_icon::menu::MenuEvent;
use tray_icon::TrayIcon;
#[cfg(not(target_os = "macos"))]
use tray_icon::{MouseButton, TrayIconEvent};
use donutbrowser_lib::daemon::{autostart, services, tray};
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
enum ServiceStatus {
Ready {
api_port: Option<u16>,
mcp_running: bool,
},
Failed(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
api_port: Option<u16>,
mcp_running: bool,
version: String,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn ensure_data_dir() -> std::io::Result<()> {
if let Some(data_dir) = autostart::get_data_dir() {
fs::create_dir_all(&data_dir)?;
}
Ok(())
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
fn write_state(state: &DaemonState) -> std::io::Result<()> {
let path = get_state_path();
let content = serde_json::to_string_pretty(state)?;
fs::write(path, content)
}
fn set_high_priority() {
#[cfg(unix)]
{
// Set high priority so the daemon is killed last under resource pressure
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
unsafe {
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
}
}
#[cfg(windows)]
{
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
};
// Set high priority so the daemon is killed last under resource pressure
unsafe {
let handle = GetCurrentProcess();
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
// GetCurrentProcess returns a pseudo-handle that doesn't need to be closed,
// but we do it anyway for consistency
let _ = CloseHandle(handle);
}
}
}
fn run_daemon() {
// Set high priority so the daemon is less likely to be killed under resource pressure
set_high_priority();
// Initialize logging to file for debugging (since stdout/stderr may be redirected)
let log_path = autostart::get_data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("daemon.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path);
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.target(if let Ok(file) = log_file {
env_logger::Target::Pipe(Box::new(file))
} else {
env_logger::Target::Stderr
})
.init();
if let Err(e) = ensure_data_dir() {
eprintln!("Failed to create data directory: {}", e);
process::exit(1);
}
log::info!("[daemon] Starting with PID {}", process::id());
// Create tokio runtime for async operations
let rt = Runtime::new().expect("Failed to create tokio runtime");
// Create channel for service status updates
let (tx, rx) = mpsc::channel::<ServiceStatus>();
// Spawn services in a background thread so we don't block the event loop
let rt_handle = rt.handle().clone();
std::thread::spawn(move || {
let result = rt_handle.block_on(async { services::DaemonServices::start().await });
let status = match result {
Ok(s) => ServiceStatus::Ready {
api_port: s.api_port,
mcp_running: s.mcp_running,
},
Err(e) => ServiceStatus::Failed(e),
};
let _ = tx.send(status);
});
// Write initial state (services still starting)
let state = DaemonState {
daemon_pid: Some(process::id()),
api_port: None,
mcp_running: false,
version: env!("CARGO_PKG_VERSION").to_string(),
};
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
// Prepare tray menu and icon (but don't create the tray icon yet)
let tray_menu = tray::TrayMenu::new();
let icon = tray::load_icon();
let menu_channel = MenuEvent::receiver();
// Create the event loop IMMEDIATELY (critical for macOS tray icon)
let event_loop = EventLoopBuilder::new().build();
// Store tray icon in Option - created after event loop starts
let mut tray_icon: Option<TrayIcon> = None;
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
#[cfg(unix)]
unsafe {
extern "C" fn signal_handler(_sig: libc::c_int) {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
}
libc::signal(
libc::SIGTERM,
signal_handler as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGINT,
signal_handler as *const () as libc::sighandler_t,
);
}
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
1 // TRUE
}
unsafe {
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
}
}
// Run the event loop
event_loop.run(move |event, _, control_flow| {
// Use WaitUntil to check for menu events periodically while staying low on CPU
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100));
match event {
Event::NewEvents(StartCause::Init) => {
// Hide from dock on macOS (must be done after event loop starts)
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
// Create tray icon after event loop has started (required for macOS)
tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu));
log::info!("[daemon] Tray icon created");
}
Event::MainEventsCleared => {
// Check for service status updates from background thread
if let Ok(status) = rx.try_recv() {
match status {
ServiceStatus::Ready {
api_port,
mcp_running,
} => {
log::info!("[daemon] Services started successfully");
// Update state file
let mut state = read_state();
state.api_port = api_port;
state.mcp_running = mcp_running;
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
}
ServiceStatus::Failed(e) => {
log::error!("Failed to start services: {}", e);
}
}
}
// Process menu events
while let Ok(event) = menu_channel.try_recv() {
if event.id == tray_menu.quit_item.id() {
log::info!("[daemon] Quit requested");
SHOULD_QUIT.store(true, Ordering::SeqCst);
}
}
// Handle tray icon click (left-click opens the app)
// On macOS, left-click already shows the menu, so don't also launch the GUI.
#[cfg(not(target_os = "macos"))]
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
if let TrayIconEvent::Click {
button: MouseButton::Left,
..
} = event
{
tray::open_gui();
}
}
// Use swap to only run cleanup once
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
// Remove tray icon from status bar immediately so the UI feels responsive
tray_icon = None;
tray::quit_gui();
let mut state = read_state();
state.daemon_pid = None;
let _ = write_state(&state);
log::info!("[daemon] Exiting");
// Use process::exit for immediate termination instead of ControlFlow::Exit.
// ControlFlow::Exit can delay because tao's macOS event loop defers exit,
// and dropping the tokio runtime blocks until all spawned tasks finish.
process::exit(0);
}
}
Event::Reopen { .. } => {
tray::open_gui();
// Re-hide daemon from Dock. macOS activates the daemon (making it
// visible) when the user clicks the Dock icon, overriding the
// Accessory policy set at init.
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
}
_ => {}
}
// Keep tray_icon alive
let _ = &tray_icon;
// Keep runtime alive
let _ = &rt;
});
}
fn stop_daemon() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let state_path = get_state_path();
if let Ok(content) = fs::read_to_string(&state_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
let _ = Command::new("taskkill")
.args(["/PID", &gui_pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
}
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
} else {
eprintln!("Daemon is not running");
}
}
fn show_status() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
#[cfg(windows)]
let is_running = win_process_exists(pid);
#[cfg(not(any(unix, windows)))]
let is_running = false;
if is_running {
eprintln!("Daemon is running (PID {})", pid);
if let Some(port) = state.api_port {
eprintln!(" API: Running on port {}", port);
} else {
eprintln!(" API: Stopped");
}
eprintln!(
" MCP: {}",
if state.mcp_running {
"Running"
} else {
"Stopped"
}
);
} else {
eprintln!("Daemon is not running (stale PID in state file)");
}
} else {
eprintln!("Daemon is not running");
}
}
fn print_usage() {
eprintln!("Donut Browser Daemon");
eprintln!();
eprintln!("Usage: donut-daemon <command>");
eprintln!();
eprintln!("Commands:");
eprintln!(" start Start the daemon (detaches from terminal)");
eprintln!(" stop Stop the running daemon");
eprintln!(" status Show daemon status");
eprintln!(" run Run in foreground (for debugging)");
eprintln!(" autostart Manage autostart settings");
eprintln!(" enable Enable autostart on login");
eprintln!(" disable Disable autostart on login");
eprintln!(" status Show autostart status");
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_usage();
process::exit(1);
}
match args[1].as_str() {
"start" => {
run_daemon();
}
"stop" => {
stop_daemon();
}
"status" => {
show_status();
}
"run" => {
run_daemon();
}
"autostart" => {
if args.len() < 3 {
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
process::exit(1);
}
match args[2].as_str() {
"enable" => {
if let Err(e) = autostart::enable_autostart() {
eprintln!("Failed to enable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart enabled");
}
"disable" => {
if let Err(e) = autostart::disable_autostart() {
eprintln!("Failed to disable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart disabled");
}
"status" => {
if autostart::is_autostart_enabled() {
eprintln!("Autostart is enabled");
} else {
eprintln!("Autostart is disabled");
}
}
_ => {
eprintln!("Unknown autostart command: {}", args[2]);
process::exit(1);
}
}
}
_ => {
print_usage();
process::exit(1);
}
}
}
+1
View File
@@ -1220,6 +1220,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+68 -276
View File
@@ -7,78 +7,11 @@ use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System;
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
/// low-traffic window for the average user; everyone shares the same UTC
/// instant so the value here doesn't track any one user's local schedule.
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
/// File name of the per-profile marker recording the last fingerprint
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
/// and is excluded from cloud sync (see `sync::manifest`) so each device
/// runs its own refresh schedule.
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
/// Most recent rollover instant on or before `now` — used as a staleness
/// threshold for Wayfern fingerprints. Anything generated before this
/// timestamp is considered stale and gets regenerated on next launch.
fn most_recent_rollover_epoch() -> u64 {
let now = Utc::now();
let today_threshold = Utc
.with_ymd_and_hms(
now.year(),
now.month(),
now.day(),
FINGERPRINT_ROLLOVER_HOUR_UTC,
0,
0,
)
.single()
.unwrap_or(now);
let threshold = if now >= today_threshold {
today_threshold
} else {
today_threshold - chrono::Duration::days(1)
};
threshold.timestamp().max(0) as u64
}
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
}
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
/// Returns `None` if the file doesn't exist or its content can't be parsed —
/// both signal "needs a refresh" to the caller.
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
let path = last_fp_refresh_path(profile_id, profiles_dir);
let content = std::fs::read_to_string(&path).ok()?;
content.trim().parse::<u64>().ok()
}
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
/// this profile. Failure is logged but never propagated — a missing marker
/// only costs an extra regen on the next launch, never blocks one.
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
let path = last_fp_refresh_path(profile_id, profiles_dir);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
return;
}
}
}
if let Err(e) = std::fs::write(&path, ts.to_string()) {
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
}
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -448,6 +381,7 @@ impl BrowserRunner {
camoufox_config,
url,
override_profile_path,
remote_debugging_port,
headless,
)
.await
@@ -612,32 +546,12 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Decide whether to (re)generate the Wayfern fingerprint for this
// launch. Two triggers:
//
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
// randomization the user opted into.
// 2. The fingerprint hasn't been refreshed since the most recent
// rollover instant. We check the per-profile marker file first
// (`.last-fp-refresh`); if it's absent we fall back to
// `profile.created_at` so brand-new profiles don't immediately
// regenerate the fingerprint they were just created with.
// Profiles with neither (truly legacy) are treated as ancient
// and refresh on next launch — once.
// Check if we need to generate a new fingerprint on every launch
let mut updated_profile = profile.clone();
let stale_threshold = most_recent_rollover_epoch();
let profile_id_str = profile.id.to_string();
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
let effective_last_refresh =
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
if randomize_every_launch || is_stale_profile {
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
log::info!(
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
profile.name,
randomize_every_launch,
is_stale_profile
"Generating random fingerprint for Wayfern profile: {}",
profile.name
);
// Create a config copy without the existing fingerprint to force generation of a new one
@@ -659,24 +573,12 @@ impl BrowserRunner {
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
// Write the marker so the next launch within the same rollover
// window skips this branch. The marker is excluded from cloud
// sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each
// device's refresh schedule is independent.
let now_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(stale_threshold);
write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch);
// Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint);
// Preserve the user's randomize-on-launch preference rather than
// forcing it on. The rollover path must not silently flip this
// flag for users who only opted into the scheduled refresh.
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
// Preserve the randomize flag so it persists across launches
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
// Preserve the OS setting so it's used for future fingerprint generation
if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone();
}
@@ -754,6 +656,24 @@ impl BrowserRunner {
let process_id = wayfern_result.processId.unwrap_or(0);
log::info!("Wayfern launched successfully with PID: {process_id}");
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
// stored fingerprint targets an older browser version). Persist it so the
// next launch starts from the upgraded value — saved below via
// save_process_info(&updated_profile).
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) {
log::info!(
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
profile.name,
used_fp.len()
);
cfg.fingerprint = Some(used_fp);
updated_profile.wayfern_config = Some(cfg);
}
}
// Update profile with the process info
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
@@ -935,57 +855,19 @@ impl BrowserRunner {
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches
let upstream_proxy = self
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Start local proxy - if this fails, DO NOT launch browser
let blocklist_file = Self::resolve_blocklist_file(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let internal_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
.map_err(|e| {
let error_msg = format!("Failed to start local proxy: {e}");
log::error!("{}", error_msg);
error_msg
})?;
let internal_proxy_settings = Some(internal_proxy.clone());
let result = self
// Camoufox and Wayfern start (and PID-reconcile) their own local proxy
// inside `launch_browser_internal`, so we hand it None here rather than
// staging a second, orphaned proxy worker.
self
.launch_browser_internal(
app_handle.clone(),
app_handle,
profile,
url,
internal_proxy_settings.as_ref(),
None,
remote_debugging_port,
headless,
)
.await;
// Update proxy with correct PID if launch succeeded
if let Ok(ref updated_profile) = result {
if let Some(actual_pid) = updated_profile.process_id {
let _ = PROXY_MANAGER.update_proxy_pid(temp_pid, actual_pid);
}
}
result
.await
}
pub async fn launch_or_open_url(
@@ -2395,6 +2277,17 @@ pub async fn launch_browser_profile(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
) -> Result<BrowserProfile, String> {
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
}
pub async fn launch_browser_profile_impl(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
force_new: bool,
) -> Result<BrowserProfile, String> {
log::info!(
"Launch request received for profile: {} (ID: {})",
@@ -2424,9 +2317,6 @@ pub async fn launch_browser_profile(
let browser_runner = BrowserRunner::instance();
// Store the internal proxy settings for passing to launch_browser
let mut internal_proxy_settings: Option<ProxySettings> = None;
// Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state
let profile_for_launch = match browser_runner
.profile_manager
@@ -2448,112 +2338,36 @@ pub async fn launch_browser_profile(
profile_for_launch.id
);
// Always start a local proxy before launching (non-Camoufox/Wayfern handled here; they have their own flow)
// This ensures all traffic goes through the local proxy for monitoring and future features
if profile.browser != "camoufox" && profile.browser != "wayfern" {
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
// Refresh cloud proxy credentials and inject profile-specific sid
let mut upstream_proxy = BrowserRunner::instance()
.resolve_launch_proxy(&profile_for_launch)
.await?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile_for_launch.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
});
log::info!("VPN worker started for profile on port {}", port);
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}"));
}
}
}
}
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Always start a local proxy, even if there's no upstream proxy
// This allows for traffic monitoring and future features
let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?;
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile_for_launch.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
{
Ok(internal_proxy) => {
// Use internal proxy for subsequent launch
internal_proxy_settings = Some(internal_proxy.clone());
// For Firefox-based browsers, always apply PAC/user.js to point to the local proxy
if matches!(
profile_for_launch.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = browser_runner.profile_manager.get_profiles_dir();
let profile_path = profiles_dir
.join(profile_for_launch.id.to_string())
.join("profile");
// Provide a dummy upstream (ignored when internal proxy is provided)
let dummy_upstream = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: internal_proxy.port,
username: None,
password: None,
};
browser_runner
.profile_manager
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
log::info!(
"Local proxy prepared for profile: {} on port: {} (upstream: {})",
profile_for_launch.name,
internal_proxy.port,
upstream_proxy
.as_ref()
.map(|p| format!("{}:{}", p.host, p.port))
.unwrap_or_else(|| "DIRECT".to_string())
);
}
Err(e) => {
let error_msg = format!("Failed to start local proxy: {e}");
log::error!("{}", error_msg);
// DO NOT launch browser if proxy startup fails - all browsers must use local proxy
return Err(error_msg);
}
}
}
log::info!(
"Starting browser launch for profile: {} (ID: {})",
profile_for_launch.name,
profile_for_launch.id
);
// Launch browser or open URL in existing instance
let updated_profile = browser_runner.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()).await.map_err(|e| {
// Launch browser or open URL in existing instance. Camoufox and Wayfern
// start their own local proxies inside `launch_browser_internal`; any
// other browser type is rejected there (we only support those for import,
// not launch), so no proxy needs to be staged here.
//
// `force_new` callers (API/MCP) always start a fresh instance with the
// requested debug port and headless mode, bypassing the "open URL in the
// existing window" path which would otherwise ignore both.
let launch_result = if force_new {
browser_runner
.launch_browser_with_debugging(
app_handle.clone(),
&profile_for_launch,
url,
remote_debugging_port,
headless,
)
.await
} else {
browser_runner
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
.await
};
let updated_profile = launch_result.map_err(|e| {
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
// Emit a failure event to clear loading states in the frontend
@@ -2710,28 +2524,6 @@ pub async fn kill_browser_profile(
}
}
pub async fn launch_browser_profile_with_debugging(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
let browser_runner = BrowserRunner::instance();
browser_runner
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
.await
.map_err(|e| format!("Failed to launch browser with debugging: {e}"))
}
#[tauri::command]
pub async fn open_url_with_profile(
app_handle: tauri::AppHandle,
+47 -4
View File
@@ -200,6 +200,7 @@ impl CamoufoxManager {
}
/// Launch Camoufox browser by directly spawning the process
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox(
&self,
_app_handle: &AppHandle,
@@ -207,6 +208,7 @@ impl CamoufoxManager {
profile_path: &str,
config: &CamoufoxConfig,
url: Option<&str>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
@@ -249,7 +251,10 @@ impl CamoufoxManager {
.to_string(),
];
let cdp_port = Self::find_free_port().await?;
let cdp_port = match remote_debugging_port {
Some(p) => p,
None => Self::find_free_port().await?,
};
args.push(format!("--remote-debugging-port={cdp_port}"));
// Add URL if provided
@@ -270,13 +275,33 @@ impl CamoufoxManager {
args
);
// Spawn the browser process
// Spawn the browser process. Camoufox prints NSS/PSM and proxy failures
// to stderr (e.g. cert errors, CONNECT failures) and the user otherwise
// sees only an opaque "Secure Connection Failed" page — capture stderr
// to a per-launch file so diagnostics survive without a TTY.
let stderr_log_path = std::env::temp_dir().join(format!("camoufox-stderr-{}.log", profile.id));
let mut command = TokioCommand::new(&executable_path);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stdout(Stdio::null());
match std::fs::File::create(&stderr_log_path) {
Ok(file) => {
log::info!(
"Camoufox stderr will be logged to: {}",
stderr_log_path.display()
);
command.stderr(Stdio::from(file));
}
Err(e) => {
log::warn!(
"Failed to open Camoufox stderr log {}: {e}",
stderr_log_path.display()
);
command.stderr(Stdio::null());
}
}
// Add environment variables
for (key, value) in &env_vars {
@@ -646,6 +671,7 @@ impl CamoufoxManager {
}
impl CamoufoxManager {
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox_profile(
&self,
app_handle: AppHandle,
@@ -653,6 +679,7 @@ impl CamoufoxManager {
config: CamoufoxConfig,
url: Option<String>,
override_profile_path: Option<std::path::PathBuf>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<CamoufoxLaunchResult, String> {
// Get profile path
@@ -708,6 +735,8 @@ impl CamoufoxManager {
// re-emit so they never duplicate.
let managed_keys = [
"network.proxy.",
"network.http.http3.enable",
"network.http.http3.enabled",
"xpinstall.signatures.required",
"extensions.startupScanScopes",
"browser.sessionhistory.max_entries",
@@ -741,6 +770,19 @@ impl CamoufoxManager {
user_pref(\"extensions.startupScanScopes\", 1);\n",
);
// Disable HTTP/3 / QUIC. Camoufox always sits behind the local
// donut-proxy, and Firefox-150's QUIC stack bypasses configured HTTP
// proxies and goes direct UDP to the remote host. With an upstream
// proxy that's the only allowed egress, that traffic silently fails
// and pages won't load. (Chromium suppresses QUIC under a proxy on
// its own, so Wayfern doesn't need the equivalent toggle.) Both
// pref names are emitted because they've been renamed across FF
// versions and either could be the active one at runtime.
prefs.push_str(
"user_pref(\"network.http.http3.enable\", false);\n\
user_pref(\"network.http.http3.enabled\", false);\n",
);
if let Some(proxy_str) = &config.proxy {
if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
@@ -782,6 +824,7 @@ impl CamoufoxManager {
&profile_path_str,
&config,
url.as_deref(),
remote_debugging_port,
headless,
)
.await
+211 -24
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,
@@ -46,6 +116,36 @@ pub struct CloudUser {
pub team_name: Option<String>,
#[serde(rename = "teamRole", default)]
pub team_role: Option<String>,
// This desktop session's position among the user's active devices, oldest
// first. Ordinal 1 is the primary device — the only one that can run browser
// automation. `default` keeps older login/state payloads (which lack these
// fields) deserializing cleanly.
#[serde(rename = "deviceOrdinal", default)]
pub device_ordinal: Option<i64>,
#[serde(rename = "deviceCount", default)]
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)]
@@ -413,7 +513,18 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Login failed ({status}): {body}"));
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
// limit or a temporary security block). Surface the human-readable
// message rather than the raw JSON so the sign-in screen is clear.
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("message")
.and_then(|m| m.as_str())
.map(std::string::ToString::to_string)
})
.unwrap_or_else(|| format!("Login failed ({status})"));
return Err(message);
}
let result: DeviceCodeExchangeResponse = response
@@ -637,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,
}
}
@@ -995,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
@@ -1029,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);
@@ -1163,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!(
@@ -1198,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]
-351
View File
@@ -1,351 +0,0 @@
use directories::ProjectDirs;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
// Get log directory (use data directory instead of /tmp)
let log_dir = get_data_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("logs");
fs::create_dir_all(&log_dir)?;
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{daemon_path}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>{log_dir}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path = daemon_path.display(),
log_dir = log_dir.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn get_plist_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
if plist_path.exists() {
// First unload the launch agent if it's loaded
let _ = unload_launch_agent();
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
get_plist_path().is_some_and(|p| p.exists())
}
#[cfg(target_os = "macos")]
pub fn load_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Launch agent plist does not exist",
));
}
// Use launchctl load to start the daemon via launchd
// The -w flag writes the "disabled" key to the override plist
let output = Command::new("launchctl")
.args(["load", "-w"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// "already loaded" is not an error condition for us
if !stderr.contains("already loaded") {
return Err(io::Error::other(format!(
"launchctl load failed: {}",
stderr
)));
}
}
log::info!("Loaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Ok(());
}
let output = Command::new("launchctl")
.args(["unload"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Not being loaded is not an error
if !stderr.contains("Could not find specified service") {
log::warn!("launchctl unload warning: {}", stderr);
}
}
log::info!("Unloaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "linux")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let escaped_daemon_path = daemon_path
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod autostart;
pub mod services;
pub mod tray;
-51
View File
@@ -1,51 +0,0 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
-204
View File
@@ -1,204 +0,0 @@
use std::process::Command;
use tray_icon::menu::{Menu, MenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub fn load_icon() -> Icon {
// On Windows, use the full-color icon so it renders well on dark taskbars.
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
#[cfg(target_os = "windows")]
let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png");
#[cfg(not(target_os = "windows"))]
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&quit_item).unwrap();
Self { menu, quit_item }
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
let builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()));
// On macOS, template icons are automatically colored by the system for light/dark mode
#[cfg(target_os = "macos")]
let builder = builder.with_icon_as_template(true);
builder.build().expect("Failed to create tray icon")
}
/// Resolve the .app bundle path from the current daemon executable.
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos_dir = exe.parent()?;
let contents_dir = macos_dir.parent()?;
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
Some(app_dir.to_path_buf())
} else {
None
}
}
pub fn open_gui() {
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
// Launch the GUI binary directly. The daemon lives inside the same .app
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
// of launching the GUI. Directly running the binary avoids macOS's app
// activation machinery. The single-instance Tauri plugin in the GUI
// handles deduplication if a GUI instance is already running.
if let Some(app_bundle) = get_app_bundle_path() {
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
if gui_binary.exists() {
let _ = Command::new(&gui_binary).spawn();
} else {
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
}
} else {
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
}
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let app_path = exe_dir.join("donutbrowser.exe");
if app_path.exists() {
let _ = Command::new(app_path).spawn();
return;
}
}
}
let paths = [
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
Some(PathBuf::from(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
fn read_gui_pid() -> Option<u32> {
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
let content = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
val.get("gui_pid")?.as_u64().map(|p| p as u32)
}
fn kill_gui_by_pid() -> bool {
let Some(pid) = read_gui_pid() else {
return false;
};
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
ret == 0
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
if kill_gui_by_pid() {
log::info!("[daemon] GUI killed by PID");
return;
}
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
#[cfg(target_os = "macos")]
{
// Use spawn() instead of output() to avoid blocking the event loop.
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut\" to quit"])
.spawn();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
-152
View File
@@ -1,152 +0,0 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
pub struct DaemonClient {
app_handle: tauri::AppHandle,
connected: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
daemon_port: Arc<Mutex<Option<u16>>>,
}
impl DaemonClient {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self {
app_handle,
connected: Arc::new(AtomicBool::new(false)),
shutdown: Arc::new(AtomicBool::new(false)),
daemon_port: Arc::new(Mutex::new(None)),
}
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::SeqCst)
}
pub async fn connect(&self, port: u16) -> Result<(), String> {
*self.daemon_port.lock().await = Some(port);
let url = format!("ws://127.0.0.1:{}/ws/events", port);
log::info!("[daemon-client] Connecting to daemon at {}", url);
let (ws_stream, _) = connect_async(&url)
.await
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
self.connected.store(true, Ordering::SeqCst);
log::info!("[daemon-client] Connected to daemon");
let (mut write, mut read) = ws_stream.split();
let app_handle = self.app_handle.clone();
let connected = self.connected.clone();
let shutdown = self.shutdown.clone();
// Spawn task to handle incoming messages
tokio::spawn(async move {
while !shutdown.load(Ordering::SeqCst) {
match read.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"event" => {
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
// Forward event to Tauri frontend
if let Err(e) = app_handle.emit(&event, payload) {
log::error!("[daemon-client] Failed to emit event: {}", e);
}
}
}
"connected" => {
log::info!("[daemon-client] Received connection confirmation");
}
"pong" => {
log::debug!("[daemon-client] Received pong");
}
_ => {
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Some(Ok(Message::Ping(data))) => {
log::debug!("[daemon-client] Received ping");
if let Err(e) = write.send(Message::Pong(data)).await {
log::error!("[daemon-client] Failed to send pong: {}", e);
break;
}
}
Some(Ok(Message::Close(_))) => {
log::info!("[daemon-client] Daemon closed connection");
break;
}
Some(Err(e)) => {
log::error!("[daemon-client] WebSocket error: {}", e);
break;
}
None => {
log::info!("[daemon-client] WebSocket stream ended");
break;
}
_ => {}
}
}
connected.store(false, Ordering::SeqCst);
log::info!("[daemon-client] Disconnected from daemon");
});
Ok(())
}
pub fn disconnect(&self) {
self.shutdown.store(true, Ordering::SeqCst);
self.connected.store(false, Ordering::SeqCst);
}
}
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
let client = DaemonClient::new(app_handle);
if let Err(e) = client.connect(port).await {
log::error!("[daemon-client] Failed to connect: {}", e);
}
client
}
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
// Try default port first
let default_port = 10108;
log::info!(
"[daemon-client] Looking for daemon on port {}",
default_port
);
let client = DaemonClient::new(app_handle);
match client.connect(default_port).await {
Ok(()) => Some(client),
Err(e) => {
log::warn!(
"[daemon-client] Could not connect to daemon on default port: {}",
e
);
None
}
}
}
-360
View File
@@ -1,360 +0,0 @@
// Daemon Spawn - Start the daemon from the GUI
// Currently disabled; will be re-enabled in the future
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
{
false
}
} else {
false
}
}
#[cfg(target_os = "macos")]
fn is_dev_mode() -> bool {
if let Ok(current_exe) = std::env::current_exe() {
let path_str = current_exe.to_string_lossy();
path_str.contains("target/debug") || path_str.contains("target/release")
} else {
false
}
}
#[cfg(target_os = "macos")]
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let daemon_path = exe_dir.join("donut-daemon");
if daemon_path.exists() {
return Some(daemon_path);
}
}
}
// Try common installation paths
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
.unwrap_or_default(),
];
paths.into_iter().find(|path| path.exists())
}
#[cfg(any(target_os = "linux", windows))]
fn get_daemon_path() -> Option<PathBuf> {
// First, try to find it next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
let exe_dir = current_exe.parent()?;
// Check for daemon binary in same directory
#[cfg(target_os = "windows")]
let daemon_name = "donut-daemon.exe";
#[cfg(target_os = "linux")]
let daemon_name = "donut-daemon";
let daemon_path = exe_dir.join(daemon_name);
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
return Some(PathBuf::from(path));
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
None
}
pub fn spawn_daemon() -> Result<(), String> {
// Log the daemon state for debugging
let state = read_state();
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
// Check if already running
if is_daemon_running() {
log::info!("Daemon is already running (verified by PID check)");
return Ok(());
}
log::info!("Daemon is not running, attempting to start...");
// Log current exe location for debugging
let current_exe = std::env::current_exe().ok();
log::info!("Current exe: {:?}", current_exe);
// On macOS, use launchctl to start the daemon via launchd
// This ensures the daemon runs in the user's Aqua session with WindowServer access
// and survives app termination since it's managed by launchd, not as a child process
#[cfg(target_os = "macos")]
{
spawn_daemon_macos()?;
}
// On Linux, use direct spawn
#[cfg(target_os = "linux")]
{
spawn_daemon_unix()?;
}
#[cfg(windows)]
{
spawn_daemon_windows()?;
}
// Wait for daemon to start (max 3 seconds)
for i in 0..30 {
thread::sleep(Duration::from_millis(100));
if is_daemon_running() {
log::info!("Daemon started successfully after {}ms", (i + 1) * 100);
return Ok(());
}
}
// Check if we got a state file at least
let state = read_state();
if let Some(pid) = state.daemon_pid {
log::info!("Daemon appears to have started (PID {} in state file)", pid);
return Ok(());
}
Err("Daemon did not start within timeout".to_string())
}
#[cfg(target_os = "macos")]
fn spawn_daemon_macos() -> Result<(), String> {
use std::os::unix::process::CommandExt;
// In dev mode, use direct spawn instead of launchctl
// This avoids issues with plist paths pointing to wrong binaries
if is_dev_mode() {
log::info!("Dev mode detected, using direct spawn instead of launchctl");
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
return Ok(());
}
// Production mode: use launchctl for proper daemon management
// First, ensure the LaunchAgent plist is installed
let autostart_enabled = autostart::is_autostart_enabled();
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
if !autostart_enabled {
log::info!("Installing LaunchAgent plist for daemon management");
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
log::info!("LaunchAgent plist installed successfully");
}
// Load the launch agent via launchctl
log::info!("Loading daemon via launchctl...");
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn spawn_daemon_unix() -> Result<(), String> {
use std::os::unix::process::CommandExt;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
#[cfg(windows)]
fn spawn_daemon_windows() -> Result<(), String> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
Command::new(&daemon_path)
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
pub fn ensure_daemon_running() -> Result<(), String> {
if !is_daemon_running() {
spawn_daemon()?;
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
-134
View File
@@ -1,134 +0,0 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::events::{DaemonEmitter, DaemonEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct WsState {
event_emitter: Option<Arc<DaemonEmitter>>,
}
impl WsState {
pub fn new() -> Self {
Self {
event_emitter: None,
}
}
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
Self {
event_emitter: Some(emitter),
}
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WsState) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to daemon events if emitter is available
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
log::info!("[ws] Client connected");
// Send initial ping to confirm connection
let ping_msg = WsMessage {
msg_type: "connected".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
loop {
tokio::select! {
// Handle incoming messages from client
Some(msg) = receiver.next() => {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"ping" => {
let pong = WsMessage {
msg_type: "pong".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&pong) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
}
_ => {
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Ok(Message::Ping(data)) => {
let _ = sender.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
log::info!("[ws] Client disconnected");
break;
}
Err(e) => {
log::error!("[ws] Error receiving message: {}", e);
break;
}
_ => {}
}
}
// Forward daemon events to client
Some(daemon_event) = async {
if let Some(ref mut rx) = event_rx {
rx.recv().await.ok()
} else {
std::future::pending::<Option<DaemonEvent>>().await
}
} => {
let ws_msg = WsMessage {
msg_type: "event".to_string(),
event: Some(daemon_event.event_type),
payload: Some(daemon_event.payload),
};
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
if sender.send(Message::Text(msg_str.into())).await.is_err() {
log::error!("[ws] Failed to send event to client");
break;
}
}
}
else => break,
}
}
log::info!("[ws] WebSocket connection closed");
}
+64 -12
View File
@@ -1296,21 +1296,73 @@ pub async fn ensure_active_browsers_downloaded(
};
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
match crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
)
.await
{
Ok(_) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
// Retry transient failures a few times. Each attempt is wrapped in an overall
// timeout so that a hang anywhere in the download pipeline (version resolution,
// a stalled stream, extraction) cannot block the next browser forever. This is
// the core of the bug fix: Wayfern going first must never starve Camoufox.
const MAX_ATTEMPTS: u32 = 3;
const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
let mut succeeded = false;
for attempt in 1..=MAX_ATTEMPTS {
let result = tokio::time::timeout(
ATTEMPT_TIMEOUT,
crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
),
)
.await;
match result {
Ok(Ok(_)) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
succeeded = true;
break;
}
Ok(Err(e)) => {
log::warn!(
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
);
}
Err(_) => {
// The download future itself hung past the overall timeout and was dropped,
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
// (the future may have re-resolved to a different version, so clear by
// browser prefix) and emit a terminal error event so the UI stops spinning.
log::warn!(
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
ATTEMPT_TIMEOUT.as_secs()
);
crate::downloader::clear_download_state_for_browser(browser);
let progress = crate::downloader::DownloadProgress {
browser: (*browser).to_string(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = crate::events::emit("download-progress", &progress);
}
}
Err(e) => {
log::warn!("Failed to auto-download {browser} {version}: {e}");
if attempt < MAX_ATTEMPTS {
// Short backoff before retrying a transient failure.
let backoff = std::time::Duration::from_secs(2u64.pow(attempt - 1));
tokio::time::sleep(backoff).await;
}
}
if !succeeded {
// Do NOT abort the whole routine: continue so the next browser (Camoufox)
// still gets its chance even though this one failed/timed out.
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
}
}
Ok(downloaded)
+125 -15
View File
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
// Maximum time to wait for the next chunk of a streaming download before treating
// the connection as stalled. Converts an indefinite hang into a terminal error so
// the UI can surface it and the caller can move on / retry.
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
@@ -44,6 +49,11 @@ impl Downloader {
Self {
client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
// Per-read idle timeout: if the connection stalls mid-stream with no bytes
// for this long, the read fails instead of hanging forever. This is the
// transport-level guard; the streaming loop also wraps each read in an
// explicit tokio timeout as defense-in-depth.
.read_timeout(STREAM_IDLE_TIMEOUT)
.build()
.unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(),
@@ -470,7 +480,26 @@ impl Downloader {
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
loop {
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
// surfaces as a terminal error instead of awaiting forever.
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Ok(item) => item,
Err(_) => {
drop(file);
// Keep any partial bytes on disk so a later attempt can resume via Range.
return Err(
format!(
"Download stalled: no data received for {}s",
STREAM_IDLE_TIMEOUT.as_secs()
)
.into(),
);
}
};
let Some(chunk) = next else {
break;
};
if let Some(token) = cancel_token {
if token.is_cancelled() {
drop(file);
@@ -694,20 +723,25 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
// Emit cancelled stage if the download was cancelled by user
if cancel_token.is_cancelled() {
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "cancelled".to_string(),
};
let _ = events::emit("download-progress", &progress);
}
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
// "cancelled"; any other failure (network error, stall timeout, bad status)
// maps to "error" so the frontend can show a concrete error toast.
let stage = if cancel_token.is_cancelled() {
"cancelled"
} else {
"error"
};
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: stage.to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to download browser: {e}").into());
}
@@ -844,6 +878,20 @@ impl Downloader {
// Do not delete files on verification failure; keep archive for manual retry.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Emit a terminal error stage so the UI shows an error instead of spinning.
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
downloading.contains(&download_key)
}
/// Clear all in-progress download bookkeeping for a browser.
///
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
/// by an outer timeout) before its own error path could run. Because
/// `download_browser_full` may re-resolve to a different version than requested, this
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
/// key is left behind regardless of which version was actually in flight.
pub fn clear_download_state_for_browser(browser: &str) {
let prefix = format!("{browser}-");
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.retain(|key| !key.starts_with(&prefix));
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.retain(|key, _| !key.starts_with(&prefix));
}
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -1110,6 +1177,49 @@ mod tests {
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content.len(), test_content.len());
}
#[test]
fn test_clear_download_state_for_browser_removes_stuck_keys() {
// Simulate a download future that was abandoned without running its own cleanup,
// leaving stuck bookkeeping for a version that differs from the requested one.
let key = "wayfern-1.2.3-resolved".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(key.clone());
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.insert(key.clone(), CancellationToken::new());
}
// A different browser's in-progress state must be left untouched.
let other = "camoufox-9.9.9".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(other.clone());
}
clear_download_state_for_browser("wayfern");
assert!(
!is_downloading("wayfern", "1.2.3-resolved"),
"stuck wayfern key should be cleared even when version differs from request"
);
{
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
assert!(
!tokens.contains_key(&key),
"stuck wayfern cancellation token should be cleared"
);
}
assert!(
is_downloading("camoufox", "9.9.9"),
"unrelated browser's download state must be preserved"
);
// Cleanup so we don't leak global state into other tests.
clear_download_state_for_browser("camoufox");
}
}
// Global singleton instance
+1
View File
@@ -281,6 +281,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
+2 -73
View File
@@ -1,10 +1,7 @@
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::broadcast;
/// Trait for emitting events to the frontend or connected clients.
/// This abstraction allows the same code to work in both GUI (Tauri) mode
/// and daemon mode (WebSocket broadcast).
/// Trait for emitting events to the frontend.
///
/// Note: This trait uses `serde_json::Value` to be dyn-compatible.
/// Use the convenience functions `emit()` and `emit_empty()` which accept
@@ -37,49 +34,6 @@ impl EventEmitter for TauriEmitter {
}
}
/// Event message sent through the daemon's broadcast channel.
#[derive(Clone, Debug)]
pub struct DaemonEvent {
pub event_type: String,
pub payload: serde_json::Value,
}
/// Daemon-based event emitter for background daemon mode.
/// Broadcasts events to all connected WebSocket clients.
#[derive(Clone)]
pub struct DaemonEmitter {
tx: broadcast::Sender<DaemonEvent>,
}
impl DaemonEmitter {
pub fn new(tx: broadcast::Sender<DaemonEvent>) -> Self {
Self { tx }
}
/// Create a new DaemonEmitter with a default channel capacity.
pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver<DaemonEvent>) {
let (tx, rx) = broadcast::channel(capacity);
(Self { tx }, rx)
}
/// Subscribe to events from this emitter.
pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
self.tx.subscribe()
}
}
impl EventEmitter for DaemonEmitter {
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
let daemon_event = DaemonEvent {
event_type: event.to_string(),
payload,
};
// Ignore send errors (no receivers connected)
let _ = self.tx.send(daemon_event);
Ok(())
}
}
/// No-op emitter for testing or when events are not needed.
#[derive(Clone, Default)]
pub struct NoopEmitter;
@@ -91,8 +45,7 @@ impl EventEmitter for NoopEmitter {
}
/// Global event emitter that can be set at runtime.
/// This allows managers to emit events without knowing whether they're
/// running in GUI or daemon mode.
/// This allows managers to emit events without holding an AppHandle directly.
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new();
/// Set the global event emitter. This should be called once during app startup.
@@ -136,30 +89,6 @@ mod tests {
.is_ok());
}
#[test]
fn test_daemon_emitter() {
let (emitter, mut rx) = DaemonEmitter::with_capacity(16);
// Emit an event
let _ = emitter.emit_value("test-event", serde_json::json!("hello"));
// Check we received it
let event = rx.try_recv().unwrap();
assert_eq!(event.event_type, "test-event");
assert_eq!(event.payload, serde_json::json!("hello"));
}
#[test]
fn test_daemon_emitter_no_receivers() {
let (tx, _) = broadcast::channel::<DaemonEvent>(16);
let emitter = DaemonEmitter::new(tx);
// Should not error even with no receivers
assert!(emitter
.emit_value("test-event", serde_json::json!("hello"))
.is_ok());
}
#[test]
fn test_emit_convenience_function() {
// Test that emit() works with various types
+8
View File
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -90,6 +94,7 @@ impl GroupManager {
name,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
groups_data.groups.push(group.clone());
@@ -136,6 +141,7 @@ impl GroupManager {
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
group.name = name;
group.updated_at = Some(crate::proxy_manager::now_secs());
let updated_group = group.clone();
self.save_groups_data(&groups_data)?;
@@ -167,6 +173,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
self.save_groups_data(&groups_data)?;
}
@@ -183,6 +190,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
} else {
groups_data.groups.push(group.clone());
}
+225 -26
View File
@@ -1,13 +1,19 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
// Store pending URLs that need to be handled when the window is ready
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// Set to true once the user has confirmed they want to quit, so the close
// interceptor lets the next CloseRequested through instead of looping back
// to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
mod api_client;
mod api_server;
mod app_auto_updater;
@@ -46,11 +52,6 @@ mod wayfern_terms;
pub mod cloud_auth;
mod commercial_license;
mod cookie_manager;
pub mod daemon;
pub mod daemon_client;
#[allow(dead_code)]
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
@@ -92,10 +93,10 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
save_sync_settings, save_table_sorting_settings,
};
use sync::{
@@ -190,7 +191,8 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
}
}
#[tauri::command]
// Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`.
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}");
@@ -927,15 +929,21 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
#[tauri::command]
async fn check_vpn_validity(
vpn_id: String,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
check_vpn_validity_core(&vpn_id).await
}
pub async fn check_vpn_validity_core(
vpn_id: &str,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id).is_some();
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
let vpn_worker = vpn_worker_runner::start_vpn_worker(vpn_id)
.await
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
@@ -1012,6 +1020,53 @@ async fn check_vpn_validity(
Ok(result)
}
/// Validate that a profile's selected proxy or VPN actually works before the
/// profile is created. Shared by the Tauri command, REST API, and MCP create
/// paths so a dead/unreachable proxy or VPN (or a 402 from an expired proxy
/// subscription) fails creation identically everywhere. Returns structured
/// `{ "code": ... }` error strings the frontend translates via backend-errors.ts.
pub async fn validate_profile_network(
proxy_id: Option<&str>,
vpn_id: Option<&str>,
) -> Result<(), String> {
if let Some(vpn_id) = vpn_id.filter(|s| !s.is_empty()) {
let result = check_vpn_validity_core(vpn_id).await?;
if !result.is_valid {
return Err(serde_json::json!({ "code": "VPN_NOT_WORKING" }).to_string());
}
return Ok(());
}
if let Some(proxy_id) = proxy_id.filter(|s| !s.is_empty()) {
// The cloud-included proxy is managed infrastructure; its only failure mode
// is the user hitting their usage limit, which surfaces as a 402 at request
// time. There's nothing to pre-validate here.
if proxy_id == crate::proxy_manager::CLOUD_PROXY_ID {
return Ok(());
}
let settings = crate::proxy_manager::PROXY_MANAGER
.get_proxy_settings_by_id(proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
match crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity(proxy_id, &settings)
.await
{
Ok(result) if result.is_valid => {}
Ok(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
Err(err) if err.contains("402") => {
return Err(serde_json::json!({ "code": "PROXY_PAYMENT_REQUIRED" }).to_string());
}
Err(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
}
}
Ok(())
}
#[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Start VPN worker process (detached, survives GUI shutdown)
@@ -1120,6 +1175,7 @@ async fn generate_sample_fingerprint(
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
if browser == "camoufox" {
@@ -1145,6 +1201,120 @@ async fn generate_sample_fingerprint(
}
}
/// Confirm a quit chosen from the close-confirmation dialog and exit the app.
#[tauri::command]
fn confirm_quit(app_handle: tauri::AppHandle) {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
app_handle.exit(0);
}
/// Hide the main window so the app keeps running behind its tray icon.
#[tauri::command]
fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app_handle.get_webview_window("main") {
window.hide().map_err(|e| e.to_string())?;
}
Ok(())
}
fn show_main_window(app_handle: &tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
/// Update the tray menu labels with localized strings pushed from the frontend
/// (which owns the active language). The item ids are unchanged so the existing
/// menu-event handler keeps matching.
#[tauri::command]
fn update_tray_menu(
app_handle: tauri::AppHandle,
show_label: String,
quit_label: String,
) -> Result<(), String> {
use tauri::menu::{MenuBuilder, MenuItemBuilder};
if let Some(tray) = app_handle.tray_by_id("main") {
let show_item = MenuItemBuilder::with_id("tray_show", show_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let quit_item = MenuItemBuilder::with_id("tray_quit", quit_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let menu = MenuBuilder::new(&app_handle)
.item(&show_item)
.separator()
.item(&quit_item)
.build()
.map_err(|e| e.to_string())?;
tray.set_menu(Some(menu)).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Build the system tray. Best-effort: on Linux the tray depends on
/// libayatana-appindicator at runtime, so any failure here must not abort app
/// startup — the caller logs and continues without a tray.
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
// Bootstrap labels only — the frontend pushes localized labels via
// `update_tray_menu` on mount and on language change, and the menu is only
// opened after a minimize-to-tray (post-mount), so these are never shown.
let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser").build(app)?;
let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit").build(app)?;
let tray_menu = MenuBuilder::new(app)
.item(&show_item)
.separator()
.item(&quit_item)
.build()?;
// macOS uses a black template icon (the OS tints it for light/dark menu
// bars). Windows and Linux use the full-color icon, because neither tints a
// template — a black template would be invisible on dark Linux panels.
#[cfg(target_os = "macos")]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
#[cfg(not(target_os = "macos"))]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8();
let (tray_w, tray_h) = tray_rgba.dimensions();
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
TrayIconBuilder::with_id("main")
.icon(tray_image)
.icon_as_template(cfg!(target_os = "macos"))
.tooltip("Donut Browser")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(|app_handle, event| match event.id().as_ref() {
"tray_show" => show_main_window(app_handle),
"tray_quit" => {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
app_handle.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Click events are not delivered on Linux (AppIndicator/SNI only drives
// the menu), so left-click-to-restore is macOS/Windows only — Linux users
// restore via the "Show Donut Browser" menu item.
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -1158,15 +1328,25 @@ pub fn run() {
let log_file_name = app_dirs::app_name();
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
// the platform default app log dir, so all on-disk state lives under one root.
let file_log_target = match app_dirs::log_dir_override() {
Some(path) => Target::new(TargetKind::Folder {
path,
file_name: Some(log_file_name.to_string()),
}),
None => Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}),
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
@@ -1218,14 +1398,6 @@ pub fn run() {
mgr.ensure_icons_extracted();
}
// Daemon (tray icon) is currently disabled — clean up any existing autostart
if daemon::autostart::is_autostart_enabled() {
log::info!("Removing daemon autostart (daemon is disabled)");
if let Err(e) = daemon::autostart::disable_autostart() {
log::warn!("Failed to remove daemon autostart: {e}");
}
}
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
@@ -1243,6 +1415,32 @@ pub fn run() {
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
// System tray so the user can keep the app running after the close
// dialog's "Minimize" action hides the window. Best-effort: a tray
// failure (e.g. missing libayatana-appindicator on Linux) must never
// prevent the app from launching, so we log and continue without it.
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
}
// Intercept the window close so the frontend can ask the user whether
// to minimize or quit. The app exits when `confirm_quit` flips
// QUIT_CONFIRMED — until then, every CloseRequested is held back.
{
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if QUIT_CONFIRMED.load(Ordering::SeqCst) {
return;
}
api.prevent_close();
if let Err(e) = app_handle.emit("close-confirm-requested", ()) {
log::warn!("Failed to emit close-confirm-requested: {e}");
}
}
});
}
// Set transparent titlebar for macOS
#[cfg(target_os = "macos")]
{
@@ -1954,6 +2152,9 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
confirm_quit,
hide_to_tray,
update_tray_menu,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
@@ -1984,15 +2185,14 @@ pub fn run() {
save_app_settings,
read_log_files,
open_log_directory,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
get_table_sorting_settings,
save_table_sorting_settings,
get_system_language,
get_system_info,
dismiss_window_resize_warning,
get_window_resize_warning_dismissed,
get_onboarding_completed,
complete_onboarding,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
@@ -2104,7 +2304,6 @@ pub fn run() {
disconnect_vpn,
get_vpn_status,
list_active_vpn_connections,
handle_url_open,
// Cloud auth commands
cloud_auth::cloud_exchange_device_code,
cloud_auth::cloud_get_user,
+153 -42
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,9 +1704,18 @@ 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
// 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.handle_update_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => {
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" => {
self
.handle_update_profile_proxy_bypass_rules(arguments)
@@ -1700,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,
@@ -1708,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 {
@@ -1823,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())
@@ -1832,7 +1925,7 @@ impl McpServer {
})?;
let url = arguments.get("url").and_then(|v| v.as_str());
let _headless = arguments
let headless = arguments
.get("headless")
.and_then(|v| v.as_bool())
.unwrap_or(false);
@@ -1876,19 +1969,21 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(),
})?;
// Launch the browser
crate::browser_runner::BrowserRunner::instance()
.launch_browser(
app_handle.clone(),
profile,
url.map(|s| s.to_string()),
None,
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to launch browser: {e}"),
})?;
// Launch a fresh instance, honoring the requested headless mode. The CDP
// port is self-allocated and discovered later via get_cdp_port_for_profile.
crate::browser_runner::launch_browser_profile_impl(
app_handle.clone(),
profile.clone(),
url.map(|s| s.to_string()),
None,
headless,
true,
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to launch browser: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
@@ -1902,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())
@@ -2578,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())
@@ -3229,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());
}
}
+77 -28
View File
@@ -200,6 +200,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -303,6 +304,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -365,6 +367,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Save profile info
@@ -377,9 +380,18 @@ impl ProfileManager {
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
// Create user.js with common Firefox preferences and apply proxy settings if provided
// Skip for ephemeral profiles since the data dir is created at launch time
if !ephemeral {
// `apply_proxy_settings_to_profile` writes a Firefox-style user.js
// with the upstream proxy host. That is wrong for both supported
// browser types:
// - Camoufox: camoufox_manager rewrites user.js at every launch with
// the local donut-proxy host; writing the upstream here leaves a
// stale, wrong proxy in user.js until the next launch.
// - Wayfern: Chromium gets its proxy via `--proxy-pac-url=` at launch
// (see wayfern_manager.rs) and never reads user.js.
// So we only call it for any unrecognized browser type that might be
// a true Firefox-family target (none currently). Ephemeral profiles
// skip regardless because their data dir is created at launch time.
if !ephemeral && !matches!(browser, "camoufox" | "wayfern") {
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
@@ -501,6 +513,7 @@ impl ProfileManager {
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile with new name
self.save_profile(&profile)?;
@@ -710,6 +723,7 @@ impl ProfileManager {
}
profile.group_id = group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -764,6 +778,7 @@ impl ProfileManager {
}
}
profile.tags = deduped;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -800,6 +815,7 @@ impl ProfileManager {
// Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -829,6 +845,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -860,6 +877,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.proxy_bypass_rules = rules;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -886,6 +904,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.dns_blocklist = dns_blocklist;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -1016,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,
@@ -1049,8 +1068,24 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
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") {
@@ -1216,6 +1251,7 @@ impl ProfileManager {
// Update proxy settings and clear VPN (mutual exclusion)
profile.proxy_id = proxy_id.clone();
profile.vpn_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save the updated profile
self
@@ -1236,18 +1272,34 @@ impl ProfileManager {
}
}
// Update on-disk browser profile config immediately
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
// Update on-disk browser profile config immediately.
// Both supported browser types ignore this write (Camoufox rewrites
// user.js at launch with the local donut-proxy host, Wayfern takes its
// proxy via `--proxy-pac-url=` and never reads user.js), and for
// Camoufox specifically writing the upstream host here would leave a
// stale, wrong proxy in user.js until the next launch.
if !matches!(profile.browser.as_str(), "camoufox" | "wayfern") {
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
} else {
// Proxy ID provided but proxy not found, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
} else {
// Proxy ID provided but proxy not found, disable proxy
// No proxy ID provided, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
@@ -1256,15 +1308,6 @@ impl ProfileManager {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
} else {
// No proxy ID provided, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
@@ -1308,6 +1351,7 @@ impl ProfileManager {
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id.clone();
profile.proxy_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self
.save_profile(&profile)
@@ -1352,6 +1396,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -2439,6 +2484,10 @@ pub async fn create_browser_profile_new(
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
// subscription) cancels creation with a translatable error.
crate::validate_profile_network(proxy_id.as_deref(), vpn_id.as_deref()).await?;
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group(
@@ -2467,10 +2516,10 @@ 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("Fingerprint editing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
@@ -2495,10 +2544,10 @@ 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("Fingerprint editing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
+6
View File
@@ -78,6 +78,12 @@ pub struct BrowserProfile {
/// any staleness check.
#[serde(default)]
pub created_at: Option<u64>,
/// Unix seconds of the last meaningful metadata edit (name, tags, note,
/// proxy/vpn/group/extension assignment, launch hook, bypass rules, dns).
/// Source of truth for metadata sync conflict resolution (last-write-wins);
/// NOT bumped by browser-file changes, which sync via the file manifest.
#[serde(default)]
pub updated_at: Option<u64>,
}
pub fn default_release_type() -> String {
+20 -414
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,87 +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,
};
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();
@@ -668,6 +329,7 @@ impl ProfileImporter {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -726,6 +388,7 @@ impl ProfileImporter {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.profile_manager.save_profile(&profile)?;
@@ -803,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())
@@ -894,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();
@@ -930,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");
+53 -11
View File
@@ -103,6 +103,11 @@ pub struct StoredProxy {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins) — bumped on config edits only, never
/// by sync bookkeeping. `None` on legacy files is treated as 0.
#[serde(default)]
pub updated_at: Option<u64>,
#[serde(default)]
pub is_cloud_managed: bool,
#[serde(default)]
@@ -124,6 +129,14 @@ pub struct StoredProxy {
pub dynamic_proxy_format: Option<String>,
}
/// Current unix time in whole seconds. Used to stamp `updated_at` on edits.
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured();
@@ -133,6 +146,7 @@ impl StoredProxy {
proxy_settings,
sync_enabled,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: None,
@@ -159,10 +173,12 @@ impl StoredProxy {
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
self.proxy_settings = proxy_settings;
self.updated_at = Some(now_secs());
}
pub fn update_name(&mut self, name: String) {
self.name = name;
self.updated_at = Some(now_secs());
}
}
@@ -455,6 +471,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: true,
is_cloud_derived: false,
geo_country: None,
@@ -646,6 +663,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: true,
geo_country: Some(country),
@@ -710,6 +728,7 @@ impl ProxyManager {
&proxy.geo_isp,
);
proxy.updated_at = Some(now_secs());
proxy.proxy_settings.username = Some(geo_username);
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
@@ -755,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
@@ -1711,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
@@ -1776,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
@@ -3154,6 +3195,7 @@ mod tests {
},
sync_enabled: false,
last_sync: None,
updated_at: None,
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: Some("US".to_string()),
-1
View File
@@ -28,7 +28,6 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
{
match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(),
"donut-daemon" => "donut-daemon.exe".to_string(),
_ => String::new(),
}
}
+74 -46
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
@@ -1147,14 +1131,17 @@ pub async fn handle_proxy_connection(
}
}
let _ = handle_connect_from_buffer(
if let Err(e) = handle_connect_from_buffer(
stream,
full_request,
upstream_url,
bypass_matcher,
blocklist_matcher,
)
.await;
.await
{
log::warn!("CONNECT tunnel ended with error: {e}");
}
return;
}
@@ -1449,6 +1436,13 @@ async fn handle_connect_from_buffer(
tracker.record_request(&domain, 0, 0);
}
log::info!(
"CONNECT {}:{} (upstream={})",
target_host,
target_port,
upstream_url.as_deref().unwrap_or("DIRECT")
);
// Connect to target (directly or via upstream proxy).
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
// Shadowsocks) share the same bidirectional-copy tunnel code below.
@@ -1503,12 +1497,46 @@ async fn handle_connect_from_buffer(
let mut buffer = [0u8; 4096];
let n = proxy_stream.read(&mut buffer).await?;
let response = String::from_utf8_lossy(&buffer[..n]);
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
let status_line = response_full.lines().next().unwrap_or("").to_string();
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
if !response_full.starts_with("HTTP/1.1 200")
&& !response_full.starts_with("HTTP/1.0 200")
{
log::warn!(
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
return Err(format!("Upstream proxy CONNECT failed: {response_full}").into());
}
// Detect the buffer-drop race where the upstream returned the
// 200 response coalesced with destination bytes — those bytes
// would otherwise be silently discarded and the browser would
// see a TLS stream missing its first record.
let header_end_in_buffer = response_full.find("\r\n\r\n").map(|i| i + 4);
if let Some(end) = header_end_in_buffer {
if end < n {
log::warn!(
"Upstream CONNECT response coalesced {} byte(s) of payload — these would be dropped without forwarding",
n - end
);
}
}
log::info!(
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
Box::new(proxy_stream)
}
"socks4" | "socks5" => {
+25 -61
View File
@@ -50,12 +50,12 @@ pub struct AppSettings {
#[serde(default)]
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
#[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
#[serde(default)]
pub disable_auto_updates: bool,
/// When true, the decrypted in-RAM copy of a password-protected profile is
/// preserved between launches for faster subsequent startups. The on-disk
@@ -93,9 +93,9 @@ impl Default for AppSettings {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
}
@@ -183,17 +183,6 @@ impl SettingsManager {
Ok(())
}
pub fn should_show_launch_on_login_prompt(&self) -> Result<bool, Box<dyn std::error::Error>> {
// Daemon is currently disabled, never show this prompt
Ok(false)
}
pub fn decline_launch_on_login(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.launch_on_login_declined = true;
self.save_settings(&settings)
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
@@ -795,7 +784,6 @@ pub async fn save_app_settings(
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.launch_on_login_declined = current.launch_on_login_declined;
}
}
@@ -919,28 +907,6 @@ pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), Stri
Ok(())
}
#[tauri::command]
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
let manager = SettingsManager::instance();
manager
.should_show_launch_on_login_prompt()
.map_err(|e| format!("Failed to check launch on login prompt setting: {e}"))
}
#[tauri::command]
pub async fn enable_launch_on_login() -> Result<(), String> {
crate::daemon::autostart::enable_autostart()
.map_err(|e| format!("Failed to enable autostart: {e}"))
}
#[tauri::command]
pub async fn decline_launch_on_login() -> Result<(), String> {
let manager = SettingsManager::instance();
manager
.decline_launch_on_login()
.map_err(|e| format!("Failed to decline launch on login: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::instance();
@@ -1047,6 +1013,27 @@ pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
Ok(settings.window_resize_warning_dismissed)
}
#[tauri::command]
pub async fn get_onboarding_completed() -> Result<bool, String> {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
Ok(settings.onboarding_completed)
}
#[tauri::command]
pub async fn complete_onboarding() -> Result<(), String> {
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
settings.onboarding_completed = true;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
@@ -1182,9 +1169,9 @@ mod tests {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
};
@@ -1247,29 +1234,6 @@ mod tests {
);
}
#[test]
fn test_should_show_launch_on_login_prompt() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.should_show_launch_on_login_prompt();
assert!(result.is_ok(), "Should not fail");
let _should_show = result.unwrap();
}
#[test]
fn test_decline_launch_on_login() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings = manager.load_settings().unwrap();
assert!(!settings.launch_on_login_declined);
manager.decline_launch_on_login().unwrap();
let settings = manager.load_settings().unwrap();
assert!(settings.launch_on_login_declined);
}
#[test]
fn test_load_corrupted_settings_file() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
+37
View File
@@ -49,6 +49,21 @@ impl SyncClient {
&self,
key: &str,
content_type: Option<&str>,
) -> SyncResult<PresignUploadResponse> {
self
.presign_upload_with_metadata(key, content_type, None)
.await
}
/// Presign an upload, asking the server to sign `metadata` into the object as
/// `x-amz-meta-*`. The response echoes the metadata the server actually signed
/// (empty/None on older servers); the caller must send exactly that back on
/// the PUT via `upload_bytes_with_metadata`.
pub async fn presign_upload_with_metadata(
&self,
key: &str,
content_type: Option<&str>,
metadata: Option<std::collections::HashMap<String, String>>,
) -> SyncResult<PresignUploadResponse> {
let response = self
.client
@@ -58,6 +73,7 @@ impl SyncClient {
key: key.to_string(),
content_type: content_type.map(|s| s.to_string()),
expires_in: Some(3600),
metadata,
})
.send()
.await
@@ -186,6 +202,21 @@ impl SyncClient {
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
) -> SyncResult<()> {
self
.upload_bytes_with_metadata(presigned_url, data, content_type, None)
.await
}
/// PUT to a presigned URL, sending `metadata` as `x-amz-meta-*` headers. These
/// MUST be exactly the metadata the presign signed (from
/// `PresignUploadResponse::metadata`) or S3 rejects the request.
pub async fn upload_bytes_with_metadata(
&self,
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
metadata: Option<&std::collections::HashMap<String, String>>,
) -> SyncResult<()> {
let mut req = self
.client
@@ -197,6 +228,12 @@ impl SyncClient {
req = req.header("Content-Type", ct);
}
if let Some(meta) = metadata {
for (k, v) in meta {
req = req.header(format!("x-amz-meta-{k}"), v);
}
}
let response = req
.send()
.await
+107 -102
View File
@@ -15,6 +15,11 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore};
/// S3 object-metadata key (stored as `x-amz-meta-updated-at`) holding an
/// entity's user-edit timestamp in unix seconds. Used to resolve sync conflicts
/// (last-write-wins) from a HEAD request without downloading the object body.
const UPDATED_AT_META_KEY: &str = "updated-at";
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
@@ -289,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();
@@ -358,6 +366,67 @@ impl SyncEngine {
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
}
/// Resolve a remote config object's user-edit timestamp (`updated_at`) for
/// conflict resolution. Prefers the value from S3 object metadata returned by
/// the HEAD (`stat`) — no body transfer. Falls back to downloading and
/// decrypting the small JSON body and reading its embedded `updated_at` (for
/// older self-hosted servers that don't surface metadata). Legacy objects with
/// neither resolve to 0, so any real local edit (`updated_at` > 0) wins.
async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
if let Some(meta) = &stat.metadata {
if let Some(v) = meta
.get(UPDATED_AT_META_KEY)
.and_then(|s| s.parse::<u64>().ok())
{
return v;
}
}
// Fallback: read updated_at from the (small) JSON body.
if let Ok(presign) = self.client.presign_download(remote_key).await {
if let Ok(raw) = self.client.download_bytes(&presign.url).await {
if let Ok(data) = encryption::maybe_unseal_after_download(&raw) {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(u) = val.get("updated_at").and_then(|x| x.as_u64()) {
return u;
}
}
}
}
}
0
}
/// Upload a small config JSON blob (proxy/vpn/group/extension/extension-group/
/// profile metadata), signing its `updated_at` into S3 object metadata so
/// future reconciles can compare via HEAD without downloading the body. The
/// body is sealed (E2E) exactly as before; only a plaintext unix timestamp
/// lives in the object metadata.
async fn upload_config_json(
&self,
remote_key: &str,
json: &str,
updated_at: u64,
) -> SyncResult<()> {
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal config: {e}")))?;
let mut meta = HashMap::new();
meta.insert(UPDATED_AT_META_KEY.to_string(), updated_at.to_string());
let presign = self
.client
.presign_upload_with_metadata(remote_key, Some(content_type), Some(meta))
.await?;
self
.client
.upload_bytes_with_metadata(
&presign.url,
&payload,
Some(content_type),
presign.metadata.as_ref(),
)
.await?;
Ok(())
}
pub async fn sync_profile(
&self,
app_handle: &tauri::AppHandle,
@@ -1431,21 +1500,13 @@ impl SyncEngine {
match (local_proxy, stat.exists) {
(Some(proxy), true) => {
// Both exist - compare timestamps
let local_updated = proxy.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = proxy.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_proxy(proxy_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_proxy(&proxy).await?;
}
}
@@ -1478,17 +1539,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
let remote_key = format!("proxies/{}.json", proxy.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_proxy.updated_at.unwrap_or(0))
.await?;
// Update local proxy with new last_sync (always write plaintext locally)
@@ -1547,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", ());
@@ -1579,21 +1639,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
// Both exist - compare timestamps
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_group(&group).await?;
}
}
@@ -1626,17 +1678,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_group)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
let remote_key = format!("groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at.unwrap_or(0))
.await?;
// Update local group with new last_sync
@@ -1795,18 +1839,13 @@ impl SyncEngine {
match (local_vpn, stat.exists) {
(Some(vpn), true) => {
let local_updated = vpn.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = vpn.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_vpn(vpn_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_vpn(&vpn).await?;
}
}
@@ -1836,17 +1875,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
let remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_vpn.updated_at.unwrap_or(0))
.await?;
// Update local VPN with new last_sync
@@ -1946,18 +1977,13 @@ impl SyncEngine {
match (local_ext, stat.exists) {
(Some(ext), true) => {
let local_updated = ext.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = ext.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension(ext_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension(&ext).await?;
}
}
@@ -1987,17 +2013,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_ext)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
let remote_key = format!("extensions/{}.json", ext.id);
let presign = self
.client
.presign_upload(&remote_key, Some(meta_content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
.upload_config_json(&remote_key, &json, updated_ext.updated_at)
.await?;
// Also upload the extension file data — encrypted as a sealed envelope
@@ -2151,18 +2169,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension_group(&group).await?;
}
}
@@ -2196,17 +2209,9 @@ impl SyncEngine {
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
})?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
let remote_key = format!("extension_groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at)
.await?;
// Update local group with new last_sync
+3 -3
View File
@@ -62,9 +62,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*",
"**/.DS_Store",
".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's
// fingerprint. Each device decides its own refresh cadence, so syncing
// this would cause one device's refresh to silence others.
// Orphaned local-only marker from earlier rollover-based fingerprint
// regeneration. Keep excluding it so any markers left on disk from
// prior builds never get uploaded.
".last-fp-refresh",
];
+14
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatRequest {
@@ -11,6 +12,11 @@ pub struct StatResponse {
#[serde(rename = "lastModified")]
pub last_modified: Option<String>,
pub size: Option<u64>,
/// User-defined S3 object metadata (`x-amz-meta-*`), lowercased keys without
/// the prefix. `None` from older servers that don't return it. Used to read
/// `updated-at` for sync conflict resolution without downloading the body.
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
pub content_type: Option<String>,
#[serde(rename = "expiresIn")]
pub expires_in: Option<u64>,
/// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`).
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
pub url: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
/// The metadata the server actually signed into the URL. The client must send
/// exactly these as `x-amz-meta-*` headers on the PUT or S3 rejects it. `None`
/// from older servers → client sends no metadata headers (body-GET fallback).
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+4
View File
@@ -52,6 +52,10 @@ pub struct VpnConfig {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on config edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
/// Parsed WireGuard configuration
+12
View File
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
sync_enabled: bool,
#[serde(default)]
last_sync: Option<u64>,
#[serde(default)]
updated_at: Option<u64>,
}
/// VPN storage manager with encryption
@@ -247,6 +249,7 @@ impl VpnStorage {
last_used: config.last_used,
sync_enabled: config.sync_enabled,
last_sync: config.last_sync,
updated_at: config.updated_at,
};
// Update existing or add new
@@ -280,6 +283,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
}
@@ -300,6 +304,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
.collect(),
)
@@ -356,6 +361,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -367,6 +373,7 @@ impl VpnStorage {
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
let mut config = self.load_config(id)?;
config.name = new_name.to_string();
config.updated_at = Some(crate::proxy_manager::now_secs());
self.save_config(&config)?;
Ok(config)
}
@@ -420,6 +427,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -463,6 +471,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -487,6 +496,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let config2 = VpnConfig {
@@ -498,6 +508,7 @@ mod tests {
last_used: Some(3000),
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config1).unwrap();
@@ -524,6 +535,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
+161 -5
View File
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
/// The fingerprint Wayfern actually applied, echoed back by
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
/// (e.g. when the stored one targets an older browser version). Internal
/// only — the caller persists it to the profile; never sent to the frontend.
#[serde(default, skip_serializing)]
pub used_fingerprint: Option<String>,
}
struct WayfernInstance {
@@ -132,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,
@@ -605,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")]
@@ -703,6 +766,7 @@ impl WayfernManager {
log::info!("Found {} page targets", page_targets.len());
// Apply fingerprint if configured
let mut used_fingerprint: Option<String> = None;
if let Some(fingerprint_json) = &config.fingerprint {
log::info!(
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
@@ -781,10 +845,30 @@ impl WayfernManager {
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
),
Ok(result) => {
log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
);
// Wayfern.setFingerprint echoes back the fingerprint it actually
// used, which may be UPGRADED from what we sent (e.g. when the
// stored fingerprint targets an older browser version). Capture
// it once, from the first target that succeeds, so the caller can
// persist the upgraded value to the profile.
if used_fingerprint.is_none() {
// getFingerprint/setFingerprint wrap the object as
// { fingerprint: {...} }; tolerate a bare object too.
let fp = result.get("fingerprint").cloned().unwrap_or(result);
if fp.is_object() {
match serde_json::to_string(&Self::normalize_fingerprint(fp)) {
Ok(s) => used_fingerprint = Some(s),
Err(e) => {
log::warn!("Failed to serialize used fingerprint: {e}")
}
}
}
}
}
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
}
}
@@ -849,6 +933,7 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
used_fingerprint,
})
}
@@ -990,6 +1075,7 @@ impl WayfernManager {
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
used_fingerprint: None,
});
} else {
log::info!(
@@ -1032,6 +1118,7 @@ impl WayfernManager {
profilePath: Some(found_profile_path),
url: None,
cdp_port,
used_fingerprint: None,
});
}
@@ -1168,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
);
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.3",
"version": "0.26.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -19,7 +19,7 @@
"active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"],
"externalBin": ["binaries/donut-proxy"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -42,11 +42,11 @@
"linux": {
"deb": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo3"]
"depends": ["xdg-utils", "libxdo3", "libayatana-appindicator3-1"]
},
"rpm": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo"]
"depends": ["xdg-utils", "libxdo", "libayatana-appindicator-gtk3"]
},
"appimage": {
"files": {
+4
View File
@@ -135,6 +135,7 @@ fn test_vpn_storage_save_and_load() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let save_result = storage.save_config(&config);
@@ -174,6 +175,7 @@ fn test_vpn_storage_list() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
}
@@ -201,6 +203,7 @@ fn test_vpn_storage_delete() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -489,6 +492,7 @@ fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> Vp
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
}
}
+148 -50
View File
@@ -3,11 +3,14 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useOnborda } from "onborda";
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";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
@@ -22,7 +25,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
@@ -39,7 +42,9 @@ import { ShortcutsPage } from "@/components/shortcuts-page";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { ThankYouDialog } from "@/components/thank-you-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WelcomeDialog } from "@/components/welcome-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
@@ -55,6 +60,11 @@ 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,
} from "@/lib/onboarding-signal";
import {
matchesGroupDigit,
matchesShortcut,
@@ -95,6 +105,95 @@ export default function Home() {
error: profilesError,
} = useProfileEvents();
// First-run onboarding tour (Onborda).
const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } =
useOnborda();
const onboardingHandledRef = useRef(false);
const [welcomeOpen, setWelcomeOpen] = useState(false);
const [thankYouOpen, setThankYouOpen] = useState(false);
// null = onboarding decision pending; false = not a first-run onboarding (run
// the normal permission checks); true = first-run onboarding, so the welcome
// flow drives permissions and the standalone permission dialog is suppressed.
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
null,
);
// Welcome flow finished. Existing-profile users are done after the welcome +
// commercial-use steps; users with no profile yet continue into the in-app
// product tour that walks them through creating their first profile.
const handleWelcomeComplete = useCallback(() => {
setWelcomeOpen(false);
setFirstRunOnboarding(false);
if (profiles.length === 0) {
startOnborda(ONBOARDING_TOUR);
}
}, [startOnborda, profiles.length]);
// The product tour finished (user clicked "Finish", not "Skip") → celebrate.
useEffect(() => {
const handler = () => setThankYouOpen(true);
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
return () =>
window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
}, []);
// Suppress the global browser-download toasts while onboarding (welcome or
// tour) is active — the welcome dialog shows setup progress itself.
useEffect(() => {
setOnboardingActive(welcomeOpen || isOnbordaVisible);
}, [welcomeOpen, isOnbordaVisible]);
// While the tour is visible, keep the body pinned to the left. Onborda calls
// scrollIntoView({ inline: "center" }) on the highlighted element; because the
// body is overflow-hidden it can still be scrolled programmatically, which
// would shove the whole app (rail and all) sideways with no way to scroll
// back. The profile table keeps its own scroll container, untouched here.
useEffect(() => {
if (!isOnbordaVisible) return;
const pin = () => {
if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0;
if (document.documentElement.scrollLeft !== 0)
document.documentElement.scrollLeft = 0;
};
pin();
window.addEventListener("scroll", pin, true);
return () => window.removeEventListener("scroll", pin, true);
}, [isOnbordaVisible]);
// On the very first launch, always show the welcome + commercial-use steps
// (one-shot: the backend flag is set immediately so it can't trigger again).
// The welcome dialog itself decides whether to continue into the browser
// download + profile-creation flow — only when the user has no profile yet.
useEffect(() => {
if (profilesLoading || onboardingHandledRef.current) return;
onboardingHandledRef.current = true;
void (async () => {
try {
const completed = await invoke<boolean>("get_onboarding_completed");
if (completed) {
setFirstRunOnboarding(false);
return;
}
await invoke("complete_onboarding");
setFirstRunOnboarding(true);
setWelcomeOpen(true);
} catch (err) {
console.error("Onboarding init failed:", err);
setFirstRunOnboarding(false);
}
})();
}, [profilesLoading]);
// Advance from the "create a profile" step to the "DNS blocking" step as soon
// as the user's first profile exists (its DNS dropdown is now in the DOM).
useEffect(() => {
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
// Small delay so the new profile row (and its DNS dropdown target) has
// mounted before Onborda re-points at it.
setCurrentStep(1, 300);
}
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
const {
groups: groupsData,
isLoading: groupsLoading,
@@ -128,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);
@@ -214,8 +310,6 @@ export default function Home() {
const [passwordDialogMode, setPasswordDialogMode] =
useState<PasswordDialogMode>("set");
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
useState<string | undefined>(undefined);
@@ -545,24 +639,6 @@ export default function Home() {
}
}, [handleUrlOpen, hasCheckedStartupUrl]);
const checkStartupPrompt = useCallback(async () => {
// Only check once during app startup to prevent reopening after dismissing notifications
if (hasCheckedStartupPrompt) return;
try {
const shouldShow = await invoke<boolean>(
"should_show_launch_on_login_prompt",
);
if (shouldShow) {
setLaunchOnLoginDialogOpen(true);
}
} catch (error) {
console.error("Failed to check startup prompt:", error);
} finally {
setHasCheckedStartupPrompt(true);
}
}, [hasCheckedStartupPrompt]);
// Handle profile errors from useProfileEvents hook
useEffect(() => {
if (profilesError) {
@@ -795,9 +871,12 @@ export default function Home() {
} catch (error) {
showErrorToast(
t("errors.createProfileFailed", {
error: error instanceof Error ? error.message : String(error),
error: translateBackendError(t, error),
}),
);
// Rethrow so the create dialog keeps itself open (its own handler
// skips closing on error), letting the user fix the proxy/VPN and retry.
throw error;
}
},
[selectedGroupId, t],
@@ -1088,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"));
@@ -1189,9 +1271,6 @@ export default function Home() {
}, [profiles, t]);
useEffect(() => {
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events and get cleanup function
const setupListeners = async () => {
const cleanup = await listenForUrlEvents();
@@ -1234,7 +1313,6 @@ export default function Home() {
};
}, [
checkForUpdates,
checkStartupPrompt,
listenForUrlEvents,
checkCurrentUrl,
checkMissingBinaries,
@@ -1249,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(
@@ -1310,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 () => {
@@ -1317,6 +1406,7 @@ export default function Home() {
unlistenStarted?.();
unlistenProgress?.();
unlistenCompleted?.();
unlistenWayfernBlocked?.();
};
}, [t]);
@@ -1336,11 +1426,13 @@ export default function Home() {
showToast({
id: "browser-support-ending-warning",
type: "error",
title: "Browser support ending soon",
description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`,
title: t("browserSupport.endingSoonTitle"),
description: t("browserSupport.endingSoonDescription", {
profiles: unsupportedNames,
}),
duration: 15000,
action: {
label: "Learn more",
label: t("common.buttons.learnMore"),
onClick: () => {
const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions",
@@ -1350,7 +1442,7 @@ export default function Home() {
},
});
}
}, [profiles]);
}, [profiles, t]);
// Re-check Wayfern terms when a browser download completes
useEffect(() => {
@@ -1371,12 +1463,14 @@ export default function Home() {
};
}, [checkTerms]);
// Check permissions when they are initialized
// Check permissions when they are initialized. During first-run onboarding
// the welcome flow requests permissions, so the standalone dialog is deferred
// until we know this isn't a first-run onboarding.
useEffect(() => {
if (isInitialized) {
if (isInitialized && firstRunOnboarding === false) {
checkAllPermissions();
}
}, [isInitialized, checkAllPermissions]);
}, [isInitialized, firstRunOnboarding, checkAllPermissions]);
// Check self-hosted sync config on mount and when cloud user changes
useEffect(() => {
@@ -1431,6 +1525,8 @@ 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}
@@ -1645,6 +1741,16 @@ export default function Home() {
onPermissionGranted={checkNextPermission}
/>
<WelcomeDialog
isOpen={welcomeOpen}
needsSetup={profiles.length === 0}
onComplete={handleWelcomeComplete}
/>
<ThankYouDialog
isOpen={thankYouOpen}
onClose={() => setThankYouOpen(false)}
/>
<CloneProfileDialog
isOpen={!!cloneProfile}
onClose={() => {
@@ -1849,14 +1955,6 @@ export default function Home() {
onClose={checkTrialStatus}
/>
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
<LaunchOnLoginDialog
isOpen={launchOnLoginDialogOpen}
onClose={() => {
setLaunchOnLoginDialogOpen(false);
}}
/>
<WindowResizeWarningDialog
isOpen={windowResizeWarningOpen}
browserType={windowResizeWarningBrowserType}
+32
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";
@@ -280,9 +281,40 @@ export function AccountPage({
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
{typeof user.deviceOrdinal === "number" && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.device")}
</p>
<p className="mt-0.5">
{t("account.deviceOrdinal", {
ordinal: user.deviceOrdinal,
count: user.deviceCount ?? user.deviceOrdinal,
})}
</p>
</div>
)}
</div>
)}
{isLoggedIn &&
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
</p>
)}
{isLoggedIn &&
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
{t("account.automationActiveHere")}
</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
+1 -1
View File
@@ -37,7 +37,7 @@ export function AppUpdateToast({
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 size-5" />
<LuCheckCheck className="shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
@@ -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>
);
}
+4 -1
View File
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { OnboardingProvider } from "@/components/onboarding-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -17,7 +18,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RippleButton } from "./ui/ripple";
export function CloseConfirmDialog() {
const { t, i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const unlistenPromise = listen("close-confirm-requested", () => {
setIsOpen(true);
});
return () => {
void unlistenPromise.then((u) => {
u();
});
};
}, []);
// The native tray menu is built in Rust and cannot read the active language,
// so push localized labels to it on mount and whenever the language changes.
useEffect(() => {
const syncTrayMenu = () => {
void invoke("update_tray_menu", {
showLabel: t("tray.show"),
quitLabel: t("tray.quit"),
}).catch(() => {
// Tray is desktop-only; ignore on platforms without one.
});
};
syncTrayMenu();
i18n.on("languageChanged", syncTrayMenu);
return () => {
i18n.off("languageChanged", syncTrayMenu);
};
}, [t, i18n]);
const handleMinimize = async () => {
setIsOpen(false);
try {
await invoke("hide_to_tray");
} catch (error) {
console.error("Failed to hide to tray:", error);
}
};
const handleQuit = async () => {
setIsOpen(false);
try {
await invoke("confirm_quit");
} catch (error) {
console.error("Failed to quit app:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("closeConfirm.title")}</DialogTitle>
<DialogDescription>{t("closeConfirm.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => {
void handleMinimize();
}}
>
{t("closeConfirm.minimize")}
</RippleButton>
<RippleButton
variant="destructive"
onClick={() => {
void handleQuit();
}}
>
{t("closeConfirm.quit")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+135 -280
View File
@@ -11,11 +11,9 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
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,12 +291,15 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
// Load downloaded Wayfern versions up front so the availability gate is
// accurate. Camoufox is deprecated and no longer creatable.
void loadDownloadedVersions("wayfern");
// 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();
}
}
@@ -320,6 +307,7 @@ export function CreateProfileDialog({
isOpen,
loadSupportedBrowsers,
loadReleaseTypes,
loadDownloadedVersions,
checkAndDownloadGeoIPDatabase,
selectedBrowser,
]);
@@ -405,72 +393,41 @@ export function CreateProfileDialog({
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
: 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) {
@@ -513,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);
@@ -538,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 }));
};
@@ -585,7 +535,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
? t("createProfile.title")
@@ -618,52 +568,42 @@ export function CreateProfileDialog({
onClick={() => {
handleBrowserSelect("wayfern");
}}
disabled={!getCreatableVersion("wayfern")}
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">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
{isBrowserCurrentlyDownloading("wayfern") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.chromiumLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.chromiumSubtitle")}
{isBrowserCurrentlyDownloading("wayfern")
? t("createProfile.downloadingSubtitle")
: t("createProfile.chromiumSubtitle")}
</div>
</div>
</Button>
{/* Camoufox (Firefox) - Second */}
<Button
onClick={() => {
handleBrowserSelect("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">
{(() => {
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">
{t("createProfile.firefoxSubtitle")}
</div>
</div>
</Button>
{/* Camoufox is deprecated no longer offered for new
profiles. Only Wayfern can be created. */}
{!getCreatableVersion("wayfern") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
</div>
</TabsContent>
@@ -867,7 +807,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
!getCreatableVersion("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
@@ -899,17 +839,53 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
isBrowserVersionAvailable("wayfern") && (
getCreatableVersion("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
getCreatableVersion("wayfern")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
getCreatableVersion("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
getBestAvailableVersion("wayfern") && (
<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: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("wayfern");
}}
isLoading={isBrowserCurrentlyDownloading(
"wayfern",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"wayfern",
)}
>
{isBrowserCurrentlyDownloading("wayfern")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
@@ -927,131 +903,14 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("wayfern")?.version
getCreatableVersion("wayfern")?.version
}
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") &&
!isBrowserVersionAvailable("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") &&
isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</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={
getBestAvailableVersion("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">
@@ -1077,7 +936,7 @@ export function CreateProfileDialog({
size="sm"
variant="outline"
>
Retry
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
@@ -1086,7 +945,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1122,18 +981,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(
selectedBrowser,
) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1432,7 +1288,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
Fetching available versions...
{t("createProfile.version.fetching")}
</p>
</div>
)}
@@ -1458,7 +1314,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1494,16 +1350,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(selectedBrowser) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1701,7 +1556,7 @@ export function CreateProfileDialog({
</ScrollArea>
</Tabs>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<DialogFooter className="shrink-0 pt-4 border-t">
{currentStep === "browser-config" ? (
<>
<RippleButton variant="outline" onClick={handleBack}>
+28 -64
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 {
@@ -174,42 +162,34 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
);
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
);
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
}
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
return <LuDownload className="shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -232,7 +212,7 @@ export function UnifiedToast(props: ToastProps) {
<button
type="button"
onClick={onCancel}
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={t("common.buttons.cancel")}
>
<LuX className="size-3" />
@@ -250,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">
@@ -268,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">
@@ -297,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)}
@@ -308,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">
@@ -359,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 &&
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
@@ -45,7 +46,7 @@ export function DeviceCodeVerifyDialog({
const handleOpenLogin = async () => {
setIsOpeningLogin(true);
try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
await openUrl(DEVICE_LINK_URL);
} catch (error) {
console.error("Failed to open login link:", error);
showErrorToast(String(error));
@@ -1129,10 +1129,10 @@ export function ExtensionManagementDialog({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+3 -3
View File
@@ -148,10 +148,10 @@ export function GroupBadges({
return (
<div className="relative mb-4">
{showLeftFade && (
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
)}
{showRightFade && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
)}
<div
ref={scrollContainerRef}
@@ -165,7 +165,7 @@ export function GroupBadges({
<Badge
key={group.id}
variant={selectedGroupId === group.id ? "default" : "secondary"}
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 flex-shrink-0"
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
onClick={(e) => {
if (hasMovedRef.current || clickBlockedRef.current) {
e.preventDefault();
+1
View File
@@ -321,6 +321,7 @@ const HomeHeader = ({
<span className="shrink-0">
<Button
size="sm"
data-onborda="create-profile"
onClick={() => {
onCreateProfileDialogOpen(true);
}}
+29 -34
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]);
@@ -303,7 +308,7 @@ export function ImportProfileDialog({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
{!subPage && (
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
@@ -577,34 +582,24 @@ 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>
<div
className={cn(
"flex-shrink-0 flex gap-2 items-center justify-end",
"shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
)}
>
-106
View File
@@ -1,106 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface LaunchOnLoginDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function LaunchOnLoginDialog({
isOpen,
onClose,
}: LaunchOnLoginDialogProps) {
const { t } = useTranslation();
const [isEnabling, setIsEnabling] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const handleEnable = useCallback(async () => {
setIsEnabling(true);
try {
await invoke("enable_launch_on_login");
showSuccessToast(t("launchOnLogin.enableSuccess"));
onClose();
} catch (error) {
console.error("Failed to enable launch on login:", error);
showErrorToast(t("launchOnLogin.enableFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsEnabling(false);
}
}, [onClose, t]);
const handleDecline = useCallback(async () => {
setIsDeclining(true);
try {
await invoke("decline_launch_on_login");
onClose();
} catch (error) {
console.error("Failed to decline launch on login:", error);
showErrorToast(t("launchOnLogin.declineFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsDeclining(false);
}
}, [onClose, t]);
return (
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-sm"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onPointerDownOutside={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("launchOnLogin.description")}
</p>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button
variant="ghost"
onClick={handleDecline}
disabled={isEnabling || isDeclining}
>
{isDeclining
? t("launchOnLogin.declining")
: t("launchOnLogin.declineButton")}
</Button>
<LoadingButton
onClick={handleEnable}
isLoading={isEnabling}
disabled={isDeclining}
>
{t("launchOnLogin.enableButton")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import type { CardComponentProps } from "onborda";
import { useOnborda } from "onborda";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal";
// Custom Onborda card, themed with the app's CSS variables. Finishing the last
// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory
// thank-you dialog (skipping early does not emit it).
export function OnboardingCard({
step,
currentStep,
totalSteps,
nextStep,
prevStep,
arrow,
}: CardComponentProps) {
const { t } = useTranslation();
const { closeOnborda } = useOnborda();
const isFirst = currentStep === 0;
const isLast = currentStep === totalSteps - 1;
// This step is completed by clicking the highlighted element (the "New"
// button), not by a "Next" button — advancing manually would jump to a step
// whose target doesn't exist yet and block the button. So hide "Next" here.
const requiresAction = step.selector === '[data-onborda="create-profile"]';
return (
<div className="relative p-4 w-80 max-w-[90vw] rounded-lg border shadow-lg bg-popover text-popover-foreground">
<div className="flex gap-2 items-start justify-between">
<h3 className="text-sm font-semibold leading-tight">{step.title}</h3>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{currentStep + 1}/{totalSteps}
</span>
</div>
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
{step.content}
</div>
<div className="flex gap-2 items-center justify-between mt-4">
{isLast ? (
<span />
) : (
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => {
closeOnborda();
}}
>
{t("onboarding.buttons.skip")}
</Button>
)}
<div className="flex gap-2 items-center">
{!isFirst && !isLast && (
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => {
prevStep();
}}
>
{t("onboarding.buttons.back")}
</Button>
)}
{isLast ? (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
closeOnborda();
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
}}
>
{t("onboarding.buttons.finish")}
</Button>
) : requiresAction ? null : (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
nextStep();
}}
>
{t("onboarding.buttons.next")}
</Button>
)}
</div>
</div>
<span className="text-popover">{arrow}</span>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Onborda, type OnbordaProps, OnbordaProvider } from "onborda";
import { useTranslation } from "react-i18next";
import { OnboardingCard } from "@/components/onboarding-card";
// Name of the first-run product tour. Referenced by the trigger logic in
// `src/app/page.tsx` via `startOnborda(ONBOARDING_TOUR)`.
export const ONBOARDING_TOUR = "donut-onboarding";
export function OnboardingProvider({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const tours: OnbordaProps["steps"] = [
{
tour: ONBOARDING_TOUR,
steps: [
{
icon: null,
title: t("onboarding.steps.createProfile.title"),
content: t("onboarding.steps.createProfile.content"),
selector: '[data-onborda="create-profile"]',
// The "New" button sits in the top-right corner; "bottom-right"
// anchors the card's right edge to it so the card extends left/down
// and stays on-screen instead of overflowing the right viewport edge.
side: "bottom-right",
showControls: true,
pointerPadding: 8,
pointerRadius: 10,
},
{
icon: null,
title: t("onboarding.steps.dnsBlocking.title"),
content: t("onboarding.steps.dnsBlocking.content"),
selector: '[data-onborda="dns-blocklist"]',
// The DNS dropdown sits in the right-hand columns. A centered "bottom"
// card runs off the right edge; "bottom-right" anchors the card's right
// edge to the dropdown and extends it left/down, keeping it fully
// on-screen with its arrow pointing up at the option.
side: "bottom-right",
showControls: true,
pointerPadding: 6,
pointerRadius: 8,
},
],
},
];
return (
<OnbordaProvider>
<Onborda
steps={tours}
cardComponent={OnboardingCard}
interact
shadowRgb="0,0,0"
shadowOpacity="0.6"
>
{children}
</Onborda>
</OnbordaProvider>
);
}
+4 -6
View File
@@ -131,9 +131,9 @@ export function PermissionDialog({
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="size-8" />;
return <BsMic className="size-5 shrink-0" />;
case "camera":
return <BsCamera className="size-8" />;
return <BsCamera className="size-5 shrink-0" />;
}
};
@@ -195,13 +195,11 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
<DialogTitle className="flex items-center justify-center gap-2 text-xl">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
{getPermissionTitle(permissionType)}
</DialogTitle>
<DialogDescription className="text-base">
<DialogDescription className="text-base text-pretty">
{getPermissionDescription(permissionType)}
</DialogDescription>
</DialogHeader>
+5 -4
View File
@@ -441,6 +441,7 @@ function DnsCell({
<PopoverTrigger asChild>
<button
type="button"
data-onborda="dns-blocklist"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
title={
@@ -2038,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>
+143 -20
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,11 +13,13 @@ import {
LuClipboardCheck,
LuCookie,
LuCopy,
LuDownload,
LuFingerprint,
LuGlobe,
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
@@ -38,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 {
@@ -262,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];
@@ -298,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;
@@ -310,6 +324,7 @@ export function ProfileInfoDialog({
const actions: ActionItem[] = [
{
id: "network",
icon: <LuGlobe className="size-4" />,
label: t("profiles.actions.viewNetwork"),
onClick: () => {
@@ -318,6 +333,7 @@ export function ProfileInfoDialog({
disabled: isCrossOs,
},
{
id: "sync",
icon: <LuRefreshCw className="size-4" />,
label: t("profiles.actions.syncSettings"),
onClick: () => {
@@ -336,12 +352,15 @@ export function ProfileInfoDialog({
runningBadge: isRunning,
},
{
id: "fingerprint",
icon: <LuFingerprint className="size-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => {
handleAction(() => onConfigureCamoufox?.(profile));
},
disabled: isDisabled,
// Viewing and editing fingerprints both require an active paid plan.
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
@@ -356,6 +375,7 @@ export function ProfileInfoDialog({
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
id: "cookiesCopy",
icon: <LuCopy className="size-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => {
@@ -369,6 +389,7 @@ export function ProfileInfoDialog({
!onCopyCookiesToProfile,
},
{
id: "cookiesManage",
icon: <LuCookie className="size-4" />,
label: t("profileInfo.actions.manageCookies"),
onClick: () => {
@@ -392,6 +413,7 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
id: "extension",
icon: <LuPuzzle className="size-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => {
@@ -416,6 +438,7 @@ export function ProfileInfoDialog({
},
},
{
id: "hook",
icon: <LuLink className="size-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
@@ -458,6 +481,7 @@ export function ProfileInfoDialog({
destructive: true,
},
{
id: "delete",
icon: <LuTrash2 className="size-4" />,
label: t("profiles.actions.delete"),
onClick: () => {
@@ -481,6 +505,9 @@ export function ProfileInfoDialog({
hideClose
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
>
{/* The dialog renders its own custom header, so the accessible title is
visually hidden but present for screen readers (Radix requires it). */}
<DialogTitle className="sr-only">{t("profileInfo.title")}</DialogTitle>
<ProfileInfoLayout
profile={profile}
ProfileIcon={ProfileIcon}
@@ -528,6 +555,7 @@ interface ProfileInfoLayoutProps {
onCloneProfile?: (profile: BrowserProfile) => void;
onKillProfile?: (profile: BrowserProfile) => void;
visibleActions: {
id?: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
@@ -573,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.
@@ -888,6 +917,7 @@ function ProfileInfoLayout({
// proBadge state. Default to false if action missing.
fingerprintAction && !fingerprintAction.proBadge,
)}
onSaved={onClose}
t={t}
/>
)}
@@ -1142,7 +1172,7 @@ function SyncSectionInline({
syncMode: mode,
});
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1185,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>
)}
@@ -1239,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);
}
@@ -1257,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);
}
@@ -1363,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();
@@ -1377,7 +1409,7 @@ function ExtensionsSectionInline({
mounted = false;
unlisten?.();
};
}, []);
}, [t]);
const onChange = async (value: string) => {
const next = value === "__none__" ? null : value;
@@ -1390,7 +1422,7 @@ function ExtensionsSectionInline({
});
setGroupId(next);
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1488,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 (
@@ -1498,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"
@@ -1507,7 +1609,7 @@ function CookiesSectionInline({
onClick={onImportCookies}
>
<LuUpload className="size-3.5" />
{t("cookies.import.title")}
{t("common.buttons.import")}
</Button>
)}
{onCopyCookies && (
@@ -1519,7 +1621,7 @@ function CookiesSectionInline({
onClick={onCopyCookies}
>
<LuCopy className="size-3.5" />
{t("profiles.actions.copyCookies")}
{t("common.buttons.copy")}
</Button>
)}
</div>
@@ -1586,11 +1688,13 @@ function FingerprintSectionInline({
profile,
isDisabled,
crossOsUnlocked,
onSaved,
t,
}: {
profile: BrowserProfile;
isDisabled: boolean;
crossOsUnlocked: boolean;
onSaved: () => void;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const [camoufoxConfig, setCamoufoxConfig] = React.useState<CamoufoxConfig>(
@@ -1629,6 +1733,23 @@ function FingerprintSectionInline({
);
}
// Viewing and editing fingerprints both require an active paid plan
// (`crossOsUnlocked` is that paid flag here). Render a locked state instead of
// the editor so free users can neither see nor change the fingerprint.
if (!crossOsUnlocked) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
<LuLock className="size-4 shrink-0 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{t("profileInfo.fingerprint.lockedTitle")}
</h3>
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
{t("profileInfo.fingerprint.lockedDescription")}
</p>
</div>
);
}
const onCamoufoxChange = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
setSuccess(null);
@@ -1655,8 +1776,10 @@ function FingerprintSectionInline({
});
}
setSuccess(t("common.buttons.saved"));
// 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 ||
+26 -7
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import { openUrl } from "@tauri-apps/plugin-opener";
import Color from "color";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -368,19 +369,36 @@ export function SettingsDialog({
async (permissionType: PermissionType) => {
setRequestingPermission(permissionType);
try {
await requestPermission(permissionType);
showSuccessToast(
t("settings.permissions.accessRequested", {
permission: getPermissionDisplayName(permissionType),
}),
const granted = await requestPermission(permissionType);
if (granted) {
showSuccessToast(
permissionType === "microphone"
? t("permissionDialog.grantedToastMicrophone")
: t("permissionDialog.grantedToastCamera"),
);
return;
}
await openUrl(
`x-apple.systempreferences:com.apple.preference.security?${
permissionType === "microphone"
? "Privacy_Microphone"
: "Privacy_Camera"
}`,
);
showErrorToast(
permissionType === "microphone"
? t("permissionDialog.stillNotGrantedMicrophone")
: t("permissionDialog.stillNotGrantedCamera"),
);
} catch (error) {
console.error("Failed to request permission:", error);
showErrorToast(t("permissionDialog.requestFailed"));
} finally {
setRequestingPermission(null);
}
},
[getPermissionDisplayName, requestPermission, t],
[requestPermission, t],
);
const handleSave = useCallback(async () => {
@@ -465,7 +483,8 @@ export function SettingsDialog({
| "zh"
| "ja"
| "ko"
| "ru"),
| "ru"
| "vi"),
);
setOriginalLanguage(selectedLanguage);
}
+89 -39
View File
@@ -422,7 +422,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="Mozilla/5.0..."
placeholder={t("common.placeholders.example", {
value: "Mozilla/5.0...",
})}
/>
</div>
<div className="space-y-2">
@@ -436,7 +438,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., MacIntel, Win32"
placeholder={t("common.placeholders.example", {
value: "MacIntel, Win32",
})}
/>
</div>
<div className="space-y-2">
@@ -452,7 +456,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 5.0 (Macintosh)"
placeholder={t("common.placeholders.example", {
value: "5.0 (Macintosh)",
})}
/>
</div>
<div className="space-y-2">
@@ -487,7 +493,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 8"
placeholder={t("common.placeholders.example", { value: "8" })}
/>
</div>
<div className="space-y-2">
@@ -504,7 +510,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -549,7 +555,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., en-US"
placeholder={t("common.placeholders.example", {
value: "en-US",
})}
/>
</div>
</div>
@@ -573,7 +581,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -590,7 +600,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -607,7 +619,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -624,7 +638,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1055"
placeholder={t("common.placeholders.example", {
value: "1055",
})}
/>
</div>
<div className="space-y-2">
@@ -641,7 +657,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 30"
placeholder={t("common.placeholders.example", {
value: "30",
})}
/>
</div>
<div className="space-y-2">
@@ -658,7 +676,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 30"
placeholder={t("common.placeholders.example", {
value: "30",
})}
/>
</div>
</div>
@@ -682,7 +702,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1512"
placeholder={t("common.placeholders.example", {
value: "1512",
})}
/>
</div>
<div className="space-y-2">
@@ -699,7 +721,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 886"
placeholder={t("common.placeholders.example", {
value: "886",
})}
/>
</div>
<div className="space-y-2">
@@ -716,7 +740,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1512"
placeholder={t("common.placeholders.example", {
value: "1512",
})}
/>
</div>
<div className="space-y-2">
@@ -733,7 +759,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 886"
placeholder={t("common.placeholders.example", {
value: "886",
})}
/>
</div>
<div className="space-y-2">
@@ -748,7 +776,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -763,7 +791,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -786,7 +814,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 41.0019"
placeholder={t("common.placeholders.example", {
value: "41.0019",
})}
/>
</div>
<div className="space-y-2">
@@ -802,7 +832,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 28.9645"
placeholder={t("common.placeholders.example", {
value: "28.9645",
})}
/>
</div>
<div className="space-y-2">
@@ -817,7 +849,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., America/New_York"
placeholder={t("common.placeholders.example", {
value: "America/New_York",
})}
/>
</div>
</div>
@@ -840,7 +874,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., tr"
placeholder={t("common.placeholders.example", {
value: "tr",
})}
/>
</div>
<div className="space-y-2">
@@ -854,7 +890,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., TR"
placeholder={t("common.placeholders.example", {
value: "TR",
})}
/>
</div>
<div className="space-y-2">
@@ -868,7 +906,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Latn"
placeholder={t("common.placeholders.example", {
value: "Latn",
})}
/>
</div>
</div>
@@ -891,7 +931,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Mesa"
placeholder={t("common.placeholders.example", {
value: "Mesa",
})}
/>
</div>
<div className="space-y-2">
@@ -1053,7 +1095,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -1071,7 +1113,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -1097,10 +1139,10 @@ export function SharedCamoufoxConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
@@ -1240,7 +1282,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -1259,7 +1303,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -1278,7 +1324,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 800"
placeholder={t("common.placeholders.example", {
value: "800",
})}
/>
</div>
<div className="space-y-2">
@@ -1297,7 +1345,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 600"
placeholder={t("common.placeholders.example", {
value: "600",
})}
/>
</div>
</div>
@@ -1305,10 +1355,10 @@ export function SharedCamoufoxConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu";
@@ -206,7 +207,7 @@ export function SyncConfigDialog({
const handleOpenLogin = useCallback(async () => {
try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
await openUrl(DEVICE_LINK_URL);
// Hand off the verify step to its own dialog so the user has a
// focused place to paste the code, and so it doesn't visually
// stack with this dialog or any other modal currently on screen.
+83
View File
@@ -0,0 +1,83 @@
"use client";
import confetti from "canvas-confetti";
import { motion } from "motion/react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Logo } from "@/components/icons/logo";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
// Celebratory close-out of the first-run onboarding: thanks the user and fires
// confetti. Shown once the product tour is finished.
export function ThankYouDialog({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
useEffect(() => {
if (!isOpen) return;
const fire = (options: confetti.Options) => {
void confetti({ origin: { y: 0.7 }, ...options });
};
fire({ particleCount: 110, spread: 70, startVelocity: 48 });
const t1 = setTimeout(
() => fire({ particleCount: 70, spread: 100, decay: 0.92 }),
200,
);
const t2 = setTimeout(
() => fire({ particleCount: 50, spread: 120, scalar: 0.9 }),
420,
);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [isOpen]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-md">
<div className="flex flex-col items-center gap-6 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.6, rotate: -12 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ ...spring, delay: 0.05 }}
className="text-foreground"
>
<Logo className="size-14" />
</motion.div>
<div className="flex flex-col gap-2">
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
{t("onboarding.thankYou.title")}
</DialogTitle>
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...spring, delay: 0.15 }}
className="mx-auto max-w-[46ch] text-sm leading-6 text-pretty text-muted-foreground"
>
{t("onboarding.thankYou.body")}
</motion.p>
</div>
<Button size="sm" onClick={onClose}>
{t("onboarding.thankYou.cta")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

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