Compare commits

...

98 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
zhom 55de231a37 docs: readme 2026-05-25 03:38:01 +04:00
zhom aab403fd9b docs: update preview 2026-05-25 02:31:06 +04:00
zhom 667a4c99f0 chore: version bump 2026-05-25 02:20:40 +04:00
zhom 9236ad38c8 refactor: cleanup 2026-05-25 02:19:20 +04:00
zhom 6850f2c573 chore: linting 2026-05-23 14:35:55 +04:00
zhom 0add6c2aae chore: update pnpm 2026-05-23 14:22:45 +04:00
zhom f54c359d15 chore: make telegram releases ai-generated 2026-05-23 14:22:45 +04:00
zhom 69da467ce0 refactor: cleanup, korean translation 2026-05-23 14:22:45 +04:00
zhom 375530e358 chore: workflow cleanup 2026-05-23 14:22:45 +04:00
andy d664e5cde6 Merge pull request #377 from zhom/dependabot/cargo/src-tauri/rust-dependencies-fa7ae92db0
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 25 updates
2026-05-23 03:22:35 -07:00
andy 096e4aaf4a Merge pull request #376 from zhom/dependabot/github_actions/github-actions-39fff3f52e
ci(deps): bump the github-actions group with 6 updates
2026-05-23 03:07:33 -07:00
dependabot[bot] 8305c45cb5 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 25 updates:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.149` | `1.0.150` |
| [tauri](https://github.com/tauri-apps/tauri) | `2.11.1` | `2.11.2` |
| [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.39.1` | `0.39.2` |
| [tar](https://github.com/composefs/tar-rs) | `0.4.45` | `0.4.46` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.6.10` | `0.6.11` |
| [cbc](https://github.com/RustCrypto/block-modes) | `0.2.0` | `0.2.1` |
| [tao](https://github.com/tauri-apps/tao) | `0.35.2` | `0.35.3` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [autocfg](https://github.com/cuviper/autocfg) | `1.5.0` | `1.5.1` |
| [built](https://github.com/lukaslueg/built) | `0.8.0` | `0.8.1` |
| [bumpalo](https://github.com/fitzgen/bumpalo) | `3.20.2` | `3.20.3` |
| [either](https://github.com/rayon-rs/either) | `1.15.0` | `1.16.0` |
| [libbz2-rs-sys](https://github.com/trifectatechfoundation/libbzip2-rs) | `0.2.4` | `0.2.5` |
| [muda](https://github.com/tauri-apps/muda) | `0.19.1` | `0.19.2` |
| [num-conv](https://github.com/jhpratt/num-conv) | `0.2.1` | `0.2.2` |
| [openssl](https://github.com/rust-openssl/rust-openssl) | `0.10.79` | `0.10.80` |
| [openssl-sys](https://github.com/rust-openssl/rust-openssl) | `0.9.115` | `0.9.116` |
| rsqlite-vfs | `0.1.0` | `0.1.1` |
| [sqlite-wasm-rs](https://github.com/Spxg/sqlite-wasm-rs) | `0.5.3` | `0.5.4` |
| [tauri-codegen](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [tauri-macros](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [tauri-plugin](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [tauri-runtime](https://github.com/tauri-apps/tauri) | `2.11.1` | `2.11.2` |
| [tauri-runtime-wry](https://github.com/tauri-apps/tauri) | `2.11.1` | `2.11.2` |
| [tauri-utils](https://github.com/tauri-apps/tauri) | `2.9.1` | `2.9.2` |


Updates `serde_json` from 1.0.149 to 1.0.150
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

Updates `tauri` from 2.11.1 to 2.11.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.11.1...tauri-v2.11.2)

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

Updates `tar` from 0.4.45 to 0.4.46
- [Release notes](https://github.com/composefs/tar-rs/releases)
- [Commits](https://github.com/composefs/tar-rs/compare/0.4.45...0.4.46)

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

Updates `cbc` from 0.2.0 to 0.2.1
- [Commits](https://github.com/RustCrypto/block-modes/compare/cbc-v0.2.0...cbc-v0.2.1)

Updates `tao` from 0.35.2 to 0.35.3
- [Release notes](https://github.com/tauri-apps/tao/releases)
- [Changelog](https://github.com/tauri-apps/tao/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tao/compare/tao-v0.35.2...tao-v0.35.3)

Updates `tauri-build` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.6.1...tauri-build-v2.6.2)

Updates `autocfg` from 1.5.0 to 1.5.1
- [Commits](https://github.com/cuviper/autocfg/compare/1.5.0...1.5.1)

Updates `built` from 0.8.0 to 0.8.1
- [Changelog](https://github.com/lukaslueg/built/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lukaslueg/built/compare/0.8.0...0.8.1)

Updates `bumpalo` from 3.20.2 to 3.20.3
- [Changelog](https://github.com/fitzgen/bumpalo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fitzgen/bumpalo/compare/v3.20.2...v3.20.3)

Updates `either` from 1.15.0 to 1.16.0
- [Commits](https://github.com/rayon-rs/either/compare/1.15.0...1.16.0)

Updates `libbz2-rs-sys` from 0.2.4 to 0.2.5
- [Release notes](https://github.com/trifectatechfoundation/libbzip2-rs/releases)
- [Changelog](https://github.com/trifectatechfoundation/libbzip2-rs/blob/main/NEWS.md)
- [Commits](https://github.com/trifectatechfoundation/libbzip2-rs/compare/0.2.4...v0.2.5)

Updates `muda` from 0.19.1 to 0.19.2
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.19.1...muda-v0.19.2)

Updates `num-conv` from 0.2.1 to 0.2.2
- [Commits](https://github.com/jhpratt/num-conv/compare/v0.2.1...v0.2.2)

Updates `openssl` from 0.10.79 to 0.10.80
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.79...openssl-v0.10.80)

Updates `openssl-sys` from 0.9.115 to 0.9.116
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-sys-v0.9.115...openssl-sys-v0.9.116)

Updates `rsqlite-vfs` from 0.1.0 to 0.1.1

Updates `sqlite-wasm-rs` from 0.5.3 to 0.5.4
- [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.3...0.5.4)

Updates `tauri-codegen` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-codegen-v2.6.1...tauri-codegen-v2.6.2)

Updates `tauri-macros` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-macros-v2.6.1...tauri-macros-v2.6.2)

Updates `tauri-plugin` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-plugin-v2.6.1...tauri-plugin-v2.6.2)

Updates `tauri-runtime` from 2.11.1 to 2.11.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-v2.11.1...tauri-runtime-v2.11.2)

Updates `tauri-runtime-wry` from 2.11.1 to 2.11.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-wry-v2.11.1...tauri-runtime-wry-v2.11.2)

Updates `tauri-utils` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-utils-v2.9.1...tauri-utils-v2.9.2)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri
  dependency-version: 2.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sysinfo
  dependency-version: 0.39.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tar
  dependency-version: 0.4.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cbc
  dependency-version: 0.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tao
  dependency-version: 0.35.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: autocfg
  dependency-version: 1.5.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: built
  dependency-version: 0.8.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bumpalo
  dependency-version: 3.20.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: either
  dependency-version: 1.16.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libbz2-rs-sys
  dependency-version: 0.2.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: muda
  dependency-version: 0.19.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: num-conv
  dependency-version: 0.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: openssl
  dependency-version: 0.10.80
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: openssl-sys
  dependency-version: 0.9.116
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rsqlite-vfs
  dependency-version: 0.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sqlite-wasm-rs
  dependency-version: 0.5.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-codegen
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-macros
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime
  dependency-version: 2.11.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime-wry
  dependency-version: 2.11.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-utils
  dependency-version: 2.9.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 09:44:23 +00:00
dependabot[bot] ff3634e6cc ci(deps): bump the github-actions group with 6 updates
Bumps the github-actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/github-script](https://github.com/actions/github-script) | `7.1.0` | `9.0.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` |
| [anomalyco/opencode](https://github.com/anomalyco/opencode) | `1.15.3` | `1.15.10` |
| [actions/stale](https://github.com/actions/stale) | `10.2.0` | `10.3.0` |


Updates `actions/github-script` from 7.1.0 to 9.0.0
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/f28e40c7f34bde8b3046d885e986cb6290c5673b...3a2844b7e9c422d3c10d287c895573f7108da1b3)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

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

Updates `actions/stale` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.15.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.3.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-23 09:05:26 +00:00
zhom 36263eac04 feat: add shortcuts 2026-05-17 21:02:11 +04:00
zhom 9e777ed37b refactor: reduce token usage 2026-05-17 21:02:11 +04:00
zhom 4d59805989 chore: use less tokens 2026-05-17 21:02:11 +04:00
zhom 28d135de06 fix: track gecko_id for extension groups 2026-05-17 21:02:11 +04:00
zhom d234172d0a chore: improve issue validation 2026-05-17 21:02:11 +04:00
andy 6cd257c40b Merge pull request #372 from zhom/dependabot/github_actions/github-actions-4cf24cbed6
ci(deps): bump the github-actions group across 1 directory with 6 updates
2026-05-17 19:01:54 +02:00
dependabot[bot] 7446f678d4 ci(deps): bump the github-actions group across 1 directory with 6 updates
Bumps the github-actions group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.0.6` | `6.0.8` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) | `2.3.5` | `2.3.8` |
| [anomalyco/opencode](https://github.com/anomalyco/opencode) | `1.14.41` | `1.15.3` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `2.3.5` | `2.3.8` |
| [actions/ai-inference](https://github.com/actions/ai-inference) | `2.0.7` | `2.1.0` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.46.1` | `1.46.2` |



Updates `pnpm/action-setup` from 6.0.6 to 6.0.8
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/91ab88e2619ed1f46221f0ba42d1492c02baf788...0e279bb959325dab635dd2c09392533439d90093)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.3.5 to 2.3.8
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c51854704019a247608d928f370c98740469d4b5...9a498708959aeaef5ef730655706c5a1df1edbc2)

Updates `anomalyco/opencode` from 1.14.41 to 1.15.3
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/8ba2a9171597262df9d19516c82a5e14f18f5c63...37f89b742907c43b20d38b68eabe65981a59690a)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.5 to 2.3.8
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c51854704019a247608d928f370c98740469d4b5...9a498708959aeaef5ef730655706c5a1df1edbc2)

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

Updates `crate-ci/typos` from 1.46.1 to 1.46.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/5374cbf686e897b15713110e233094e2874de7ef...aca895bf05aec0cb7dffa6f94495e923224d9f17)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.15.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/ai-inference
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.46.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 17:00:42 +00:00
andy 72e2b99b9e Merge pull request #371 from zhom/dependabot/cargo/src-tauri/rust-dependencies-60b6c910ca
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 9 updates
2026-05-17 17:13:37 +02:00
dependabot[bot] 98b83aaf5a deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [toml](https://github.com/toml-rs/toml) | `0.9.12+spec-1.1.0` | `1.1.2+spec-1.1.0` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.39.4` | `0.40.1` |
| [filetime](https://github.com/alexcrichton/filetime) | `0.2.28` | `0.2.29` |
| [kurbo](https://github.com/linebender/kurbo) | `0.13.0` | `0.13.1` |
| [open](https://github.com/Byron/open-rs) | `5.3.4` | `5.3.5` |
| [pin-project](https://github.com/taiki-e/pin-project) | `1.1.12` | `1.1.13` |
| [pin-project-internal](https://github.com/taiki-e/pin-project) | `1.1.12` | `1.1.13` |
| [zerofrom](https://github.com/unicode-org/icu4x) | `0.1.7` | `0.1.8` |


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 `toml` from 0.9.12+spec-1.1.0 to 1.1.2+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.12...toml-v1.1.2)

Updates `quick-xml` from 0.39.4 to 0.40.1
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.39.4...v0.40.1)

Updates `filetime` from 0.2.28 to 0.2.29
- [Commits](https://github.com/alexcrichton/filetime/compare/0.2.28...0.2.29)

Updates `kurbo` from 0.13.0 to 0.13.1
- [Release notes](https://github.com/linebender/kurbo/releases)
- [Changelog](https://github.com/linebender/kurbo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/kurbo/compare/v0.13.0...v0.13.1)

Updates `open` from 5.3.4 to 5.3.5
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.4...v5.3.5)

Updates `pin-project` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/taiki-e/pin-project/releases)
- [Changelog](https://github.com/taiki-e/pin-project/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/pin-project/compare/v1.1.12...v1.1.13)

Updates `pin-project-internal` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/taiki-e/pin-project/releases)
- [Changelog](https://github.com/taiki-e/pin-project/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/pin-project/compare/v1.1.12...v1.1.13)

Updates `zerofrom` from 0.1.7 to 0.1.8
- [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)

---
updated-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: toml
  dependency-version: 1.1.2+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: quick-xml
  dependency-version: 0.40.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: filetime
  dependency-version: 0.2.29
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: kurbo
  dependency-version: 0.13.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: open
  dependency-version: 5.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pin-project
  dependency-version: 1.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pin-project-internal
  dependency-version: 1.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerofrom
  dependency-version: 0.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 09:42:11 +00:00
github-actions[bot] 99074280ea chore: update flake.nix for v0.24.2 [skip ci] (#370)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:29:04 +00:00
github-actions[bot] 85586ed8fa docs: update CHANGELOG.md and README.md for v0.24.2 [skip ci] (#369)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:28:42 +00:00
zhom 2e891dd9ec chore: version bump 2026-05-16 02:43:17 +04:00
zhom e5361b6905 fix: camoufox proxy pid connection 2026-05-16 02:41:28 +04:00
zhom f6daa642d0 refactor: browser update 2026-05-15 20:42:25 +04:00
zhom c84d547a8c feat: more mcp integrations 2026-05-15 19:59:44 +04:00
zhom c8a43b43f1 refactor: ui cleanup 2026-05-15 15:44:20 +04:00
zhom 56b0da990b refactor: cleanup 2026-05-14 20:04:19 +04:00
zhom 597efb7e58 chore: cleanup 2026-05-14 20:03:22 +04:00
github-actions[bot] ba72e4cb3b chore: update flake.nix for v0.24.1 [skip ci] (#364)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:28 +00:00
github-actions[bot] c2ace4b8d3 docs: update CHANGELOG.md and README.md for v0.24.1 [skip ci] (#363)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:10 +00:00
zhom 35a874ead0 chore: version bump 2026-05-12 20:52:10 +04:00
zhom f02397dba9 refactor: creation button disaster recovery 2026-05-12 20:50:29 +04:00
github-actions[bot] d5752633c8 chore: update flake.nix for v0.24.0 [skip ci] (#357)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:02:08 +00:00
github-actions[bot] 5752260018 docs: update CHANGELOG.md and README.md for v0.24.0 [skip ci] (#356)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:01:52 +00:00
zhom 405d7c5716 fix: pass correct parameter for dns list selection 2026-05-12 13:17:29 +04:00
178 changed files with 18537 additions and 7087 deletions
@@ -0,0 +1,23 @@
messages:
- role: system
content: |-
You write short, friendly release summaries for Donut Browser, an anti-detect browser desktop app built with Tauri and Next.js.
Rules:
- Keep it minimal and friendly. No marketing voice, no filler, no superlatives.
- No emojis or pictographic symbols.
- Plain ASCII punctuation only. No em-dashes, en-dashes, ellipses, smart quotes, or any non-ASCII characters. Use a regular hyphen, three dots, or straight quotes instead.
- Plain text only. No markdown (no asterisks for bold, no backticks for code, no headings), no HTML tags.
- Focus on user-visible changes. Skip chore, docs-only, CI, test, dependency, formatting, and purely internal refactor commits unless they have user-visible impact.
- Group related commits into a single bullet when it reads better.
- Use simple, direct language.
- Do not include the version number, download links, or a heading. The surrounding message already has those.
- If nothing in the commits is user-visible, output exactly one bullet: "- Small fixes and internal improvements."
- role: user
content: |-
Write the summary for Donut Browser {{version}} from these commits:
{{commits}}
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
model: openai/gpt-4.1
+2 -2
View File
@@ -31,10 +31,10 @@ 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@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
+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
@@ -13,7 +13,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
+4 -4
View File
@@ -30,13 +30,13 @@ 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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
echo "Tags: ${TAGS}"
- name: Build and push Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
with:
context: .
file: ./donut-sync/Dockerfile
+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
+133
View File
@@ -0,0 +1,133 @@
name: Issue Compliance Check
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
env:
MODEL: z-ai/glm-5.1
jobs:
check-compliance:
# 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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 reviewing a new GitHub issue for template compliance. 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)
## Compliance — 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 — a non-compliant verdict closes the issue, so only flag a genuine template violation.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...]
}
If there is nothing to flag, return:
{"is_compliant": true, "non_compliance_reasons": []}
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: ("New issue title: " + $title + "\n\nNew issue body:\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
# Strip accidental markdown fences and parse. On parse failure, fall back
# 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"
cat /tmp/raw.txt
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi
echo "Result:"
cat /tmp/result.json
- name: Build comment
id: build
run: |
python3 - <<'EOF'
import json, os
r = json.load(open('/tmp/result.json'))
compliant = bool(r.get('is_compliant', True))
reasons = r.get('non_compliance_reasons') or []
parts = []
if not compliant:
parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).")
parts.append('')
parts.append('**What was missing:**')
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
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'non_compliant={"true" if not compliant else "false"}\n')
EOF
- 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 comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
+32 -19
View File
@@ -18,8 +18,8 @@ permissions:
env:
# Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: anthropic/claude-opus-4.7
COMPOSER_MODEL: anthropic/claude-opus-4.7
TRIAGE_MODEL: z-ai/glm-5.1
COMPOSER_MODEL: z-ai/glm-5.1
jobs:
analyze-issue:
@@ -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
@@ -102,12 +102,14 @@ jobs:
its API, MCP server, and the bundled `donut-sync` self-hosted server.
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
bugs are in-scope here unless they are obviously upstream Chromium issues.
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
contribute to Camoufox and CANNOT fix bugs in it.
- **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
separate repository. Bugs about Camoufox's *internal* behavior are outside
the scope of this project.
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
https://github.com/daijro/camoufox/issues.
checkbox/radio quirks) are out of scope here. Ask the user to first
search https://github.com/daijro/camoufox/issues for a matching report,
and if they don't find one, to open it there themselves.
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
in-scope here.
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
@@ -146,7 +148,10 @@ jobs:
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
which exact version was the last working one and what changed.
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
behavior. Redirect, do not collect logs.
behavior. Tell the user it's outside the scope of this project and ask
them to search the Camoufox repo and, if no matching issue exists, file
one there. Do NOT say the maintainer doesn't contribute / can't fix it
— keep it strictly about project scope. Do not collect logs.
- **Fork-support request**: asks the maintainer to support an alternative
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
"clear", "reasonable", "well-thought-out", etc.
@@ -159,10 +164,14 @@ jobs:
numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
# Easiest path for the user: Donut → Settings → Advanced → Copy logs
# (puts the latest rotated log on the clipboard). If they prefer to
# attach files directly, the active log is `DonutBrowser.log`; older
# rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-…`).
- macOS: `~/Library/Logs/com.donutbrowser/`
- Linux: `~/.local/share/com.donutbrowser/logs/`
- Windows: `%APPDATA%\com.donutbrowser\logs\`
- macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else)
@@ -338,7 +347,7 @@ jobs:
The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then say this is outside the scope of this project — ask the user to first search https://github.com/daijro/camoufox/issues for a matching report and, if none exists, to open one there themselves. Do NOT phrase it as "the maintainer does not contribute" or anything personal — keep it strictly about scope. Do NOT ask for Donut logs. Stop after that.
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
@@ -352,10 +361,14 @@ jobs:
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
Use ONLY the one matching `triage.operating_system`:
- macos: `~/Library/Logs/com.donutbrowser/`
- linux: `~/.local/share/com.donutbrowser/logs/`
- windows: `%APPDATA%\com.donutbrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\com.donutbrowser\logs`)
Recommend Settings → Advanced → Copy logs first — it bundles the
latest rotated log onto the clipboard without the user hunting for
a directory. If they want to attach files directly, point at the
path that matches `triage.operating_system`. The active log is
always `DonutBrowser.log`; rotated copies sit next to it.
- macos: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
- unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions)
@@ -466,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
@@ -604,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@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
uses: anomalyco/opencode/github@76c631d198f9ff620e15468e45f3457d50481b57 #v1.16.2
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -34,10 +34,10 @@ 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@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
+2 -6
View File
@@ -41,10 +41,10 @@ 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@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -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
+46 -31
View File
@@ -22,6 +22,7 @@ on:
permissions:
contents: read
models: read
jobs:
notify:
@@ -31,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
@@ -105,21 +106,12 @@ jobs:
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Post release announcement to Telegram
- name: Collect commits between previous tag and current tag
id: commits
if: steps.gate.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# Find the previous stable tag (skip the current one) so the
# changelog range is well-defined.
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
@@ -127,29 +119,52 @@ jobs:
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${TAG}" --no-merges > commits.txt
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
version: ${{ steps.tag.outputs.tag }}
file_input: |
commits: ./commits.txt
max-tokens: 1024
# Build a plain bullet list from feat / fix / refactor commits.
# Other commit types (chore, docs, ci, test, deps) are intentionally
# filtered out to keep the channel focused on user-visible changes.
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
;;
esac
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
if [ -z "$CHANGES" ]; then
CHANGES="• See release notes."$'\n'
- name: Post release announcement to Telegram
if: steps.gate.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
AI_RESPONSE: ${{ steps.ai.outputs.response }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# HTML-escape the changelog before injecting into Telegram HTML
# mode — commit messages can legitimately contain `<`, `>`, `&`.
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
# Prefer the file output — `response` can be truncated for longer summaries.
if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
SUMMARY=$(cat "$AI_RESPONSE_FILE")
else
SUMMARY="$AI_RESPONSE"
fi
if [ -z "${SUMMARY//[[:space:]]/}" ]; then
echo "::error::AI summary is empty"
exit 1
fi
# HTML-escape the AI summary before injecting into Telegram HTML mode —
# commit messages can legitimately contain `<`, `>`, `&` and the AI may echo them.
ESCAPED_CHANGES=$(printf '%s' "$SUMMARY" \
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
VERSION="${TAG}"
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
@@ -58,7 +58,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
+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@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
+12 -11
View File
@@ -20,7 +20,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
@@ -105,10 +105,10 @@ 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@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -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
+10 -9
View File
@@ -19,7 +19,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
@@ -104,10 +104,10 @@ 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@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -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@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
+4 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
@@ -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"
+4 -4
View File
@@ -32,10 +32,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -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: |
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
+154 -4
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
@@ -53,6 +51,17 @@ donutbrowser/
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
- 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
@@ -64,11 +73,91 @@ 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 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)
User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BAR", "params": { … } }` strings — never raw English (`format!("Failed to …")`). The frontend resolves the code via `translateBackendError(t, err)` from `src/lib/backend-errors.ts`. Adding a new code requires four parallel edits:
1. Emit the JSON from Rust:
```rust
return Err(serde_json::json!({ "code": "FOO_BAR" }).to_string());
// or with params:
return Err(serde_json::json!({ "code": "FOO_BAR", "params": { "n": "5" } }).to_string());
```
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 nine locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
## Sub-page Dialog mode
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
```tsx
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
Account
</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4">…</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
```
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
### Cross-component tab control
When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky:
```tsx
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
```
Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses.
## Keyboard shortcuts
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 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.
Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut:
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 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.
## Singletons
@@ -93,6 +182,16 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## App data directory naming
`src-tauri/src/app_dirs.rs::app_name()` returns `"DonutBrowserDev"` when `cfg!(debug_assertions)` is true, `"DonutBrowser"` otherwise. So release builds (anything built via `tauri build` / `cargo build --release`) write to:
- macOS — `~/Library/Application Support/DonutBrowser/`
- Linux — `~/.local/share/DonutBrowser/`
- Windows — `%LOCALAPPDATA%\DonutBrowser\`
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
@@ -114,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.
+186
View File
@@ -1,6 +1,192 @@
# 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
- more mcp integrations
### Bug Fixes
- camoufox proxy pid connection
### Refactoring
- browser update
- ui cleanup
- cleanup
### Maintenance
- chore: version bump
- chore: cleanup
- chore: update flake.nix for v0.24.1 [skip ci] (#364)
## v0.24.1 (2026-05-12)
### Refactoring
- creation button disaster recovery
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.0 [skip ci] (#357)
## v0.24.0 (2026-05-12)
### Features
- support latest camoufox
- full ui refresh
### Bug Fixes
- pass correct parameter for dns list selection
### Refactoring
- better error handling and prevention of creating ephemeral password protected profiles
- ui cleanup
- sync cleanup
- proxy spawn
### Maintenance
- chore: version bump
- chore: update dependencies
- chore: fix telegram notifications
- chore: fix issue validation
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
## v0.23.0 (2026-05-10)
### Features
+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
+23 -9
View File
@@ -19,9 +19,6 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
</a>
</p>
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
@@ -29,7 +26,8 @@
## 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
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
@@ -48,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.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:
@@ -58,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.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.23.0/Donut_0.23.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut-0.23.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut-0.23.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.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:
@@ -137,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"/>
@@ -144,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"/>
@@ -151,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]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 508 KiB

+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.23.0";
releaseVersion = "0.26.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.AppImage";
hash = "sha256-bcdZOV1Vj7H9BxlYKUUtGZprrA80283J34xb3NslRjg=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.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.23.0/Donut_0.23.0_aarch64.AppImage";
hash = "sha256-IbGvqHMxwYHFj6dFP07MhFl00aiHVont+KoZck+HIvk=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
}
else
null;
+7 -12
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.0",
"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",
@@ -89,17 +94,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
},
"pnpm": {
"overrides": {
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12",
"fast-xml-parser@<5.7.0": ">=5.7.2",
"fast-uri@<3.1.2": ">=3.1.2",
"fast-xml-builder@<1.2.0": ">=1.2.0"
}
},
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@11.2.2",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+92 -26
View File
@@ -11,6 +11,8 @@ overrides:
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
importers:
@@ -31,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)
@@ -82,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
@@ -97,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)
@@ -112,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)
@@ -149,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
@@ -212,7 +229,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^11.0.21
version: 11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)
version: 11.0.21(@types/node@25.7.0)
'@nestjs/schematics':
specifier: ^11.1.0
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3)
@@ -248,7 +265,7 @@ importers:
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3)))(typescript@6.0.3)
ts-loader:
specifier: ^9.5.7
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0))
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
@@ -1671,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:
@@ -2060,6 +2090,7 @@ packages:
'@smithy/core@3.24.1':
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
engines: {node: '>=18.0.0'}
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
'@smithy/credential-provider-imds@4.3.1':
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
@@ -2480,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==}
@@ -3009,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'}
@@ -3872,9 +3909,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4282,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==}
@@ -4401,8 +4447,8 @@ packages:
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
radix-ui@1.4.3:
@@ -6421,7 +6467,7 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@nestjs/cli@11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)':
'@nestjs/cli@11.0.21(@types/node@25.7.0)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
@@ -6432,14 +6478,14 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0))
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0)
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- '@minify-html/node'
@@ -6999,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)
@@ -7819,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
@@ -8125,7 +8183,7 @@ snapshots:
'@types/js-cookie': 3.0.6
dayjs: 1.11.20
intersection-observer: 0.12.2
js-cookie: 3.0.5
js-cookie: 3.0.7
lodash: 4.18.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
@@ -8295,7 +8353,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.1
qs: 6.15.2
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -8369,6 +8427,8 @@ snapshots:
caniuse-lite@1.0.30001792: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -8733,7 +8793,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.1
qs: 6.15.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -8804,7 +8864,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0)):
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -8819,7 +8879,7 @@ snapshots:
semver: 7.8.0
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
form-data@4.0.5:
dependencies:
@@ -9382,7 +9442,7 @@ snapshots:
jiti@2.7.0: {}
js-cookie@3.0.5: {}
js-cookie@3.0.7: {}
js-tokens@4.0.0: {}
@@ -9723,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
@@ -9834,7 +9902,7 @@ snapshots:
pure-rand@7.0.1: {}
qs@6.15.1:
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -10294,7 +10362,7 @@ snapshots:
formidable: 3.5.4
methods: 1.1.2
mime: 2.6.0
qs: 6.15.1
qs: 6.15.2
transitivePeerDependencies:
- supports-color
@@ -10330,15 +10398,13 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.11.0
terser-webpack-plugin@5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0)):
terser-webpack-plugin@5.6.0(webpack@5.106.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.47.1
webpack: 5.106.0(lightningcss@1.32.0)
optionalDependencies:
lightningcss: 1.32.0
webpack: 5.106.0
terser@5.47.1:
dependencies:
@@ -10391,7 +10457,7 @@ snapshots:
babel-jest: 30.4.1(@babel/core@7.29.0)
jest-util: 30.4.1
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0)):
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0):
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.21.3
@@ -10399,7 +10465,7 @@ snapshots:
semver: 7.8.0
source-map: 0.7.6
typescript: 6.0.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
dependencies:
@@ -10588,7 +10654,7 @@ snapshots:
webpack-sources@3.4.1: {}
webpack@5.106.0(lightningcss@1.32.0):
webpack@5.106.0:
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -10612,7 +10678,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0))
terser-webpack-plugin: 5.6.0(webpack@5.106.0)
watchpack: 2.5.1
webpack-sources: 3.4.1
transitivePeerDependencies:
+22
View File
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
- sharp
- sqlite3
- unrs-resolver
# Husky and lint-staged shell out to pnpm without a TTY, so the interactive
# "purge modules dir?" prompt errors out (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY)
# and aborts the commit. Skipping the prompt lets the hook proceed.
confirmModulesPurge: false
# Pinned for security. Moved from package.json#pnpm.overrides — pnpm 11
# no longer reads that field; settings live here now.
overrides:
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.10: '>=8.5.12'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
allowBuilds:
'@nestjs/core': true
sharp: true
unrs-resolver: true
+175 -209
View File
@@ -31,11 +31,11 @@ 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.1",
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.0",
]
@@ -445,9 +445,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "av-scenechange"
@@ -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",
@@ -785,15 +785,15 @@ dependencies = [
[[package]]
name = "built"
version = "0.8.0"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byte-unit"
@@ -962,18 +962,18 @@ dependencies = [
[[package]]
name = "cbc"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.1",
"cipher 0.5.2",
]
[[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",
@@ -1103,11 +1103,11 @@ dependencies = [
[[package]]
name = "cipher"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.1",
"crypto-common 0.2.2",
"inout 0.2.2",
]
@@ -1405,9 +1405,9 @@ dependencies = [
[[package]]
name = "crypto-common"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
@@ -1679,7 +1679,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.1",
"crypto-common 0.2.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.0"
version = "0.26.0"
dependencies = [
"aes 0.9.0",
"aes 0.9.1",
"aes-gcm",
"argon2",
"async-socks5",
@@ -1824,10 +1824,10 @@ dependencies = [
"objc2-app-kit",
"once_cell",
"playwright",
"quick-xml",
"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",
@@ -1858,9 +1858,9 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 1.1.2+spec-1.1.0",
"tower",
"tower-http",
"tray-icon 0.24.0",
"url",
"urlencoding",
"utoipa",
@@ -1961,9 +1961,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "embed-resource"
@@ -2212,9 +2212,9 @@ dependencies = [
[[package]]
name = "filetime"
version = "0.2.28"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
@@ -2863,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]]
@@ -2937,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",
@@ -2997,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",
@@ -3086,7 +3089,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@@ -3430,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",
@@ -3443,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",
@@ -3553,12 +3556,13 @@ dependencies = [
[[package]]
name = "kurbo"
version = "0.13.0"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"smallvec",
]
@@ -3621,9 +3625,9 @@ dependencies = [
[[package]]
name = "libfuzzer-sys"
version = "0.4.12"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
@@ -3647,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"
@@ -3707,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",
]
@@ -3818,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"
@@ -3868,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",
@@ -3913,15 +3898,14 @@ dependencies = [
[[package]]
name = "muda"
version = "0.19.1"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
dependencies = [
"crossbeam-channel",
"dpi",
"gtk",
"keyboard-types",
"libxdo",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -4037,9 +4021,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
@@ -4381,9 +4365,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.4"
version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [
"dunce",
"is-wsl",
@@ -4393,9 +4377,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.79"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
@@ -4424,9 +4408,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.115"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
@@ -4659,18 +4643,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.12"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.12"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
@@ -4741,7 +4725,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -4797,6 +4781,15 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -5013,6 +5006,15 @@ name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
dependencies = [
"memchr",
"serde",
@@ -5325,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",
@@ -5488,9 +5490,9 @@ dependencies = [
[[package]]
name = "rsqlite-vfs"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
@@ -5498,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",
@@ -5638,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"
@@ -5713,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"
@@ -5853,9 +5840,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -5918,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",
@@ -5938,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",
@@ -5963,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",
@@ -6113,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"
@@ -6227,9 +6213,9 @@ 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.61.2",
@@ -6304,9 +6290,9 @@ dependencies = [
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
@@ -6448,9 +6434,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.39.1"
version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
"libc",
"memchr",
@@ -6497,9 +6483,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.35.2"
version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.11.1",
"block2",
@@ -6554,9 +6540,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -6571,9 +6557,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [
"anyhow",
"bytes",
@@ -6586,6 +6572,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -6599,7 +6586,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.3",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -6612,7 +6599,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon 0.23.1",
"tray-icon",
"url",
"webkit2gtk",
"webview2-com",
@@ -6622,9 +6609,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
dependencies = [
"anyhow",
"cargo_toml",
@@ -6643,9 +6630,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -6670,9 +6657,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -6684,9 +6671,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
dependencies = [
"anyhow",
"glob",
@@ -6873,9 +6860,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
dependencies = [
"cookie",
"dpi",
@@ -6898,9 +6885,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [
"gtk",
"http",
@@ -6924,9 +6911,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
dependencies = [
"anyhow",
"brotli",
@@ -7383,9 +7370,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.10"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.11.1",
"bytes",
@@ -7486,27 +7473,6 @@ dependencies = [
"windows-sys 0.61.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.61.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
@@ -7564,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"
@@ -7664,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"
@@ -7824,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",
@@ -8091,7 +8057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.39.4",
"quote",
]
@@ -9040,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",
@@ -9063,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",
@@ -9098,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",
@@ -9124,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",
@@ -9144,9 +9110,9 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
@@ -9331,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",
@@ -9345,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",
@@ -9358,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",
+9 -13
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.0"
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,21 +95,20 @@ 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"
regex-lite = "0.1"
tempfile = "3"
maxminddb = "0.28"
quick-xml = { version = "0.39", features = ["serialize"] }
quick-xml = { version = "0.40", features = ["serialize"] }
# VPN support
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"
@@ -144,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"
+288 -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>,
@@ -87,6 +100,8 @@ pub struct UpdateProfileRequest {
pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
}
#[derive(Clone)]
@@ -215,6 +230,20 @@ struct OpenUrlRequest {
url: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct ImportCookiesRequest {
/// Raw cookie file content. Format is auto-detected: a JSON array
/// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
content: String,
}
#[derive(Debug, Serialize, ToSchema)]
struct ImportCookiesResponse {
cookies_imported: usize,
cookies_replaced: usize,
errors: Vec<String>,
}
#[derive(OpenApi)]
#[openapi(
paths(
@@ -226,6 +255,7 @@ struct OpenUrlRequest {
run_profile,
open_url_in_profile,
kill_profile,
import_profile_cookies,
get_groups,
get_group,
create_group,
@@ -268,6 +298,8 @@ struct OpenUrlRequest {
RunProfileResponse,
RunProfileRequest,
OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
ProxySettings,
)),
tags(
@@ -277,6 +309,7 @@ struct OpenUrlRequest {
(name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"),
(name = "browsers", description = "Browser management endpoints"),
(name = "cookies", description = "Cookie management endpoints"),
),
modifiers(&SecurityAddon),
)]
@@ -363,6 +396,7 @@ impl ApiServer {
.routes(routes!(run_profile))
.routes(routes!(open_url_in_profile))
.routes(routes!(kill_profile))
.routes(routes!(import_profile_cookies))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_tags))
@@ -385,22 +419,23 @@ 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",
get(move || async move { Json(api_for_v1) }),
)
// Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers.
@@ -490,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);
}
@@ -532,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()));
@@ -568,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,
@@ -598,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
@@ -655,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
@@ -674,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(
@@ -695,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()
@@ -709,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(),
@@ -758,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,
@@ -866,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) => {
@@ -929,6 +1041,15 @@ async fn update_profile(
}
}
if let Some(sync_mode) = request.sync_mode {
if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode)
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
// Return updated profile
get_profile(Path(id), State(state)).await
}
@@ -1675,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);
@@ -1715,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
{
@@ -1759,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);
@@ -1785,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")
),
@@ -1797,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()
@@ -1818,6 +1951,77 @@ async fn kill_profile(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/cookies/import",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = ImportCookiesRequest,
responses(
(status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
(status = 400, description = "Invalid cookie file or unsupported browser"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Browser is currently running"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn import_profile_cookies(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<ImportCookiesRequest>,
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !profiles.iter().any(|p| p.id.to_string() == id) {
return Err(StatusCode::NOT_FOUND);
}
match crate::cookie_manager::CookieManager::import_cookies(
&state.app_handle,
&id,
&request.content,
)
.await
{
Ok(result) => {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
if profile.is_sync_enabled() {
let pid = id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
Ok(Json(ImportCookiesResponse {
cookies_imported: result.cookies_imported,
cookies_replaced: result.cookies_replaced,
errors: result.errors,
}))
}
Err(e) => {
let msg = e.to_lowercase();
if msg.contains("running") {
Err(StatusCode::CONFLICT)
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
Err(StatusCode::BAD_REQUEST)
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
// API Handler - Download Browser
#[utoipa::path(
post,
@@ -1961,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);
+76 -278
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(
@@ -1582,7 +1464,10 @@ impl BrowserRunner {
}
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -1924,7 +1809,10 @@ impl BrowserRunner {
}
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -2389,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: {})",
@@ -2418,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
@@ -2442,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
@@ -2704,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,
+6 -5
View File
@@ -376,11 +376,12 @@ impl CamoufoxConfigBuilder {
(config, target_os)
};
// Add random window history length
config.insert(
"window.history.length".to_string(),
serde_json::json!(rng.random_range(1..=5)),
);
// Note: we used to spoof `window.history.length` to a random value in
// [1, 5] here. Newer Camoufox builds clamp the docShell session history
// to this value, which disables the toolbar back/forward buttons when
// the spoof rolls a small number. The fingerprint value drifts on every
// user navigation anyway, so a constant spoof is detectable and not
// worth the broken navigation UX.
// Add fonts
if !self.custom_fonts_only {
+152 -46
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 {
@@ -222,10 +224,16 @@ impl CamoufoxManager {
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Parse the fingerprint config JSON
let fingerprint_config: HashMap<String, serde_json::Value> =
let mut fingerprint_config: HashMap<String, serde_json::Value> =
serde_json::from_str(&custom_config)
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
// Strip `window.history.length` even when present in a previously-saved
// fingerprint. Newer Camoufox clamps the docShell session history to the
// spoofed value, which disables the toolbar back/forward buttons. See
// the matching note in camoufox/config.rs.
fingerprint_config.remove("window.history.length");
// Convert to environment variables using CAMOU_CONFIG chunking
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
@@ -243,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
@@ -264,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 {
@@ -287,7 +318,7 @@ impl CamoufoxManager {
}
}
let child = command
let mut child = command
.spawn()
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
@@ -296,6 +327,34 @@ impl CamoufoxManager {
log::info!("Camoufox launched with PID: {:?}", process_id);
// Watch the child so its exit status (signal / non-zero code) lands in
// the log. Without this, all we see is "PID X is no longer running" via
// the periodic sysinfo poll, with no clue why it died.
let watch_profile_path = profile_path.to_string();
tokio::spawn(async move {
match child.wait().await {
Ok(status) => {
if status.success() {
log::info!(
"Camoufox PID {:?} for {} exited cleanly (status=0)",
process_id,
watch_profile_path
);
} else {
log::warn!(
"Camoufox PID {:?} for {} exited abnormally: {}",
process_id,
watch_profile_path,
status
);
}
}
Err(e) => {
log::warn!("Failed to await Camoufox PID {:?} exit: {}", process_id, e);
}
}
});
// Store the instance
let instance = CamoufoxInstance {
id: instance_id.clone(),
@@ -557,28 +616,28 @@ impl CamoufoxManager {
for (id, instance) in inner.instances.iter() {
if let Some(process_id) = instance.process_id {
// Check if the process is still alive
if !self.is_server_running(process_id).await {
// Process is dead
// Camoufox instance is no longer running
log::info!(
"Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
id,
process_id,
instance.profile_path
);
dead_instances.push(id.clone());
instances_to_remove.push(id.clone());
}
} else {
// No process_id means it's likely a dead instance
// Camoufox instance has no PID, marking as dead
log::info!("Camoufox instance {} has no PID, marking as dead", id);
dead_instances.push(id.clone());
instances_to_remove.push(id.clone());
}
}
}
// Remove dead instances
if !instances_to_remove.is_empty() {
let mut inner = self.inner.lock().await;
for id in &instances_to_remove {
inner.instances.remove(id);
// Removed dead Camoufox instance
}
}
@@ -612,6 +671,7 @@ impl CamoufoxManager {
}
impl CamoufoxManager {
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox_profile(
&self,
app_handle: AppHandle,
@@ -619,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
@@ -662,54 +723,98 @@ impl CamoufoxManager {
}
}
// Write explicit proxy prefs to user.js so Firefox always uses the local
// donut-proxy and never falls back to stale proxy settings baked into prefs.js
// from a previous session. user.js values override prefs.js on every launch.
if let Some(proxy_str) = &config.proxy {
// Patch user.js with Camoufox-specific overrides on every launch. This
// always runs (not gated on the proxy being set) because Camoufox's
// bundled camoufox.cfg ships defaults that break basic browser features
// and we need to override them per-profile.
{
let user_js_path = profile_path.join("user.js");
let mut prefs = String::new();
// Preserve existing user.js content (ephemeral prefs, etc.)
// Preserve existing user.js lines, but strip any keys we're about to
// 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",
"browser.sessionhistory.max_total_viewers",
];
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
// Strip old proxy prefs so we don't duplicate
for line in existing.lines() {
if !line.contains("network.proxy.") {
if !managed_keys.iter().any(|k| line.contains(k)) {
prefs.push_str(line);
prefs.push('\n');
}
}
}
if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
// Camoufox's bundled camoufox.cfg sets these to 0, which makes
// docShell remember zero prior pages and leaves the toolbar
// back/forward buttons permanently disabled no matter how much
// the user navigates. Restore Firefox defaults.
prefs.push_str(
"user_pref(\"browser.sessionhistory.max_entries\", 50);\n\
user_pref(\"browser.sessionhistory.max_total_viewers\", -1);\n",
);
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
// Required for sideloaded extensions:
// - signatures.required=false accepts unsigned .xpi (Camoufox is built
// without MOZ_REQUIRE_SIGNING so this is honored).
// - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly
// dropped .xpi files in <profile>/extensions/ get registered.
prefs.push_str(
"user_pref(\"xpinstall.signatures.required\", false);\n\
user_pref(\"extensions.startupScanScopes\", 1);\n",
);
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write proxy prefs to user.js: {e}");
// 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");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
}
}
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write user.js: {e}");
}
}
self
@@ -719,6 +824,7 @@ impl CamoufoxManager {
&profile_path_str,
&config,
url.as_deref(),
remote_debugging_port,
headless,
)
.await
+218 -30
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,30 +1377,39 @@ 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]
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?;
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
// Always clear the stored sync URL and token on cloud logout. While the
// user was signed in, the cloud auth flow populated these with the hosted
// sync server's URL + a server-issued token — leaving them in place would
// pre-fill the Self-Hosted tab with our production URL and a token the
// user never typed. The cloud-URL-only check we used to do here missed
// trailing-slash / scheme variants and any future cloud endpoint moves.
let manager = crate::settings_manager::SettingsManager::instance();
if let Ok(sync_settings) = manager.get_sync_settings() {
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
let _ = manager.save_sync_server_url(None);
}
}
let _ = manager.save_sync_server_url(None);
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
-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");
}
+94 -21
View File
@@ -290,24 +290,45 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
// For each browser where every registered version would be removed (no
// profile uses any), keep the newest one by semver. Without this, the
// version preserved depends on HashMap iteration order, so a freshly
// downloaded version can be deleted in favor of an older orphan — leaving
// the UI stuck on "needs to be downloaded".
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
let mut removal_versions_by_browser: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
for (browser, version) in &to_remove {
removal_versions_by_browser
.entry(browser.clone())
.or_default()
.push(version.clone());
}
to_remove.retain(|(browser, version)| {
let mut keep_per_browser: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (browser, versions) in &removal_versions_by_browser {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
if versions.len() >= total {
if let Some(latest) = versions
.iter()
.max_by(|a, b| crate::api_client::compare_versions(a, b))
{
keep_per_browser.insert(browser.clone(), latest.clone());
}
}
}
drop(data);
to_remove.retain(|(browser, version)| {
if keep_per_browser
.get(browser)
.is_some_and(|keep| keep == version)
{
log::info!("Keeping latest available version: {browser} {version}");
return false;
}
true
@@ -1275,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
+99 -37
View File
@@ -27,6 +27,11 @@ pub struct Extension {
pub author: Option<String>,
#[serde(default)]
pub homepage_url: Option<String>,
/// Firefox extension ID from `browser_specific_settings.gecko.id` (or
/// `applications.gecko.id` in old manifests). Firefox refuses to load a
/// sideloaded .xpi unless the filename matches this value.
#[serde(default)]
pub gecko_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
(name, version, description, author, homepage_url)
}
/// Read `browser_specific_settings.gecko.id` (or the legacy
/// `applications.gecko.id`) from the extension's manifest.json. Firefox uses
/// this value as the canonical add-on ID; sideloaded .xpi files must be named
/// `<gecko_id>.xpi` to be picked up.
fn extract_gecko_id(file_data: &[u8], file_type: &str) -> Option<String> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut manifest_content = String::new();
std::io::Read::read_to_string(
&mut archive.by_name("manifest.json").ok()?,
&mut manifest_content,
)
.ok()?;
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
manifest
.pointer("/browser_specific_settings/gecko/id")
.or_else(|| manifest.pointer("/applications/gecko/id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
@@ -285,6 +316,7 @@ impl ExtensionManager {
name
};
let gecko_id = extract_gecko_id(&file_data, &file_type);
let ext = Extension {
id: uuid::Uuid::new_v4().to_string(),
name: final_name,
@@ -299,6 +331,7 @@ impl ExtensionManager {
description,
author,
homepage_url,
gecko_id,
};
let file_dir = self.get_file_dir(&ext.id);
@@ -415,6 +448,7 @@ impl ExtensionManager {
ext.name = mn;
}
}
ext.gecko_id = extract_gecko_id(&data, &new_file_type);
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
@@ -893,24 +927,33 @@ impl ExtensionManager {
continue;
}
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
if src_file.exists() {
// Firefox expects .xpi files in extensions dir
let dest_name = if ext.file_type == "zip" {
format!(
"{}.xpi",
ext
.file_name
.rsplit('.')
.next_back()
.unwrap_or(&ext.file_name)
)
} else {
ext.file_name.clone()
};
let dest = extensions_dir.join(&dest_name);
fs::copy(&src_file, &dest)?;
extension_paths.push(dest.to_string_lossy().to_string());
if !src_file.exists() {
continue;
}
// Firefox/Camoufox only loads sideloaded .xpi files whose filename
// matches `browser_specific_settings.gecko.id` from the manifest.
// Prefer the cached value; fall back to reading the manifest now
// for extensions added before the field existed.
let gecko_id = if let Some(ref id) = ext.gecko_id {
Some(id.clone())
} else if let Ok(data) = fs::read(&src_file) {
extract_gecko_id(&data, &ext.file_type)
} else {
None
};
let Some(gecko_id) = gecko_id else {
log::warn!(
"Skipping Firefox extension '{}': could not determine gecko id from manifest.json",
ext.name
);
continue;
};
let dest = extensions_dir.join(format!("{gecko_id}.xpi"));
fs::copy(&src_file, &dest)?;
extension_paths.push(dest.to_string_lossy().to_string());
}
}
}
@@ -1022,30 +1065,49 @@ impl ExtensionManager {
}
}
if ext.version.is_none() && ext.description.is_none() {
let needs_meta_backfill = ext.version.is_none() && ext.description.is_none();
let needs_gecko_backfill =
ext.gecko_id.is_none() && ext.browser_compatibility.iter().any(|b| b == "firefox");
if needs_meta_backfill || needs_gecko_backfill {
let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) {
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &ext.file_type);
if version.is_some()
|| description.is_some()
|| author.is_some()
|| homepage_url.is_some()
|| manifest_name.is_some()
{
let mut updated_ext = ext.clone();
if let Some(v) = version {
updated_ext.version = Some(v);
let mut updated_ext = ext.clone();
let mut changed = false;
if needs_meta_backfill {
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &ext.file_type);
if version.is_some()
|| description.is_some()
|| author.is_some()
|| homepage_url.is_some()
|| manifest_name.is_some()
{
if let Some(v) = version {
updated_ext.version = Some(v);
}
if let Some(d) = description {
updated_ext.description = Some(d);
}
if let Some(a) = author {
updated_ext.author = Some(a);
}
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
}
changed = true;
}
if let Some(d) = description {
updated_ext.description = Some(d);
}
if let Some(a) = author {
updated_ext.author = Some(a);
}
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
}
if needs_gecko_backfill {
if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) {
updated_ext.gecko_id = Some(gid);
changed = true;
}
}
if changed {
let metadata_path = self.get_metadata_path(&ext.id);
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
let _ = fs::write(metadata_path, json);
+11 -13
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());
}
@@ -268,7 +276,9 @@ impl GroupManager {
}
}
// Create result including all groups (even those with 0 count)
// Create result including all groups (even those with 0 count).
// The "Default" pseudo-group is intentionally not returned: profiles
// without a group_id are surfaced through the "All" filter instead.
let mut result = Vec::new();
for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0);
@@ -281,18 +291,6 @@ impl GroupManager {
});
}
// Add default group count (profiles without group_id), always include even if 0
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
sync_enabled: false,
last_sync: None,
};
// Insert at the beginning for consistent ordering with UI expectations
result.insert(0, default_group);
Ok(result)
}
}
+287 -124
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,12 +52,8 @@ 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;
mod tag_manager;
mod team_lock;
@@ -74,7 +76,7 @@ use profile::manager::{
use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile,
set_profile_password, unlock_profile, verify_profile_password,
};
use browser_version_manager::{
@@ -91,18 +93,19 @@ 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::{
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
verify_e2e_password,
};
use tag_manager::get_all_tags;
@@ -188,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}");
@@ -503,20 +507,20 @@ fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
}
}
#[tauri::command]
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
Ok(dir.join("manifest.json").exists())
fn is_mcp_in_claude_desktop_internal() -> bool {
let Some(dir) = claude_desktop_extension_dir() else {
return false;
};
dir.join("manifest.json").exists()
}
#[tauri::command]
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
@@ -605,8 +609,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
Ok(())
}
#[tauri::command]
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
if ext_dir.exists() {
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
@@ -668,91 +671,48 @@ fn update_claude_extensions_registry(
Ok(())
}
fn find_claude_cli() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = vec![
std::path::PathBuf::from("/usr/local/bin/claude"),
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
];
if let Some(home) = dirs::home_dir() {
candidates.insert(0, home.join(".local/bin/claude"));
candidates.push(home.join(".claude/local/claude"));
}
#[cfg(windows)]
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.insert(
0,
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
);
}
for p in &candidates {
if p.exists() {
return Some(p.clone());
}
}
None
}
#[tauri::command]
async fn is_mcp_in_claude_code() -> Result<bool, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
// `claude mcp list` health-checks every registered MCP server, so a
// missing or stalled server can hang the call for many seconds. Cap it
// — for this dialog, a slow `claude` is treated the same as "not registered".
let fut = tokio::process::Command::new(&cli)
.args(["mcp", "list"])
.output();
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.map_err(|_| "claude mcp list timed out".to_string())?
.map_err(|e| format!("Failed to run claude: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.contains("donut-browser"))
}
#[tauri::command]
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
let _ = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output();
let output = std::process::Command::new(&cli)
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
}
Ok(())
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
}
#[tauri::command]
fn remove_mcp_from_claude_code() -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let output = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
Ok(mcp_integrations::list_agents_with_status(&[(
"claude-desktop",
claude_desktop_connected,
)]))
}
#[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
Ok(())
if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
}
#[tauri::command]
@@ -969,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}"))?;
@@ -1054,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)
@@ -1162,6 +1175,7 @@ async fn generate_sample_fingerprint(
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
if browser == "camoufox" {
@@ -1187,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();
@@ -1200,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.
@@ -1260,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())
@@ -1285,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")]
{
@@ -1822,6 +1978,19 @@ pub fn run() {
);
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
// Must run BEFORE `mark_profile_stopped` because that
// releases any queued sync run, and a sync that picks up
// the on-disk dir before re-encryption finishes uploads
// the previous snapshot (issue: encrypted profiles not
// syncing fresh data).
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit_and_wait(&profile)
.await;
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
@@ -1832,13 +2001,6 @@ pub fn run() {
}
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit(&profile);
}
last_running_states.insert(profile_id, is_running);
} else {
// Update the state even if unchanged to ensure we have it tracked
@@ -1990,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,
@@ -2020,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,
@@ -2093,6 +2257,7 @@ pub fn run() {
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync,
set_proxy_sync_enabled,
set_group_sync_enabled,
@@ -2106,6 +2271,7 @@ pub fn run() {
enable_sync_for_all_entities,
set_e2e_password,
check_has_e2e_password,
verify_e2e_password,
delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies,
@@ -2123,12 +2289,9 @@ pub fn run() {
stop_mcp_server,
get_mcp_server_status,
get_mcp_config,
is_mcp_in_claude_desktop,
add_mcp_to_claude_desktop,
remove_mcp_from_claude_desktop,
is_mcp_in_claude_code,
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
list_mcp_agents,
add_mcp_to_agent,
remove_mcp_from_agent,
// VPN commands
import_vpn_config,
list_vpn_configs,
@@ -2141,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,
@@ -2171,6 +2333,7 @@ pub fn run() {
set_profile_password,
change_profile_password,
remove_profile_password,
verify_profile_password,
unlock_profile,
lock_profile,
is_profile_locked,
+574
View File
@@ -0,0 +1,574 @@
// MCP client integrations — installs/removes the donut-browser MCP server in
// 14 popular AI assistant clients. Ports the add-mcp registry to Rust.
//
// Claude Desktop is managed via Claude's local extensions bundle
// (manifest.json + node bridge), since the desktop app supports only stdio
// servers via its plain JSON config but exposes HTTP through the extension
// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other
// agents (including Claude Code) use the generic config-file installer here.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const SERVER_NAME: &str = "donut-browser";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum AgentCategory {
DesktopApp,
Cli,
Editor,
EditorExt,
}
#[derive(Debug, Clone, Copy)]
enum ConfigFormat {
Json,
Toml,
Yaml,
}
#[derive(Debug, Clone)]
struct AgentSpec {
id: &'static str,
display_name: &'static str,
category: AgentCategory,
/// Top-level key (supports dot notation) where the server is written.
config_key: &'static str,
format: ConfigFormat,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct McpAgentInfo {
pub id: String,
pub display_name: String,
pub category: AgentCategory,
pub connected: bool,
/// True when the underlying client appears to be installed on the system
/// (its config directory exists), regardless of whether we have installed
/// the donut-browser server into it.
pub detected: bool,
}
fn home() -> Option<PathBuf> {
dirs::home_dir()
}
#[cfg(target_os = "macos")]
fn vscode_user_dir() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Code")
.join("User")
})
}
#[cfg(target_os = "windows")]
fn vscode_user_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Code").join("User"))
}
#[cfg(target_os = "linux")]
fn vscode_user_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Code").join("User"))
}
#[cfg(target_os = "macos")]
fn zed_config_dir() -> Option<PathBuf> {
home().map(|h| h.join("Library").join("Application Support").join("Zed"))
}
#[cfg(target_os = "windows")]
fn zed_config_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Zed"))
}
#[cfg(target_os = "linux")]
fn zed_config_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("zed"))
}
#[cfg(target_os = "windows")]
fn goose_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Block")
.join("goose")
.join("config")
.join("config.yaml")
})
}
#[cfg(not(target_os = "windows"))]
fn goose_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("goose").join("config.yaml"))
}
/// Resolve the global config path for an agent. Returns `None` on unsupported
/// platforms (none currently — every supported agent has a defined path on
/// macOS/Linux/Windows).
fn config_path_for(agent_id: &str) -> Option<PathBuf> {
let h = home()?;
match agent_id {
"antigravity" => Some(
h.join(".gemini")
.join("antigravity")
.join("mcp_config.json"),
),
"cline" => vscode_user_dir().map(|d| {
d.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json")
}),
"cline-cli" => {
let base = std::env::var("CLINE_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".cline"));
Some(
base
.join("data")
.join("settings")
.join("cline_mcp_settings.json"),
)
}
"claude-code" => Some(h.join(".claude.json")),
"claude-desktop" => claude_desktop_config_path(),
"codex" => {
let base = std::env::var("CODEX_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".codex"));
Some(base.join("config.toml"))
}
"cursor" => Some(h.join(".cursor").join("mcp.json")),
"gemini-cli" => Some(h.join(".gemini").join("settings.json")),
"goose" => goose_config_path(),
"github-copilot-cli" => Some(
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".copilot"))
.join("mcp-config.json"),
),
"mcporter" => {
// add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back
// to mcporter.jsonc if it already exists, else default to mcporter.json.
let dir = h.join(".mcporter");
let json_path = dir.join("mcporter.json");
let jsonc_path = dir.join("mcporter.jsonc");
if json_path.exists() {
Some(json_path)
} else if jsonc_path.exists() {
Some(jsonc_path)
} else {
Some(json_path)
}
}
"opencode" => Some(h.join(".config").join("opencode").join("opencode.json")),
"vscode" => vscode_user_dir().map(|d| d.join("mcp.json")),
"zed" => zed_config_dir().map(|d| d.join("settings.json")),
_ => None,
}
}
#[cfg(target_os = "macos")]
fn claude_desktop_config_path() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "windows")]
fn claude_desktop_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "linux")]
fn claude_desktop_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Claude").join("claude_desktop_config.json"))
}
const AGENT_SPECS: &[AgentSpec] = &[
AgentSpec {
id: "claude-desktop",
display_name: "Claude Desktop",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "claude-code",
display_name: "Claude Code",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cursor",
display_name: "Cursor",
category: AgentCategory::Editor,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "vscode",
display_name: "VS Code",
category: AgentCategory::Editor,
config_key: "servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "zed",
display_name: "Zed",
category: AgentCategory::Editor,
config_key: "context_servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline-cli",
display_name: "Cline CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline",
display_name: "Cline VSCode",
category: AgentCategory::EditorExt,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "codex",
display_name: "Codex",
category: AgentCategory::Cli,
config_key: "mcp_servers",
format: ConfigFormat::Toml,
},
AgentSpec {
id: "gemini-cli",
display_name: "Gemini CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "github-copilot-cli",
display_name: "GitHub Copilot CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "goose",
display_name: "Goose",
category: AgentCategory::Cli,
config_key: "extensions",
format: ConfigFormat::Yaml,
},
AgentSpec {
id: "antigravity",
display_name: "Antigravity",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "opencode",
display_name: "OpenCode",
category: AgentCategory::Cli,
config_key: "mcp",
format: ConfigFormat::Json,
},
AgentSpec {
id: "mcporter",
display_name: "MCPorter",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
];
fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> {
AGENT_SPECS.iter().find(|s| s.id == agent_id)
}
fn detect_agent_directory(agent_id: &str) -> bool {
// Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate
// parent of the config file. Used only for UI annotation; install/uninstall
// always operates on the resolved config path.
let Some(h) = home() else {
return false;
};
match agent_id {
"antigravity" => h.join(".gemini").exists(),
"cline" => config_path_for("cline")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"cline-cli" => config_path_for("cline-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"claude-code" => h.join(".claude").exists(),
"claude-desktop" => claude_desktop_config_path()
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"codex" => h.join(".codex").exists(),
"cursor" => h.join(".cursor").exists(),
"gemini-cli" => h.join(".gemini").exists(),
"github-copilot-cli" => config_path_for("github-copilot-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"goose" => goose_config_path().is_some_and(|p| p.exists()),
"mcporter" => h.join(".mcporter").exists(),
"opencode" => h.join(".config").join("opencode").exists(),
"vscode" => vscode_user_dir().is_some_and(|d| d.exists()),
"zed" => zed_config_dir().is_some_and(|d| d.exists()),
_ => false,
}
}
/// Transform the donut-browser HTTP server config into the per-agent shape.
/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge
/// (handled by the extension installer in lib.rs).
fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value {
use serde_json::json;
match agent_id {
"zed" => json!({ "source": "custom", "type": "http", "url": url }),
"opencode" => json!({ "type": "remote", "url": url, "enabled": true }),
"antigravity" => json!({ "serverUrl": url }),
"cursor" => json!({ "url": url }),
"cline" | "cline-cli" => json!({
"url": url,
"type": "streamableHttp",
"disabled": false,
}),
"codex" => json!({ "type": "http", "url": url }),
"github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }),
"goose" => json!({
"name": SERVER_NAME,
"description": "",
"type": "streamable_http",
"uri": url,
"headers": {},
"enabled": true,
"timeout": 300,
}),
"vscode" => json!({ "type": "http", "url": url }),
// claude-code, claude-desktop, gemini-cli, mcporter — passthrough
_ => json!({ "type": "http", "url": url }),
}
}
/// Detect whether a server config object looks like our donut-browser HTTP
/// endpoint by URL prefix. Matches across the various per-agent key shapes
/// (`url`, `uri`, `serverUrl`).
fn config_matches_donut(value: &serde_json::Value) -> bool {
for key in ["url", "uri", "serverUrl"] {
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if s.contains("/mcp/")
&& (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost"))
{
return true;
}
}
}
false
}
fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value {
let Ok(content) = fs::read_to_string(path) else {
return serde_json::Value::Null;
};
match format {
ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null),
ConfigFormat::Toml => toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|t| serde_json::to_value(t).ok())
.unwrap_or(serde_json::Value::Null),
ConfigFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(&content)
.ok()
.and_then(|y| serde_json::to_value(y).ok())
.unwrap_or(serde_json::Value::Null),
}
}
fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = match format {
ConfigFormat::Json => {
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))?
}
ConfigFormat::Toml => {
let toml_val: toml::Value = serde_json::from_value(value.clone())
.map_err(|e| format!("Failed to convert to TOML: {e}"))?;
toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))?
}
ConfigFormat::Yaml => {
let yaml_val: serde_yaml::Value = serde_yaml::from_str(
&serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?,
)
.map_err(|e| format!("Failed to convert to YAML: {e}"))?;
serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))?
}
};
fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
/// Navigate `config_key` (dot notation), creating object literals at each
/// missing level. Returns a mutable reference to the bottom container so the
/// caller can set/remove server entries.
fn ensure_nested_object<'a>(
root: &'a mut serde_json::Value,
config_key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !root.is_object() {
*root = serde_json::Value::Object(serde_json::Map::new());
}
let mut current = root.as_object_mut().expect("just set to object");
let parts: Vec<&str> = config_key.split('.').collect();
for part in &parts {
let entry = current
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
current = entry.as_object_mut().expect("just ensured object");
}
current
}
fn nested_object<'a>(
root: &'a serde_json::Value,
config_key: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let mut current = root.as_object()?;
for part in config_key.split('.') {
current = current.get(part)?.as_object()?;
}
Some(current)
}
fn is_generic_agent_connected(agent_id: &str) -> bool {
let Some(spec) = spec_for(agent_id) else {
return false;
};
let Some(path) = config_path_for(agent_id) else {
return false;
};
if !path.exists() {
return false;
}
let root = read_value(&path, spec.format);
let Some(servers) = nested_object(&root, spec.config_key) else {
return false;
};
if let Some(entry) = servers.get(SERVER_NAME) {
return config_matches_donut(entry);
}
servers.values().any(config_matches_donut)
}
/// Install or remove the donut-browser entry from a generic agent. Returns
/// `true` if a write happened. Callers handle higher-level dispatch (Claude
/// Desktop extension setup, Claude Code CLI invocation).
pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let path = config_path_for(agent_id)
.ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?;
let mut root = if path.exists() {
read_value(&path, spec.format)
} else {
serde_json::Value::Object(serde_json::Map::new())
};
if !root.is_object() {
root = serde_json::Value::Object(serde_json::Map::new());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.insert(
SERVER_NAME.to_string(),
transform_remote_config(agent_id, url),
);
write_value(&path, &root, spec.format)
}
pub fn uninstall_generic(agent_id: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let Some(path) = config_path_for(agent_id) else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let mut root = read_value(&path, spec.format);
if !root.is_object() {
return Ok(());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.remove(SERVER_NAME);
write_value(&path, &root, spec.format)
}
pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec<McpAgentInfo> {
AGENT_SPECS
.iter()
.map(|spec| {
let connected = connected_overrides
.iter()
.find(|(id, _)| *id == spec.id)
.map(|(_, c)| *c)
.unwrap_or_else(|| is_generic_agent_connected(spec.id));
McpAgentInfo {
id: spec.id.to_string(),
display_name: spec.display_name.to_string(),
category: spec.category,
connected,
detected: detect_agent_directory(spec.id),
}
})
.collect()
}
pub fn agent_exists(agent_id: &str) -> bool {
spec_for(agent_id).is_some()
}
+650 -40
View File
@@ -33,6 +33,48 @@ pub struct McpTool {
pub input_schema: serde_json::Value,
}
/// JavaScript executed in the target page to enumerate visible interactive
/// elements. Returns a JSON string `{elements, count, truncated}` where
/// `elements` is the newline-joined labeled list. Live references are stashed
/// on `window.__donut_interactive` so subsequent `click_by_index` /
/// `type_by_index` calls can resolve `index → Element` without round-tripping
/// a selector. `__MAX_CHARS__` is substituted at call time.
const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => {
const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt'];
const MAX_CHARS = __MAX_CHARS__;
const interactive = [];
const lines = [];
let truncated = false;
let total = 0;
const nodes = document.querySelectorAll(SELECTORS);
for (const el of nodes) {
if (el.disabled) continue;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) continue;
const style = window.getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
const tag = el.tagName.toLowerCase();
const parts = [];
for (const a of ATTRS) {
const v = el.getAttribute(a);
if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"');
}
let text = '';
if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100);
}
const idx = interactive.length;
const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + '</' + tag + '>';
if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; }
total += line.length + 1;
interactive.push(el);
lines.push(line);
}
window.__donut_interactive = interactive;
return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated });
})()"#;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct McpRequest {
@@ -110,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={:?}",
@@ -122,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(())
@@ -244,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,
@@ -274,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>,
@@ -297,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);
@@ -466,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": {
@@ -488,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": {
@@ -1103,6 +1167,25 @@ impl McpServer {
"required": ["profile_id"]
}),
},
// Cookie management tools
McpTool {
name: "import_profile_cookies".to_string(),
description: "Import cookies into a Wayfern or Camoufox profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the target profile"
},
"content": {
"type": "string",
"description": "Raw cookie file content (JSON array or Netscape cookies.txt)"
}
},
"required": ["profile_id", "content"]
}),
},
// Team lock tools
McpTool {
name: "get_team_locks".to_string(),
@@ -1354,6 +1437,76 @@ impl McpServer {
"required": ["profile_id"]
}),
},
McpTool {
name: "get_interactive_elements".to_string(),
description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"max_chars": {
"type": "integer",
"description": "Cap on the serialized output length (default: 40000). The response carries a `truncated` flag if the list was cut off — narrow the viewport or scroll if you need elements past the cutoff."
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "click_by_index".to_string(),
description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
}
},
"required": ["profile_id", "index"]
}),
},
McpTool {
name: "type_by_index".to_string(),
description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
},
"text": {
"type": "string",
"description": "Text to type into the element"
},
"clear_first": {
"type": "boolean",
"description": "Clear the input before typing (default: true)"
},
"instant": {
"type": "boolean",
"description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection."
},
"wpm": {
"type": "number",
"description": "Target words per minute for human typing (default: 80)"
}
},
"required": ["profile_id", "index", "text"]
}),
},
]
}
@@ -1508,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,
@@ -1540,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)
@@ -1562,12 +1735,18 @@ impl McpServer {
.handle_assign_extension_group_to_profile(arguments)
.await
}
// Cookie management
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
// Team lock tools
"get_team_locks" => self.handle_get_team_locks().await,
"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,
@@ -1575,33 +1754,85 @@ 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_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_interactive_elements(arguments).await
}
"click_by_index" => {
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_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_by_index(arguments).await
}
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
@@ -1678,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())
@@ -1687,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);
@@ -1731,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": [{
@@ -1757,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())
@@ -2433,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())
@@ -2731,6 +2987,74 @@ impl McpServer {
}))
}
// Cookie management handlers
async fn handle_import_profile_cookies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
})?;
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?
.clone()
};
let result =
crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import cookies: {e}"),
})?;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = crate::profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.is_sync_enabled() {
let pid = profile_id.to_string();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} replaced, {} parse error(s)",
result.cookies_imported,
result.cookies_replaced,
result.errors.len()
)
}]
}))
}
// VPN management handlers
async fn handle_import_vpn(
&self,
@@ -3016,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(),
});
}
@@ -4263,6 +4587,11 @@ impl McpServer {
.and_then(|v| v.as_str())
.unwrap_or("text");
let selector = arguments.get("selector").and_then(|v| v.as_str());
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(40_000);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
@@ -4310,10 +4639,28 @@ impl McpServer {
.and_then(|v| v.as_str())
.unwrap_or("");
// Cap output so a 500 KB DOM dump doesn't blow out the agent's context.
// Slice on character boundaries (chars().take().collect()) rather than
// byte indices, since the latter would panic on multi-byte boundaries.
let total_chars = content.chars().count();
let (text, truncated) = if total_chars > max_chars {
(content.chars().take(max_chars).collect::<String>(), true)
} else {
(content.to_string(), false)
};
let payload = if truncated {
format!(
"{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]"
)
} else {
text
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": content
"text": payload
}]
}))
}
@@ -4361,6 +4708,267 @@ impl McpServer {
}))
}
async fn handle_get_interactive_elements(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(40_000);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
// Walk the DOM for visible, non-disabled interactive elements, label them
// with a zero-based index, and cache the live references on
// `window.__donut_interactive` so click_by_index / type_by_index can
// resolve the index → Element without round-tripping a selector.
let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string());
let result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Enumeration failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
let payload_str = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{}");
let payload: serde_json::Value =
serde_json::from_str(payload_str).unwrap_or(serde_json::json!({}));
let elements = payload
.get("elements")
.and_then(|v| v.as_str())
.unwrap_or("");
let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
let truncated = payload
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let header = if truncated {
format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):")
} else {
format!("{count} interactive elements:")
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("{header}\n{elements}")
}]
}))
}
async fn handle_click_by_index(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let index = arguments
.get("index")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing index".to_string(),
})?;
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let js = format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.click();
return true;
}})()"#
);
let result = self
.send_cdp_and_wait_for_load(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
10,
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Click failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Clicked element at index {index}")
}]
}))
}
async fn handle_type_by_index(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let index = arguments
.get("index")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing index".to_string(),
})?;
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing text".to_string(),
})?;
let clear_first = arguments
.get("clear_first")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let instant = arguments
.get("instant")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let wpm = arguments.get("wpm").and_then(|v| v.as_f64());
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
// Mirrors handle_type_text's focus step but resolves the element via the
// cached index instead of a CSS selector.
let focus_js = if clear_first {
format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
el.value = '';
el.dispatchEvent(new Event('input', {{bubbles: true}}));
return true;
}})()"#
)
} else {
format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
return true;
}})()"#
)
};
let focus_result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": focus_js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = focus_result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Focus failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
if instant {
self
.send_cdp(
&ws_url,
"Input.insertText",
serde_json::json!({ "text": text }),
)
.await?;
} else {
self.send_human_keystrokes(&ws_url, text, wpm).await?;
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Typed text into element at index {index}")
}]
}))
}
// --- Synchronizer handlers ---
async fn handle_start_sync_session(
@@ -4560,6 +5168,8 @@ mod tests {
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
// Cookie tools
assert!(tool_names.contains(&"import_profile_cookies"));
// Team lock tools
assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status"));
+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());
}
}
+187 -35
View File
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url;
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
});
{
let mut f = fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)
}
pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
@@ -186,6 +200,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -289,6 +304,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -351,6 +367,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Save profile info
@@ -363,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)?;
@@ -396,7 +422,7 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?;
let json = serde_json::to_string_pretty(profile)?;
fs::write(profile_file, json)?;
atomic_write(&profile_file, json.as_bytes())?;
// Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -421,8 +447,26 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
let content = match fs::read_to_string(&metadata_file) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Skipping profile at {}: failed to read metadata.json: {e}",
path.display()
);
continue;
}
};
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!(
"Skipping profile at {}: invalid metadata.json: {e}",
path.display()
);
continue;
}
};
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
@@ -431,7 +475,7 @@ impl ProfileManager {
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
let _ = atomic_write(&metadata_file, json.as_bytes());
}
}
}
@@ -469,10 +513,13 @@ 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)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -676,8 +723,11 @@ 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);
// Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id {
@@ -728,10 +778,13 @@ impl ProfileManager {
}
}
profile.tags = deduped;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -762,10 +815,13 @@ 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)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -789,9 +845,12 @@ 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)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -818,9 +877,12 @@ 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)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -842,9 +904,12 @@ 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)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -970,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,
@@ -1003,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") {
@@ -1060,6 +1141,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1120,6 +1203,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1166,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
@@ -1174,6 +1260,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
@@ -1184,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
@@ -1204,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)
@@ -1256,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)
@@ -1263,6 +1359,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
@@ -1298,8 +1396,11 @@ 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);
// Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller.
@@ -1453,13 +1554,18 @@ impl ProfileManager {
};
let mut merged = latest_profile.clone();
let mut detected_stop = false;
if let Some(pid) = found_pid {
if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}");
}
if let Some(prev) = old_pid {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, pid);
}
}
} else if merged.process_id.is_some() {
// Clear the PID if no process found
@@ -1467,6 +1573,15 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}");
}
detected_stop = true;
}
if detected_stop {
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(&app_handle, &merged)
{
merged = updated;
}
}
// Emit profile update event to frontend
@@ -1481,7 +1596,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager
async fn check_camoufox_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager;
@@ -1510,10 +1625,14 @@ impl ProfileManager {
};
if latest.process_id != camoufox_process.processId {
let old_pid = latest.process_id;
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, camoufox_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1555,6 +1674,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1591,6 +1716,12 @@ impl ProfileManager {
);
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
// Emit profile update event to frontend
if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1605,7 +1736,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager
async fn check_wayfern_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager;
@@ -1634,10 +1765,14 @@ impl ProfileManager {
};
if latest.process_id != wayfern_process.processId {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, wayfern_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1679,6 +1814,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1703,10 +1844,17 @@ impl ProfileManager {
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
// Keep extension updates enabled and allow sideloaded extensions
// Keep extension updates enabled and allow sideloaded extensions.
// - autoDisableScopes=0: profile-installed extensions are enabled by default.
// - startupScanScopes=1: rescan SCOPE_PROFILE on each launch so freshly
// dropped .xpi files in <profile>/extensions/ get registered.
// - signatures.required=false: accept unsigned/dev .xpi files. Camoufox
// is built without MOZ_REQUIRE_SIGNING so this is honored.
"user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
"user_pref(\"extensions.startupScanScopes\", 1);".to_string(),
"user_pref(\"xpinstall.signatures.required\", false);".to_string(),
// Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(),
@@ -2336,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(
@@ -2364,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
@@ -2392,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
+56 -10
View File
@@ -292,10 +292,45 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
.map_err(err_internal)?;
cache_key(id, key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
/// Verify a profile password without unlocking. Used by the Settings UI's
/// "Validate" button so users can confirm they remember the password without
/// performing a destructive change. Honors the same lockout schedule as
/// `unlock_profile` so a brute-force attacker can't bypass rate-limiting by
/// hammering this command.
#[tauri::command]
pub async fn verify_profile_password(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
let profile = load_profile(&id)?;
if !profile.password_protected {
return Err(err_code("PROFILE_NOT_PROTECTED"));
}
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
let salt = profile
.encryption_salt
.as_deref()
.ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?;
let key = derive_profile_key(&password, salt).map_err(err_internal)?;
let dir = profile_data_dir(&profile);
match verify_key_against_dir(&key, &dir) {
Ok(()) => {
clear_failed_attempts(&id);
Ok(())
}
Err(crate::profile::encryption::PasswordError::WrongPassword) => {
record_failed_attempt(id);
Err(err_code("INCORRECT_PASSWORD"))
}
Err(other) => Err(err_internal(other)),
}
}
#[tauri::command]
pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
@@ -396,6 +431,7 @@ pub async fn change_profile_password(
drop_cached_key(&id);
cache_key(id, new_key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
@@ -464,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
.map_err(err_internal)?;
drop_cached_key(&id);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
@@ -637,22 +674,31 @@ pub fn complete_after_quit_blocking(
result
}
/// Async re-encrypt of a password-protected profile's ephemeral dir back to
/// disk, called after the browser process exits. Optionally purges the
/// ephemeral dir + cached key based on the global setting.
pub fn complete_after_quit(profile: &crate::profile::BrowserProfile) {
/// Re-encrypt a password-protected profile's ephemeral dir back to the
/// on-disk encrypted dir after the browser process exits. Optionally purges
/// the ephemeral dir + cached key based on the global setting. Returns the
/// number of files re-encrypted (`None` when nothing to do or the profile
/// isn't protected).
///
/// Callers that release a queued sync run after a browser quit MUST await
/// this future — releasing sync while re-encryption is still in-flight
/// uploads the stale on-disk snapshot and leaves the fresh ciphertext
/// orphaned until the next scheduler tick.
pub async fn complete_after_quit_and_wait(
profile: &crate::profile::BrowserProfile,
) -> Option<usize> {
if !profile.password_protected {
return;
return None;
}
let keep_decrypted = read_keep_decrypted_setting();
let profile = profile.clone();
tauri::async_runtime::spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
complete_after_quit_blocking(&profile, keep_decrypted);
tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
.await
.unwrap_or_else(|e| {
log::error!("complete_after_quit_and_wait join error: {e}");
None
})
.await;
});
}
#[cfg(test)]
+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" => {
+37 -62
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", "ru", or None for system 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();
@@ -991,6 +957,17 @@ pub async fn save_sync_settings(
sync_server_url: Option<String>,
sync_token: Option<String>,
) -> Result<SyncSettings, String> {
// Cloud login and self-hosted sync share the same sync engine and a
// profile can't be sync'd to two backends at once. Block any *write*
// (non-null URL or token) while the user is signed into their cloud
// account — the clearing path (both `None`) is always allowed so logged-
// in users can wipe a stale self-hosted config that pre-dates their
// sign-in.
let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some();
if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string());
}
let manager = SettingsManager::instance();
manager
@@ -1036,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()
@@ -1171,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,
};
@@ -1236,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
+8
View File
@@ -346,6 +346,14 @@ pub fn check_has_e2e_password() -> bool {
has_e2e_password()
}
#[tauri::command]
pub fn verify_e2e_password(password: String) -> Result<bool, String> {
match load_e2e_password()? {
Some(stored) => Ok(stored == password),
None => Err(serde_json::json!({ "code": "NO_E2E_PASSWORD_SET" }).to_string()),
}
}
#[tauri::command]
pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?;
+349 -229
View File
@@ -10,11 +10,53 @@ use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
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());
}
fn register_sync_cancel(profile_id: &str) -> Arc<AtomicBool> {
let mut map = SYNC_CANCEL_FLAGS.lock().unwrap();
let flag = Arc::new(AtomicBool::new(false));
map.insert(profile_id.to_string(), flag.clone());
flag
}
fn clear_sync_cancel(profile_id: &str) {
SYNC_CANCEL_FLAGS.lock().unwrap().remove(profile_id);
}
pub fn request_sync_cancel(profile_id: &str) -> bool {
if let Some(flag) = SYNC_CANCEL_FLAGS.lock().unwrap().get(profile_id) {
flag.store(true, Ordering::SeqCst);
true
} else {
false
}
}
struct SyncCancelGuard(String);
impl Drop for SyncCancelGuard {
fn drop(&mut self) {
clear_sync_cancel(&self.0);
}
}
#[tauri::command]
pub async fn cancel_profile_sync(profile_id: String) -> Result<bool, String> {
Ok(request_sync_cancel(&profile_id))
}
/// Upload/download concurrency limit
const SYNC_CONCURRENCY: usize = 32;
@@ -252,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();
@@ -321,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,
@@ -391,6 +497,9 @@ impl SyncEngine {
let profile_dir = profiles_dir.join(profile.id.to_string());
let profile_id = profile.id.to_string();
let cancel_flag = register_sync_cancel(&profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.clone());
// Determine team key prefix for team profiles
let key_prefix = Self::get_team_key_prefix(profile).await;
@@ -514,10 +623,16 @@ impl SyncEngine {
&diff.files_to_upload,
encryption_key.as_ref(),
&key_prefix,
&cancel_flag,
)
.await?;
}
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after uploads", profile_id);
return Err(SyncError::Cancelled);
}
// Perform downloads
if !diff.files_to_download.is_empty() {
self
@@ -529,10 +644,16 @@ impl SyncEngine {
&diff.files_to_download,
encryption_key.as_ref(),
&key_prefix,
&cancel_flag,
)
.await?;
}
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after downloads", profile_id);
return Err(SyncError::Cancelled);
}
// Delete local files that don't exist remotely (when remote is newer)
for path in &diff.files_to_delete_local {
let file_path = profile_dir.join(path);
@@ -823,6 +944,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -930,6 +1052,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
if cancel_flag.load(Ordering::Relaxed) {
log::info!(
"Upload cancelled for profile {} before scheduling more files",
profile_id_owned
);
break;
}
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone();
@@ -958,6 +1087,7 @@ impl SyncEngine {
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
let content_type = mime_guess::from_path(&file.path)
.first()
.map(|m| m.to_string());
@@ -965,6 +1095,10 @@ impl SyncEngine {
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
let data = match fs::read(&file_path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
@@ -1095,6 +1229,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -1194,6 +1329,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
if cancel_flag.load(Ordering::Relaxed) {
log::info!(
"Download cancelled for profile {} before scheduling more files",
profile_id_owned
);
break;
}
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone();
@@ -1222,13 +1364,21 @@ impl SyncEngine {
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
// Retry loop for network downloads
let mut last_err = String::new();
for attempt in 0..MAX_FILE_RETRIES {
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
match client.download_bytes(&url).await {
Ok(data) => {
let write_data = if let Some(ref key) = enc_key {
@@ -1350,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?;
}
}
@@ -1397,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)
@@ -1466,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", ());
@@ -1498,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?;
}
}
@@ -1545,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
@@ -1714,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?;
}
}
@@ -1755,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
@@ -1865,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?;
}
}
@@ -1906,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
@@ -2070,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?;
}
}
@@ -2115,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
@@ -2361,6 +2447,8 @@ impl SyncEngine {
);
}
if !manifest.files.is_empty() {
let cancel_flag = register_sync_cancel(profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
self
.download_profile_files(
app_handle,
@@ -2370,6 +2458,7 @@ impl SyncEngine {
&manifest.files,
encryption_key.as_ref(),
key_prefix,
&cancel_flag,
)
.await?;
}
@@ -2506,8 +2595,46 @@ impl SyncEngine {
profiles_to_check.len()
);
// For each remote profile, check if it exists locally and download if missing
// For each remote profile, check if it exists locally and download if missing.
// Skip any profile that has a tombstone — a leftover manifest under a
// tombstoned id means delete_prefix raced or partially failed, and
// re-downloading it here is what surfaced the "Browsing keeps re-syncing"
// bug after a delete.
for (profile_id, key_prefix) in &profiles_to_check {
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
let has_personal_tombstone = matches!(
self.client.stat(&personal_tombstone).await,
Ok(stat) if stat.exists
);
let team_tombstone_key = if key_prefix.is_empty() {
None
} else {
Some(format!(
"{}tombstones/profiles/{}.json",
key_prefix, profile_id
))
};
let has_team_tombstone = if let Some(ref tk) = team_tombstone_key {
matches!(self.client.stat(tk).await, Ok(stat) if stat.exists)
} else {
false
};
if has_personal_tombstone || has_team_tombstone {
log::info!(
"Skipping download of tombstoned profile {} (clearing leftover remote files)",
profile_id
);
let prefix = format!("{}profiles/{}/", key_prefix, profile_id);
if let Err(e) = self.client.delete_prefix(&prefix, None).await {
log::warn!(
"Failed to clear stale remote files for tombstoned profile {}: {}",
profile_id,
e
);
}
continue;
}
match self
.download_profile_if_missing(app_handle, profile_id, key_prefix)
.await
@@ -2571,6 +2698,24 @@ impl SyncEngine {
};
if has_personal_tombstone || has_team_tombstone {
// Originator guard: re-read the profile right before deleting. If the
// local user disabled sync between the snapshot above and this stat
// call, they're the one who wrote this tombstone — keep their local
// copy. Tombstones must delete remote-originated changes, never the
// sender's own data. (Caused mass local deletion in v0.24.x.)
let still_sync_enabled = profile_manager
.list_profiles()
.unwrap_or_default()
.iter()
.find(|p| p.id.to_string() == *pid)
.is_some_and(|p| p.is_sync_enabled());
if !still_sync_enabled {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy (originating device)",
pid
);
continue;
}
log::info!(
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
pid
@@ -2948,6 +3093,11 @@ pub async fn set_profile_sync_mode(
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
}
let enabling_now = new_mode != SyncMode::Disabled;
if enabling_now && profile.process_id.is_some() {
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
}
if profile.ephemeral {
return Err("Cannot enable sync for an ephemeral profile".to_string());
}
@@ -3029,6 +3179,22 @@ pub async fn set_profile_sync_mode(
let _ = events::emit("profiles-changed", ());
// When (re-)enabling sync, clear any stale tombstone from a previous
// disable on this device. Otherwise the next reconcile on another
// device — or even a race on this one — would see the tombstone and
// delete the freshly re-uploaded data.
if enabling {
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
let key_prefix = SyncEngine::get_team_key_prefix(&profile).await;
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
let _ = engine.client.delete(&personal_tombstone, None).await;
if !key_prefix.is_empty() {
let team_tombstone = format!("{}tombstones/profiles/{}.json", key_prefix, profile_id);
let _ = engine.client.delete(&team_tombstone, None).await;
}
}
}
if enabling {
let is_running = profile.process_id.is_some();
@@ -3084,28 +3250,25 @@ pub async fn set_profile_sync_mode(
log::warn!("Scheduler not initialized, sync will not start");
}
} else {
// Delete remote data when disabling sync
// Delete remote data when disabling sync. Awaited (not spawned) so the
// tombstone write completes before this command returns. A previous
// tokio::spawn here allowed the tombstone-write to land *after* a fast
// user-triggered re-enable's tombstone-clear, re-introducing the
// tombstone and tripping the reconcile-pass deletion of a profile the
// user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20).
if old_mode != SyncMode::Disabled {
let profile_id_clone = profile_id.clone();
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
match SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
log::warn!(
"Failed to delete profile {} from sync: {}",
profile_id_clone,
e
);
} else {
log::info!("Profile {} deleted from sync service", profile_id_clone);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id).await {
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
} else {
log::info!("Profile {} deleted from sync service", profile_id);
}
}
});
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
}
}
}
let _ = events::emit(
@@ -3183,6 +3346,28 @@ pub async fn sync_profile(app_handle: tauri::AppHandle, profile_id: String) -> R
trigger_sync_for_profile(app_handle, profile_id).await
}
/// Ensure the device has either a cloud login or a self-hosted server URL + token.
/// Returns a JSON error code string consumable by the frontend translator.
async fn ensure_sync_configured(app_handle: &tauri::AppHandle) -> Result<(), String> {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if cloud_logged_in {
return Ok(());
}
let manager = SettingsManager::instance();
let settings = manager.load_settings().map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
if settings.sync_server_url.is_none() {
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
}
let token = manager.get_sync_token(app_handle).await.ok().flatten();
if token.is_none() {
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
}
Ok(())
}
pub async fn trigger_sync_for_profile(
app_handle: tauri::AppHandle,
profile_id: String,
@@ -3222,43 +3407,29 @@ pub async fn set_proxy_sync_enabled(
let proxy = proxies
.iter()
.find(|p| p.id == proxy_id)
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
.ok_or_else(|| serde_json::json!({ "code": "PROXY_NOT_FOUND" }).to_string())?;
// Block modifying sync for cloud-managed proxies
if proxy.is_cloud_managed {
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
return Err(serde_json::json!({ "code": "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" }).to_string());
}
// If disabling, check if proxy is used by any synced profile
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let new_last_sync = if enabled { proxy.last_sync } else { None };
proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?;
proxy_manager
.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } }).to_string()
})?;
let _ = events::emit("stored-proxies-changed", ());
@@ -3299,36 +3470,18 @@ pub async fn set_group_sync_enabled(
groups
.iter()
.find(|g| g.id == group_id)
.ok_or_else(|| format!("Group with ID '{group_id}' not found"))?
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?
.clone()
};
// If disabling, check if group is used by any synced profile
if !enabled && is_group_used_by_synced_profile(&group_id) {
return Err("Sync cannot be disabled while this group is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_group = group.clone();
@@ -3341,7 +3494,10 @@ pub async fn set_group_sync_enabled(
{
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
if let Err(e) = group_manager.update_group_internal(&updated_group) {
return Err(format!("Failed to update group: {e}"));
return Err(
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string(),
);
}
}
@@ -3392,35 +3548,17 @@ pub async fn set_vpn_sync_enabled(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.load_config(&vpn_id)
.map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "VPN_NOT_FOUND" }).to_string())?
};
// If disabling, check if VPN is used by any synced profile
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let last_sync = if enabled { vpn.last_sync } else { None };
@@ -3429,7 +3567,10 @@ pub async fn set_vpn_sync_enabled(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.update_sync_fields(&vpn_id, enabled, last_sync)
.map_err(|e| format!("Failed to update VPN sync: {e}"))?;
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("vpn-configs-changed", ());
@@ -3526,6 +3667,11 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Intentionally excludes profiles: enabling profile sync uploads the entire
// browser data dir per profile, which is destructive if the user expected
// an opt-in. Profile sync stays under explicit per-profile control via
// set_profile_sync_mode. This command only touches metadata-sized entities.
// Enable sync for all unsynced proxies
{
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
@@ -3621,26 +3767,11 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_extension(&extension_id)
.map_err(|e| format!("Extension with ID '{extension_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "EXTENSION_NOT_FOUND" }).to_string())?
};
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_ext = ext;
@@ -3653,7 +3784,10 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.update_extension_internal(&updated_ext)
.map_err(|e| format!("Failed to update extension sync: {e}"))?;
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("extensions-changed", ());
@@ -3677,26 +3811,11 @@ pub async fn set_extension_group_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_group(&extension_group_id)
.map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "EXTENSION_GROUP_NOT_FOUND" }).to_string())?
};
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_group = group;
@@ -3707,9 +3826,10 @@ pub async fn set_extension_group_sync_enabled(
{
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.update_group_internal(&updated_group)
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
manager.update_group_internal(&updated_group).map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("extensions-changed", ());
+13 -3
View File
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/startupCache/**",
"**/safebrowsing/**",
"**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/sessionstore-backups/**",
"**/sessions/**",
"**/serviceworker.txt",
"**/AlternateServices.bin",
"**/SiteSecurityServiceState.bin",
"**/favicons.sqlite",
"**/favicons.sqlite-*",
"**/crashes/**",
"**/minidumps/**",
"*.tmp",
@@ -52,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",
];
+24 -4
View File
@@ -7,11 +7,13 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
@@ -22,3 +24,21 @@ pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, Syn
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
/// reassignment shows up on other devices without waiting for the next
/// scheduled tick. Spawns the async queue call so this helper is callable
/// from both sync and async contexts.
pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
let profile_id = profile.id.to_string();
tauri::async_runtime::spawn(async move {
if let Some(scheduler) = get_global_scheduler() {
scheduler.queue_profile_sync(profile_id).await;
}
});
}
+10 -3
View File
@@ -716,16 +716,18 @@ impl SyncScheduler {
match entity_type.as_str() {
"profile" => {
let profile_manager = ProfileManager::instance();
let has_profile = {
let local_sync_enabled = {
if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
profile_uuid
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
.is_some_and(|p| p.is_sync_enabled())
} else {
false
}
};
if has_profile {
if local_sync_enabled {
log::info!(
"Profile {} was deleted remotely, deleting locally",
entity_id
@@ -733,6 +735,11 @@ impl SyncScheduler {
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
}
} else {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
entity_id
);
}
}
"proxy" => {
+16
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)]
@@ -166,6 +180,7 @@ pub enum SyncError {
SerializationError(String),
ConflictError(String),
InvalidData(String),
Cancelled,
}
impl std::fmt::Display for SyncError {
@@ -178,6 +193,7 @@ impl std::fmt::Display for SyncError {
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
}
}
}
+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.0",
"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,
}
}
+330 -56
View File
@@ -3,11 +3,15 @@
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";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
@@ -21,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 {
@@ -34,10 +38,13 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { type AppPage, RailNav } from "@/components/rail-nav";
import { SettingsDialog } from "@/components/settings-dialog";
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";
@@ -53,6 +60,17 @@ 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,
SHORTCUTS,
type ShortcutId,
} from "@/lib/shortcuts";
import {
dismissToast,
showErrorToast,
@@ -87,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,
@@ -120,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);
@@ -149,6 +253,11 @@ export default function Home() {
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
"proxies" | "vpns"
>("proxies");
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
useState<"extensions" | "groups">("extensions");
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
"api" | "mcp"
>("api");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -201,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);
@@ -221,6 +328,11 @@ export default function Home() {
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] =
useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
useState<BrowserProfile | null>(null);
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions();
@@ -273,9 +385,134 @@ export default function Home() {
case "account":
setAccountDialogOpen(true);
break;
case "shortcuts":
// Plain page render — nothing else to open.
break;
}
}, []);
const runShortcut = useCallback(
(id: ShortcutId) => {
switch (id) {
case "openPalette":
setCommandPaletteOpen(true);
break;
case "openShortcuts":
handleRailNavigate("shortcuts");
break;
case "importProfile":
handleRailNavigate("import");
break;
case "goProfiles":
handleRailNavigate("profiles");
break;
case "goProxies": {
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
// initialTab, so we just pick the right destination.
if (currentPage === "proxies") {
handleRailNavigate("vpns");
} else if (currentPage === "vpns") {
handleRailNavigate("proxies");
} else {
handleRailNavigate(
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
);
}
break;
}
case "goExtensions": {
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
if (currentPage === "extensions") {
setExtensionManagementInitialTab((cur) =>
cur === "extensions" ? "groups" : "extensions",
);
} else {
handleRailNavigate("extensions");
}
break;
}
case "goGroups":
handleRailNavigate("groups");
break;
case "goIntegrations": {
// Mod+I: flip api↔mcp tab when already on integrations.
if (currentPage === "integrations") {
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
} else {
handleRailNavigate("integrations");
}
break;
}
case "goAccount":
handleRailNavigate("account");
break;
case "goSettings":
handleRailNavigate("settings");
break;
}
},
[handleRailNavigate, currentPage, proxyManagementInitialTab],
);
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
const orderedGroupTargets = useMemo(
() => [
{ id: "__all__", name: t("rail.profiles") },
...groupsData.map((g) => ({ id: g.id, name: g.name })),
],
[groupsData, t],
);
const selectGroupByDigit = useCallback(
(digit: number) => {
const target = orderedGroupTargets[digit - 1];
if (!target) return;
handleRailNavigate("profiles");
handleSelectGroup(target.id);
},
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
);
useEffect(() => {
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const isTyping =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target?.isContentEditable === true;
const digit = matchesGroupDigit(e);
if (digit !== null) {
if (isTyping) return;
if (digit - 1 >= orderedGroupTargets.length) return;
e.preventDefault();
selectGroupByDigit(digit);
return;
}
for (const s of SHORTCUTS) {
if (!matchesShortcut(s, e)) continue;
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
return;
}
e.preventDefault();
runShortcut(s.id);
return;
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
// Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => {
try {
@@ -402,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) {
@@ -613,7 +832,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined),
(selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook,
@@ -650,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],
@@ -943,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"));
@@ -1029,7 +1256,7 @@ export default function Home() {
failed_count: payload.failed_count ?? 0,
phase: payload.phase,
},
{ id: toastId },
{ id: toastId, profileId: payload.profile_id },
);
}
});
@@ -1044,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();
@@ -1089,7 +1313,6 @@ export default function Home() {
};
}, [
checkForUpdates,
checkStartupPrompt,
listenForUrlEvents,
checkCurrentUrl,
checkMissingBinaries,
@@ -1104,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(
@@ -1165,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 () => {
@@ -1172,6 +1406,7 @@ export default function Home() {
unlistenStarted?.();
unlistenProgress?.();
unlistenCompleted?.();
unlistenWayfernBlocked?.();
};
}, [t]);
@@ -1191,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",
@@ -1205,7 +1442,7 @@ export default function Home() {
},
});
}
}, [profiles]);
}, [profiles, t]);
// Re-check Wayfern terms when a browser download completes
useEffect(() => {
@@ -1226,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(() => {
@@ -1243,11 +1482,10 @@ export default function Home() {
let filtered = profiles;
// Filter by group. "__all__" is a virtual filter that shows every
// profile regardless of group; "default" shows ungrouped profiles.
if (selectedGroupId === "__all__") {
// profile (including ungrouped ones). Any other value is a real
// group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles;
} else if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else {
filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId,
@@ -1287,11 +1525,14 @@ 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}
onSearchQueryChange={setSearchQuery}
groups={groupsData}
totalProfiles={profiles.length}
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle}
@@ -1304,6 +1545,8 @@ export default function Home() {
{isLoading && groupsData.length === 0 ? null : null}
<ProfilesDataTable
profiles={filteredProfiles}
infoDialogProfile={profileInfoDialog}
onInfoDialogProfileChange={setProfileInfoDialog}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
@@ -1342,6 +1585,10 @@ export default function Home() {
</div>
)}
{currentPage === "shortcuts" && (
<ShortcutsPage groupTargets={orderedGroupTargets} />
)}
{settingsDialogOpen && (
<SettingsDialog
isOpen={settingsDialogOpen}
@@ -1366,6 +1613,7 @@ export default function Home() {
setCurrentPage("profiles");
}}
subPage={currentPage === "integrations"}
initialTab={integrationsInitialTab}
/>
)}
@@ -1402,6 +1650,7 @@ export default function Home() {
}}
limitedMode={false}
subPage={currentPage === "extensions"}
initialTab={extensionManagementInitialTab}
/>
)}
@@ -1445,6 +1694,29 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
/>
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
onAction={runShortcut}
groupTargets={orderedGroupTargets}
onSelectGroup={(id) => {
handleRailNavigate("profiles");
handleSelectGroup(id);
}}
profiles={profiles}
runningProfileIds={runningProfiles}
onLaunchProfile={(profile) => {
void launchProfile(profile);
}}
onKillProfile={(profile) => {
void handleKillProfile(profile);
}}
onShowProfileInfo={(profile) => {
handleRailNavigate("profiles");
setProfileInfoDialog(profile);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
@@ -1469,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={() => {
@@ -1673,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}
+453 -103
View File
@@ -1,12 +1,33 @@
"use client";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCloud, LuLogOut, LuRefreshCw, LuUser } from "react-icons/lu";
import {
LuCloud,
LuEye,
LuEyeOff,
LuLogOut,
LuRefreshCw,
LuUser,
} from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { 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";
interface AccountPageProps {
isOpen: boolean;
@@ -15,6 +36,8 @@ interface AccountPageProps {
onOpenSignIn: () => void;
}
type ConnectionStatus = "unknown" | "testing" | "connected" | "error";
export function AccountPage({
isOpen,
onClose,
@@ -22,8 +45,34 @@ export function AccountPage({
onOpenSignIn,
}: AccountPageProps) {
const { t } = useTranslation();
const { user, isLoggedIn, logout, refreshProfile } = useCloudAuth();
const {
user,
isLoggedIn,
isLoading: isCloudLoading,
logout,
refreshProfile,
} = useCloudAuth();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Self-hosted server state. Loaded once when the dialog opens and persisted
// via `save_sync_settings` so the rest of the app picks up the new URL/token
// from `SettingsManager`.
const [serverUrl, setServerUrl] = useState("");
const [token, setToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [isSavingSelfHosted, setIsSavingSelfHosted] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("unknown");
const hasConfig = Boolean(serverUrl && token);
// Self-hosted and cloud are mutually exclusive — both share the same sync
// engine and a profile can't be sync'd to two backends. The tab trigger is
// disabled here AND the backend rejects mixed state (see `save_sync_settings`
// / `cloud_logout`), so even if someone bypasses the UI we don't end up
// with split-brain.
const selfHostedDisabled = isLoggedIn || isCloudLoading;
const handleRefresh = async () => {
setIsRefreshing(true);
@@ -38,119 +87,420 @@ export function AccountPage({
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await logout();
// The backend wipes sync URL + token as part of cloud_logout (see
// `cloud_auth::cloud_logout`); pull the now-empty settings back into
// the form so a user who flips to the Self-hosted tab doesn't see the
// pre-logout production URL still sitting there.
await loadSelfHostedSettings();
showSuccessToast(t("account.loggedOut"));
} catch (e) {
showErrorToast(String(e));
} finally {
setIsLoggingOut(false);
}
};
const loadSelfHostedSettings = useCallback(async () => {
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
setServerUrl(settings.sync_server_url ?? "");
setToken(settings.sync_token ?? "");
setConnectionStatus(
settings.sync_server_url && settings.sync_token ? "unknown" : "unknown",
);
} catch (error) {
console.error("Failed to load sync settings:", error);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadSelfHostedSettings();
}
}, [isOpen, loadSelfHostedSettings]);
const handleTestConnection = useCallback(async () => {
if (!serverUrl) {
showErrorToast(t("sync.config.serverUrlRequired"));
return;
}
setIsTestingConnection(true);
setConnectionStatus("testing");
try {
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
if (response.ok) {
setConnectionStatus("connected");
showSuccessToast(t("sync.config.connectionSuccess"));
} else {
setConnectionStatus("error");
showErrorToast(t("sync.config.serverError"));
}
} catch {
setConnectionStatus("error");
showErrorToast(t("sync.config.connectFailed"));
} finally {
setIsTestingConnection(false);
}
}, [serverUrl, t]);
const handleSaveSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: serverUrl || null,
syncToken: token || null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
showSuccessToast(t("sync.config.settingsSaved"));
} catch (error) {
console.error("Failed to save sync settings:", error);
// Use the structured backend-error translator so the cloud-vs-self-
// hosted mutex (`SELF_HOSTED_REQUIRES_LOGOUT`) shows a clear message
// instead of the generic "save failed" toast.
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsSavingSelfHosted(false);
}
}, [serverUrl, token, t]);
const handleDisconnectSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: null,
syncToken: null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
setServerUrl("");
setToken("");
setConnectionStatus("unknown");
showSuccessToast(t("sync.config.disconnected"));
} catch (error) {
console.error("Failed to disconnect:", error);
showErrorToast(t("sync.config.disconnectFailed"));
} finally {
setIsSavingSelfHosted(false);
}
}, [t]);
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="w-6 h-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="text-base font-semibold truncate">
{user.email}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">{user.plan}</p>
</div>
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="w-3 h-3" />
{t("account.refresh")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="w-3 h-3" />
{t("account.logout")}
</Button>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
selfHostedDisabled
? t("account.selfHosted.disabledWhileLoggedIn")
: undefined
}
>
<LuCloud className="w-3 h-3" />
{t("account.signIn")}
</Button>
)}
</div>
{t("account.tabs.selfHosted")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="size-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="text-base font-semibold truncate">
{user.email}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">
{user.plan}
</p>
</div>
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
{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 ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="size-3" />
{t("account.refresh")}
</Button>
<LoadingButton
size="sm"
variant="destructive"
isLoading={isLoggingOut}
disabled={isRefreshing}
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="size-3" />
{t("account.logout")}
</LoadingButton>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<LuCloud className="size-3" />
{t("account.signIn")}
</Button>
)}
</div>
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
// but if state flips mid-render (e.g. a cloud login finishes
// while the tab is open), show the explanation instead of
// a silent empty card.
<p className="text-sm text-muted-foreground">
{t("account.selfHosted.disabledWhileLoggedIn")}
</p>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="text-sm font-medium">
{t("account.selfHosted.title")}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.selfHosted.description")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-server-url" className="text-xs">
{t("sync.serverUrl")}
</Label>
<Input
id="self-hosted-server-url"
type="url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-token" className="text-xs">
{t("sync.token")}
</Label>
<div className="relative">
<Input
id="self-hosted-token"
type={showToken ? "text" : "password"}
placeholder={t("sync.tokenPlaceholder")}
value={token}
onChange={(e) => {
setToken(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
className="pr-9"
/>
<button
type="button"
onClick={() => {
setShowToken((v) => !v);
}}
aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<LuEyeOff className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("account.selfHosted.connectionStatus")}
</span>
{connectionStatus === "connected" && (
<Badge
variant="default"
className="text-success-foreground bg-success"
>
{t("sync.status.connected")}
</Badge>
)}
{connectionStatus === "error" && (
<Badge variant="destructive">
{t("sync.status.error")}
</Badge>
)}
{connectionStatus === "testing" && (
<Badge variant="secondary">
{t("sync.status.syncing")}
</Badge>
)}
{connectionStatus === "unknown" && (
<Badge variant="secondary">
{t("account.selfHosted.statusUnknown")}
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
size="sm"
variant="outline"
isLoading={isTestingConnection}
disabled={!serverUrl || isSavingSelfHosted}
onClick={() => void handleTestConnection()}
className="h-8 text-xs"
>
{t("account.selfHosted.testConnection")}
</LoadingButton>
<LoadingButton
size="sm"
isLoading={isSavingSelfHosted}
disabled={!serverUrl || !token || isTestingConnection}
onClick={() => void handleSaveSelfHosted()}
className="h-8 text-xs"
>
{t("common.buttons.save")}
</LoadingButton>
{hasConfig && (
<Button
size="sm"
variant="destructive"
disabled={isSavingSelfHosted || isTestingConnection}
onClick={() => void handleDisconnectSelfHosted()}
className="h-8 text-xs"
>
{t("account.selfHosted.disconnect")}
</Button>
)}
</div>
</div>
)}
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+5 -5
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 w-5 h-5" />
<LuCheckCheck className="shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
@@ -59,9 +59,9 @@ export function AppUpdateToast({
variant="ghost"
size="sm"
onClick={onDismiss}
className="p-0 w-6 h-6 shrink-0"
className="p-0 size-6 shrink-0"
>
<FaTimes className="w-3 h-3" />
<FaTimes className="size-3" />
</Button>
</div>
@@ -72,7 +72,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<LuCheckCheck className="w-3 h-3" />
<LuCheckCheck className="size-3" />
{t("appUpdate.toast.restartNow")}
</RippleButton>
) : (
@@ -83,7 +83,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
<FaExternalLinkAlt className="size-3" />
{t("appUpdate.toast.viewRelease")}
</RippleButton>
)
@@ -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>
+10 -8
View File
@@ -36,16 +36,18 @@ export function CloneProfileDialog({
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen && profile) {
const defaultName = `${profile.name} (Copy)`;
setName(defaultName);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
} else {
if (!(isOpen && profile)) {
setIsLoading(false);
return;
}
setName(`${profile.name} (Copy)`);
const handle = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen, profile]);
if (!profile) return null;
+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>
);
}
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear } from "react-icons/go";
import {
LuCircleStop,
LuCloud,
LuInfo,
LuKeyboard,
LuPlay,
LuPlug,
LuPuzzle,
LuUser,
LuUsers,
} from "react-icons/lu";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
formatGroupShortcut,
formatShortcut,
SHORTCUTS,
type ShortcutDef,
type ShortcutId,
} from "@/lib/shortcuts";
import type { BrowserProfile } from "@/types";
interface GroupTarget {
id: string;
name: string;
}
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAction: (id: ShortcutId) => void;
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
groupTargets: GroupTarget[];
onSelectGroup: (id: string) => void;
/** All profiles for launch/stop/info entries. */
profiles: BrowserProfile[];
runningProfileIds: Set<string>;
onLaunchProfile: (profile: BrowserProfile) => void;
onKillProfile: (profile: BrowserProfile) => void;
onShowProfileInfo: (profile: BrowserProfile) => void;
}
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
openPalette: LuKeyboard,
openShortcuts: LuKeyboard,
importProfile: FaDownload,
goProfiles: LuUser,
goProxies: FiWifi,
goExtensions: LuPuzzle,
goGroups: LuUsers,
goIntegrations: LuPlug,
goAccount: LuCloud,
goSettings: GoGear,
};
function Tokens({ tokens }: { tokens: string[] }) {
return (
<CommandShortcut className="flex items-center gap-0.5">
{tokens.map((tok, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
>
{tok}
</kbd>
))}
</CommandShortcut>
);
}
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
return <Tokens tokens={formatShortcut(shortcut)} />;
}
/**
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
* to appear as a substring somewhere in the item's value or its keywords; the
* score is reduced when tokens appear later in the haystack so a closer match
* sorts higher. "ctest info" matches "Info — ctest" the default cmdk filter
* requires tokens in document order so it would otherwise return zero.
*/
function fuzzyFilter(
value: string,
search: string,
keywords?: string[],
): number {
if (!search.trim()) return 1;
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
let score = 0;
for (const tok of tokens) {
const idx = haystack.indexOf(tok);
if (idx === -1) return 0;
score += 1 / (1 + idx);
}
return score / tokens.length;
}
export function CommandPalette({
open,
onOpenChange,
onAction,
groupTargets,
onSelectGroup,
profiles,
runningProfileIds,
onLaunchProfile,
onKillProfile,
onShowProfileInfo,
}: CommandPaletteProps) {
const { t } = useTranslation();
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
// on the next tick so an action that opens another dialog doesn't race
// this one's close animation.
const dispatch = (fn: () => void) => {
onOpenChange(false);
setTimeout(fn, 0);
};
const byGroup = (group: ShortcutDef["group"]) =>
SHORTCUTS.filter((s) => s.group === group);
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
// We still display more in the palette (without a shortcut hint) so the
// user can search/jump to any of them.
const renderGroup = (target: GroupTarget, index: number) => (
<CommandItem
key={target.id}
onSelect={() => {
dispatch(() => {
onSelectGroup(target.id);
});
}}
>
<LuUsers />
<span>{target.name}</span>
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
</CommandItem>
);
return (
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
<CommandInput placeholder={t("commandPalette.placeholder")} />
<CommandList>
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
<CommandGroup heading={t("commandPalette.groups.navigation")}>
{byGroup("navigation").map((s) => {
const Icon = ICONS[s.id];
return (
<CommandItem
key={s.id}
onSelect={() => {
dispatch(() => {
onAction(s.id);
});
}}
>
<Icon />
<span>{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</CommandItem>
);
})}
</CommandGroup>
{groupTargets.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
{groupTargets.map((target, i) => renderGroup(target, i))}
</CommandGroup>
</>
) : null}
{profiles.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.profiles")}>
{profiles.map((p) => {
const running = runningProfileIds.has(p.id);
return running ? (
<CommandItem
key={`run-${p.id}`}
onSelect={() => {
dispatch(() => {
onKillProfile(p);
});
}}
>
<LuCircleStop />
<span>
{t("commandPalette.actions.stopProfile", {
name: p.name,
})}
</span>
</CommandItem>
) : (
<CommandItem
key={`run-${p.id}`}
onSelect={() => {
dispatch(() => {
onLaunchProfile(p);
});
}}
>
<LuPlay />
<span>
{t("commandPalette.actions.launchProfile", {
name: p.name,
})}
</span>
</CommandItem>
);
})}
{profiles.map((p) => (
<CommandItem
key={`info-${p.id}`}
onSelect={() => {
dispatch(() => {
onShowProfileInfo(p);
});
}}
>
<LuInfo />
<span>
{t("commandPalette.actions.profileInfo", { name: p.name })}
</span>
</CommandItem>
))}
</CommandGroup>
</>
) : null}
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.actions")}>
{byGroup("actions").map((s) => {
const Icon = ICONS[s.id];
return (
<CommandItem
key={s.id}
onSelect={() => {
dispatch(() => {
onAction(s.id);
});
}}
>
<Icon />
<span>{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
+6 -6
View File
@@ -335,7 +335,7 @@ export function CookieCopyDialog({
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" />
<LuCookie className="size-5" />
{t("cookies.copy.title")}
</DialogTitle>
<DialogDescription>
@@ -372,7 +372,7 @@ export function CookieCopyDialog({
disabled={isRunning}
>
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="w-4 h-4" />}
{IconComponent && <IconComponent className="size-4" />}
<span>{profile.name}</span>
{isRunning && (
<span className="text-xs text-muted-foreground">
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
</div>
<div className="relative">
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t("cookies.copy.searchPlaceholder")}
value={searchQuery}
@@ -450,7 +450,7 @@ export function CookieCopyDialog({
{isLoadingCookies ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
@@ -565,9 +565,9 @@ function DomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-4 h-4" />
<LuChevronDown className="size-4" />
) : (
<LuChevronRight className="w-4 h-4" />
<LuChevronRight className="size-4" />
)}
<span className="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground">
+7 -7
View File
@@ -15,9 +15,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -429,7 +429,7 @@ export function CookieManagementDialog({
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("cookies.management.dropPrompt")}
<br />
@@ -556,14 +556,14 @@ export function CookieManagementDialog({
{isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
{t("cookies.management.noCookies")}
</div>
) : (
<ScrollArea className="h-[200px] border rounded-md">
<FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/>
))}
</div>
</ScrollArea>
</FadingScrollArea>
)}
</div>
@@ -645,9 +645,9 @@ function ExportDomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" />
<LuChevronDown className="size-3.5" />
) : (
<LuChevronRight className="w-3.5 h-3.5" />
<LuChevronRight className="size-3.5" />
)}
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
+171 -296
View File
@@ -1,14 +1,19 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} 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";
@@ -49,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";
@@ -79,7 +78,6 @@ interface CreateProfileDialogProps {
releaseType: string;
proxyId?: string;
vpnId?: string;
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
@@ -98,10 +96,6 @@ interface BrowserOption {
}
const browserOptions: BrowserOption[] = [
{
value: "camoufox",
label: "Camoufox",
},
{
value: "wayfern",
label: "Wayfern",
@@ -116,29 +110,27 @@ export function CreateProfileDialog({
crossOsUnlocked = false,
}: CreateProfileDialogProps) {
const { t } = useTranslation();
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
@@ -147,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[]>([]);
@@ -298,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();
}
}
@@ -311,6 +307,7 @@ export function CreateProfileDialog({
isOpen,
loadSupportedBrowsers,
loadReleaseTypes,
loadDownloadedVersions,
checkAndDownloadGeoIPDatabase,
selectedBrowser,
]);
@@ -396,68 +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 !== "default" ? 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 !== "default" ? 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) {
@@ -478,7 +448,10 @@ export function CreateProfileDialog({
version: bestVersion.version,
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
@@ -497,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);
@@ -522,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 }));
};
@@ -569,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")
@@ -602,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 w-8 h-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
<div className="flex justify-center items-center size-8">
{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 w-8 h-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-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>
@@ -676,9 +632,9 @@ export function CreateProfileDialog({
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 w-8 h-8">
<div className="flex justify-center items-center size-8">
{IconComponent && (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
)}
</div>
<div className="text-left">
@@ -729,7 +685,7 @@ export function CreateProfileDialog({
{/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
@@ -749,7 +705,7 @@ export function CreateProfileDialog({
{/* Password Option */}
{!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="enable-password"
checked={enablePassword}
@@ -814,7 +770,7 @@ export function CreateProfileDialog({
{/* Wayfern Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<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>
@@ -851,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">
@@ -883,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", {
@@ -911,137 +903,20 @@ 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="w-4 h-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">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<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>
@@ -1061,7 +936,7 @@ export function CreateProfileDialog({
size="sm"
variant="outline"
>
Retry
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
@@ -1070,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">
@@ -1106,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>
@@ -1154,7 +1026,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1168,6 +1040,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdAntiDetect}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1190,10 +1063,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdAntiDetect}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1217,7 +1091,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1236,7 +1110,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1261,7 +1135,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
@@ -1412,9 +1286,9 @@ export function CreateProfileDialog({
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<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>
)}
@@ -1440,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">
@@ -1476,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>
@@ -1520,7 +1393,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1534,6 +1407,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdRegular}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1556,10 +1430,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdRegular}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1583,7 +1458,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1602,7 +1477,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1627,7 +1502,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
@@ -1681,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}>
+30 -66
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 w-4 h-4 text-foreground" />;
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
);
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />
);
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
}
return <LuDownload className="flex-shrink-0 w-4 h-4 text-foreground" />;
return <LuDownload className="shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-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 w-4 h-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,10 +212,10 @@ 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="w-3 h-3" />
<LuX className="size-3" />
</button>
)}
</div>
@@ -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,11 +249,12 @@ 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 space-x-2">
<div className="flex items-center gap-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
@@ -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 &&
+1 -1
View File
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
{...props}
>
{isPending ? (
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3.5 rounded-full border border-current animate-spin border-t-transparent" />
) : (
children
)}

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