Compare commits

...

93 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
178 changed files with 17907 additions and 7045 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 build-mode: none
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Contribute List - name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11 uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env: env:
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
security-scan: security-scan:
name: Security Vulnerability Scan name: Security Vulnerability Scan
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]' 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: with:
scan-args: |- scan-args: |-
-r -r
+4 -4
View File
@@ -30,13 +30,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up Docker Buildx - 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 - name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
echo "Tags: ${TAGS}" echo "Tags: ${TAGS}"
- name: Build and push Docker image - 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: with:
context: . context: .
file: ./donut-sync/Dockerfile file: ./donut-sync/Dockerfile
+9 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Install Nix - name: Install Nix
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31 uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
@@ -47,3 +47,11 @@ jobs:
- name: Run flake info app - name: Run flake info app
run: nix run .#info 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: env:
# Single source of truth for the model used by both triage and composer. # Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: anthropic/claude-opus-4.7 TRIAGE_MODEL: z-ai/glm-5.1
COMPOSER_MODEL: anthropic/claude-opus-4.7 COMPOSER_MODEL: z-ai/glm-5.1
jobs: jobs:
analyze-issue: analyze-issue:
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Check if first-time contributor - name: Check if first-time contributor
id: check-first-time id: check-first-time
@@ -102,12 +102,14 @@ jobs:
its API, MCP server, and the bundled `donut-sync` self-hosted server. its API, MCP server, and the bundled `donut-sync` self-hosted server.
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern - **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
bugs are in-scope here unless they are obviously upstream Chromium issues. 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 - **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
contribute to Camoufox and CANNOT fix bugs in it. 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, - Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
dropdowns, form widgets, fingerprinting *as Camoufox implements it*, dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to checkbox/radio quirks) are out of scope here. Ask the user to first
https://github.com/daijro/camoufox/issues. 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 - Bugs about how Donut *launches, configures, or downloads* Camoufox are
in-scope here. in-scope here.
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT - **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 dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
which exact version was the last working one and what changed. which exact version was the last working one and what changed.
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own - **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 - **Fork-support request**: asks the maintainer to support an alternative
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
"clear", "reasonable", "well-thought-out", etc. "clear", "reasonable", "well-thought-out", etc.
@@ -159,10 +164,14 @@ jobs:
numbers. Never speculate about how subscription / paid-plan checks work. numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS) # 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/` - macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/com.donutbrowser/logs/` - Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%APPDATA%\com.donutbrowser\logs\` - Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these # KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else) # verbatim before suggesting anything else)
@@ -338,7 +347,7 @@ jobs:
The triage classification (`triage.classification`) determines the response shape: 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-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. - `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. - `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. - `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. 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 ## OS-specific log paths
Use ONLY the one matching `triage.operating_system`: Recommend Settings → Advanced → Copy logs first — it bundles the
- macos: `~/Library/Logs/com.donutbrowser/` latest rotated log onto the clipboard without the user hunting for
- linux: `~/.local/share/com.donutbrowser/logs/` a directory. If they want to attach files directly, point at the
- windows: `%APPDATA%\com.donutbrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\com.donutbrowser\logs`) 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. - unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions) ## Known error signatures (apply BEFORE asking generic questions)
@@ -466,7 +479,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Check if first-time contributor - name: Check if first-time contributor
id: check-first-time id: check-first-time
@@ -604,10 +617,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Run opencode - name: Run opencode
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41 uses: anomalyco/opencode/github@76c631d198f9ff620e15468e45f3457d50481b57 #v1.16.2
env: env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -34,10 +34,10 @@ jobs:
run: git config --global core.autocrlf false run: git config --global core.autocrlf false
- name: Checkout repository code - name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+2 -6
View File
@@ -41,10 +41,10 @@ jobs:
run: git config --global core.autocrlf false run: git config --global core.autocrlf false
- name: Checkout repository code - name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
@@ -88,7 +88,6 @@ jobs:
working-directory: ./src-tauri working-directory: ./src-tauri
run: | run: |
cargo build --bin donut-proxy --release cargo build --bin donut-proxy --release
cargo build --bin donut-daemon --release
- name: Copy sidecar binaries to Tauri binaries - name: Copy sidecar binaries to Tauri binaries
shell: bash shell: bash
@@ -97,12 +96,9 @@ jobs:
HOST_TARGET="${{ steps.host_target.outputs.target }}" HOST_TARGET="${{ steps.host_target.outputs.target }}"
if [[ "$HOST_TARGET" == *"windows"* ]]; then 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-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 else
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET} 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-proxy-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-daemon-${HOST_TARGET}
fi fi
- name: Run rustfmt check - name: Run rustfmt check
+46 -31
View File
@@ -22,6 +22,7 @@ on:
permissions: permissions:
contents: read contents: read
models: read
jobs: jobs:
notify: notify:
@@ -31,7 +32,7 @@ jobs:
github.event.workflow_run.conclusion == 'success') github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
@@ -105,21 +106,12 @@ jobs:
fi fi
echo "skip=false" >> "$GITHUB_OUTPUT" 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' if: steps.gate.outputs.skip != 'true'
env: env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }} TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: | 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 \ PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \ | grep -v "^${TAG}$" \
@@ -127,29 +119,52 @@ jobs:
if [ -z "$PREV_TAG" ]; then if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD) PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi 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. - name: Post release announcement to Telegram
# Other commit types (chore, docs, ci, test, deps) are intentionally if: steps.gate.outputs.skip != 'true'
# filtered out to keep the channel focused on user-visible changes. env:
CHANGES="" TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
while IFS= read -r msg; do TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
[ -z "$msg" ] && continue TAG: ${{ steps.tag.outputs.tag }}
case "$msg" in REPO: ${{ github.repository }}
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*) AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n' AI_RESPONSE: ${{ steps.ai.outputs.response }}
;; run: |
esac if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}") echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
if [ -z "$CHANGES" ]; then
CHANGES="• See release notes."$'\n'
fi fi
# HTML-escape the changelog before injecting into Telegram HTML # Prefer the file output — `response` can be truncated for longer summaries.
# mode — commit messages can legitimately contain `<`, `>`, `&`. if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \ 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()))") | python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
VERSION="${TAG}" VERSION="${TAG}"
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled: scan-scheduled:
name: Scheduled Security Scan name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} 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: with:
scan-args: |- scan-args: |-
-r -r
@@ -58,7 +58,7 @@ jobs:
scan-pr: scan-pr:
name: PR Security Scan name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} 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: with:
scan-args: |- scan-args: |-
-r -r
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
security-scan: security-scan:
name: Security Vulnerability Scan name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} 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: with:
scan-args: |- scan-args: |-
-r -r
+25 -169
View File
@@ -23,6 +23,9 @@ jobs:
github.event.workflow_run.conclusion == 'success') github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Determine release tag - name: Determine release tag
id: tag id: tag
env: env:
@@ -40,182 +43,35 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi 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 - name: Install tools
run: | 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 update
sudo apt-get install -y dpkg-dev createrepo-c python3-pip 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 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" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
aws --version
- name: Download packages from GitHub release - name: Publish DEB & RPM repositories to R2
env: 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 }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: | run: |
mkdir -p /tmp/packages # GitHub injects secrets verbatim. If a value was pasted with
gh release download "$TAG" \ # surrounding quotes or a trailing newline — the local .env wraps all
--repo "${{ github.repository }}" \ # four R2_* values in double quotes — it reaches the script malformed:
--pattern "*.deb" \ # e.g. an endpoint of https://"host" yields
--dir /tmp/packages # `Could not connect to the endpoint URL`, and a quoted key yields
gh release download "$TAG" \ # `Unauthorized`. The local run is unaffected because publish-repo.sh
--repo "${{ github.repository }}" \ # sources .env through bash, which strips the quotes; CI has no .env,
--pattern "*.rpm" \ # so strip here. No-op when the secrets are already clean. The script
--dir /tmp/packages # itself is intentionally left untouched.
echo "Downloaded packages:" strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
ls -lh /tmp/packages/ export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
- name: Build DEB repository export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
env: export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
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)"
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -82,7 +82,7 @@ jobs:
- name: Generate release notes with AI - name: Generate release notes with AI
id: generate-notes id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false' 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: with:
prompt-file: .github/prompts/release-notes.prompt.yml prompt-file: .github/prompts/release-notes.prompt.yml
input: | input: |
+11 -10
View File
@@ -20,7 +20,7 @@ jobs:
security-scan: security-scan:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan 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: with:
scan-args: |- scan-args: |-
-r -r
@@ -105,10 +105,10 @@ jobs:
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
@@ -162,7 +162,6 @@ jobs:
working-directory: ./src-tauri working-directory: ./src-tauri
run: | run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release 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 - name: Copy sidecar binaries to Tauri binaries
shell: bash shell: bash
@@ -170,12 +169,9 @@ jobs:
mkdir -p src-tauri/binaries mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then 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-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 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-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-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi fi
- name: Import Apple certificate - name: Import Apple certificate
@@ -250,7 +246,12 @@ jobs:
# Copy sidecar binaries # Copy sidecar binaries
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/" cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.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/" cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
fi
# Copy WebView2Loader if present # Copy WebView2Loader if present
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
@@ -287,7 +288,7 @@ jobs:
contents: write contents: write
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with: with:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
@@ -453,7 +454,7 @@ jobs:
needs: [release, changelog] needs: [release, changelog]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with: with:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
@@ -551,7 +552,7 @@ jobs:
contents: write contents: write
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with: with:
ref: main ref: main
+9 -8
View File
@@ -19,7 +19,7 @@ jobs:
security-scan: security-scan:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan 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: with:
scan-args: |- scan-args: |-
-r -r
@@ -104,10 +104,10 @@ jobs:
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
@@ -161,7 +161,6 @@ jobs:
working-directory: ./src-tauri working-directory: ./src-tauri
run: | run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release 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 - name: Copy sidecar binaries to Tauri binaries
shell: bash shell: bash
@@ -169,12 +168,9 @@ jobs:
mkdir -p src-tauri/binaries mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then 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-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 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-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-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi fi
- name: Import Apple certificate - 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/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-proxy.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/" cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
fi
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/" cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
@@ -283,7 +284,7 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Generate nightly tag - name: Generate nightly tag
id: tag id: tag
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Actions Repository - name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Spell Check Repo - 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 pull-requests: write
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open." 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" stale-pr-label: "stale"
days-before-stale: 30 days-before-stale: 30
days-before-close: 7 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: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.3
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
@@ -73,7 +73,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.3
- name: Start MinIO - name: Start MinIO
run: | run: |
@@ -94,7 +94,7 @@ jobs:
done done
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+154 -4
View File
@@ -11,7 +11,7 @@ donutbrowser/
│ ├── app/ # App router (page.tsx, layout.tsx) │ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI) │ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks │ ├── 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) │ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces │ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri) ├── src-tauri/ # Rust backend (Tauri)
@@ -27,9 +27,7 @@ donutbrowser/
│ │ ├── mcp_server.rs # MCP protocol server │ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler) │ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels │ │ ├── vpn/ # WireGuard tunnels
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management │ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
│ │ ├── downloader.rs # Browser binary downloader │ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi) │ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence │ │ ├── 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 - 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 - 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` - `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 ## 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()`. - 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. - 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. - 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. - 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. - **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. - 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 ## Singletons
@@ -93,6 +182,16 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc. - 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` - 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 ## 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: 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`. 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 ## 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. 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.
+159
View File
@@ -1,6 +1,165 @@
# Changelog # 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) ## v0.24.0 (2026-05-12)
### Features ### Features
+1 -1
View File
@@ -73,7 +73,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
## Key Rules ## 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 - **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 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 - **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"> <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"> <img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a> </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> </p>
<img alt="Donut Browser Preview" src="assets/donut-preview.png" /> <img alt="Donut Browser Preview" src="assets/donut-preview.png" />
@@ -29,7 +26,8 @@
## Features ## Features
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data - **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 - **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- **VPN support** — WireGuard configs per profile - **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 - **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 | | | Apple Silicon | Intel |
|---|---|---| |---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64.dmg) | | **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: Or install via Homebrew:
@@ -58,15 +56,15 @@ brew install --cask donut
### Windows ### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64-portable.zip) [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 ### Linux
| Format | x86_64 | ARM64 | | Format | x86_64 | ARM64 |
|---|---|---| |---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_arm64.deb) | | **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.24.0/Donut-0.24.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut-0.24.0-1.aarch64.rpm) | | **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.24.0/Donut_0.24.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.AppImage) | | **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 --> <!-- install-links-end -->
Or install via package manager: Or install via package manager:
@@ -137,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Hassiy</b></sub> <sub><b>Hassiy</b></sub>
</a> </a>
</td> </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"> <td align="center">
<a href="https://github.com/yb403"> <a href="https://github.com/yb403">
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="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> <sub><b>yb403</b></sub>
</a> </a>
</td> </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"> <td align="center">
<a href="https://github.com/drunkod"> <a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="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> <sub><b>drunkod</b></sub>
</a> </a>
</td> </td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/JorySeverijnse"> <a href="https://github.com/JorySeverijnse">
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="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/*.json",
"src-tauri/src/camoufox/data/*.xml", "src-tauri/src/camoufox/data/*.xml",
"src/i18n/locales/*.json", "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] [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 { import {
type CanActivate, type CanActivate,
type ExecutionContext, type ExecutionContext,
@@ -10,6 +11,13 @@ import type { Request } from "express";
import * as jwt from "jsonwebtoken"; import * as jwt from "jsonwebtoken";
import type { UserContext } from "./user-context.interface.js"; 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() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name); private readonly logger = new Logger(AuthGuard.name);
@@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate {
// Try SYNC_TOKEN first (self-hosted mode) // Try SYNC_TOKEN first (self-hosted mode)
const expectedToken = this.configService.get<string>("SYNC_TOKEN"); 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 = { (request as unknown as Record<string, unknown>).user = {
mode: "self-hosted", mode: "self-hosted",
prefix: "", prefix: "",
@@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate {
algorithms: ["RS256"], algorithms: ["RS256"],
}) as jwt.JwtPayload; }) 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 = { (request as unknown as Record<string, unknown>).user = {
mode: "cloud", mode: "cloud",
prefix: decoded.prefix || `users/${decoded.sub}/`, prefix,
teamPrefix: decoded.teamPrefix || null, teamPrefix,
profileLimit: decoded.profileLimit || 0, profileLimit: decoded.profileLimit || 0,
teamProfileLimit: decoded.teamProfileLimit || 0, teamProfileLimit: decoded.teamProfileLimit || 0,
} satisfies UserContext; } satisfies UserContext;
+8
View File
@@ -6,17 +6,25 @@ export class StatResponseDto {
exists: boolean; exists: boolean;
lastModified?: string; lastModified?: string;
size?: number; 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 { export class PresignUploadRequestDto {
key: string; key: string;
contentType?: string; contentType?: string;
expiresIn?: number; expiresIn?: number;
// Object metadata to sign into the presigned PUT as `x-amz-meta-*`.
metadata?: Record<string, string>;
} }
export class PresignUploadResponseDto { export class PresignUploadResponseDto {
url: string; url: string;
expiresAt: 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 { export class PresignDownloadRequestDto {
+9 -1
View File
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import { import {
Body, Body,
Controller, Controller,
@@ -9,6 +10,13 @@ import {
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { SyncService } from "./sync.service.js"; 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") @Controller("v1/internal")
export class InternalController { export class InternalController {
private readonly internalKey: string | undefined; private readonly internalKey: string | undefined;
@@ -26,7 +34,7 @@ export class InternalController {
@Headers("x-internal-key") key: string, @Headers("x-internal-key") key: string,
@Body() body: { userId: string; maxProfiles: number }, @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"); throw new UnauthorizedException("Invalid internal key");
} }
+40 -4
View File
@@ -54,6 +54,29 @@ import type {
*/ */
const MANIFEST_KEY = ".donut-sync-manifest"; 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() @Injectable()
export class SyncService implements OnModuleInit { export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name); private readonly logger = new Logger(SyncService.name);
@@ -256,6 +279,10 @@ export class SyncService implements OnModuleInit {
exists: true, exists: true,
lastModified: response.LastModified?.toISOString(), lastModified: response.LastModified?.toISOString(),
size: response.ContentLength, 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) { } catch (error: unknown) {
if ( if (
@@ -282,13 +309,19 @@ export class SyncService implements OnModuleInit {
await this.checkProfileLimit(ctx); await this.checkProfileLimit(ctx);
} }
const expiresIn = dto.expiresIn || 3600; const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000); 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({ const command = new PutCmd({
Bucket: this.bucket, Bucket: this.bucket,
Key: key, Key: key,
ContentType: dto.contentType || "application/octet-stream", 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 }); const url = await getSignedUrl(this.s3Client, command, { expiresIn });
@@ -306,6 +339,9 @@ export class SyncService implements OnModuleInit {
return { return {
url, url,
expiresAt: expiresAt.toISOString(), 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); const key = this.scopeKey(ctx, dto.key);
this.validateKeyAccess(ctx, key); this.validateKeyAccess(ctx, key);
const expiresIn = dto.expiresIn || 3600; const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000); const expiresAt = new Date(Date.now() + expiresIn * 1000);
const command = new GetObjectCommand({ const command = new GetObjectCommand({
@@ -431,7 +467,7 @@ export class SyncService implements OnModuleInit {
await this.checkProfileLimit(ctx); await this.checkProfileLimit(ctx);
} }
const expiresIn = dto.expiresIn || 3600; const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000); const expiresAt = new Date(Date.now() + expiresIn * 1000);
const items = await Promise.all( const items = await Promise.all(
@@ -484,7 +520,7 @@ export class SyncService implements OnModuleInit {
dto: PresignDownloadBatchRequestDto, dto: PresignDownloadBatchRequestDto,
ctx: UserContext, ctx: UserContext,
): Promise<PresignDownloadBatchResponseDto> { ): Promise<PresignDownloadBatchResponseDto> {
const expiresIn = dto.expiresIn || 3600; const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000); const expiresAt = new Date(Date.now() + expiresIn * 1000);
const items = await Promise.all( const items = await Promise.all(
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767767207, "lastModified": 1779560665,
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=", "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886", "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github" "type": "github"
}, },
"original": { "original": {
+7 -5
View File
@@ -34,6 +34,7 @@
libsoup_3 libsoup_3
glib glib
gtk3 gtk3
libayatana-appindicator
cairo cairo
gdk-pixbuf gdk-pixbuf
pango pango
@@ -84,6 +85,7 @@
pkgs.gdk-pixbuf pkgs.gdk-pixbuf
pkgs.glib pkgs.glib
pkgs.gtk3 pkgs.gtk3
pkgs.libayatana-appindicator
pkgs.libsoup_3 pkgs.libsoup_3
pkgs.libxkbcommon pkgs.libxkbcommon
pkgs.openssl pkgs.openssl
@@ -94,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" ( pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs pkgConfigLibs ++ map lib.getDev pkgConfigLibs
); );
releaseVersion = "0.24.0"; releaseVersion = "0.26.0";
releaseAppImage = releaseAppImage =
if system == "x86_64-linux" then if system == "x86_64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage";
hash = "sha256-tidp6JvFPCbsPzZldeG4697dzQjhYv83DouzgxS+lKY="; hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
} }
else if system == "aarch64-linux" then else if system == "aarch64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
hash = "sha256-9kHwDafQ+UsKeOeJ+7DbXGGeugogn+NjnhUBYxUeUUo="; hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
} }
else else
null; null;
+7 -12
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser", "name": "donutbrowser",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"version": "0.24.1", "version": "0.26.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 12341", "dev": "next dev --turbopack -p 12341",
@@ -37,6 +37,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.10",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
@@ -54,16 +55,19 @@
"@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7", "ahooks": "^3.9.7",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"color": "^5.0.3", "color": "^5.0.3",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"framer-motion": "^12.38.0",
"i18next": "^26.1.0", "i18next": "^26.1.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "^16.2.6", "next": "^16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -78,6 +82,7 @@
"@biomejs/biome": "2.4.15", "@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1", "@tauri-apps/cli": "~2.11.1",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1", "@types/color": "^4.2.1",
"@types/node": "^25.7.0", "@types/node": "^25.7.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@@ -89,17 +94,7 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"pnpm": { "packageManager": "pnpm@11.2.2",
"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",
"lint-staged": { "lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [ "**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix" "biome check --fix"
+92 -26
View File
@@ -11,6 +11,8 @@ overrides:
fast-xml-parser@<5.7.0: '>=5.7.2' fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2' fast-uri@<3.1.2: '>=3.1.2'
fast-xml-builder@<1.2.0: '>=1.2.0' 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: importers:
@@ -31,6 +33,9 @@ importers:
'@radix-ui/react-popover': '@radix-ui/react-popover':
specifier: ^1.1.15 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) 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': '@radix-ui/react-progress':
specifier: ^1.1.8 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) 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: ahooks:
specifier: ^3.9.7 specifier: ^3.9.7
version: 3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) 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: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -97,6 +105,9 @@ importers:
flag-icons: flag-icons:
specifier: ^7.5.0 specifier: ^7.5.0
version: 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: i18next:
specifier: ^26.1.0 specifier: ^26.1.0
version: 26.1.0(typescript@6.0.3) version: 26.1.0(typescript@6.0.3)
@@ -112,6 +123,9 @@ importers:
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.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: radix-ui:
specifier: ^1.4.3 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) 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': '@tauri-apps/cli':
specifier: ~2.11.1 specifier: ~2.11.1
version: 2.11.1 version: 2.11.1
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/color': '@types/color':
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@@ -212,7 +229,7 @@ importers:
devDependencies: devDependencies:
'@nestjs/cli': '@nestjs/cli':
specifier: ^11.0.21 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': '@nestjs/schematics':
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3) 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) 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: ts-loader:
specifier: ^9.5.7 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: ts-node:
specifier: ^10.9.2 specifier: ^10.9.2
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3) version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
@@ -1671,6 +1688,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies: peerDependencies:
@@ -2060,6 +2090,7 @@ packages:
'@smithy/core@3.24.1': '@smithy/core@3.24.1':
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==} resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
engines: {node: '>=18.0.0'} 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': '@smithy/credential-provider-imds@4.3.1':
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==} resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
@@ -2480,6 +2511,9 @@ packages:
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/color-convert@2.0.4': '@types/color-convert@2.0.4':
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==} resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
@@ -3009,6 +3043,9 @@ packages:
caniuse-lite@1.0.30001792: caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3872,9 +3909,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
js-cookie@3.0.5: js-cookie@3.0.7:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=14'} engines: {node: '>=20'}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4282,6 +4319,15 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'} 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: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -4401,8 +4447,8 @@ packages:
pure-rand@7.0.1: pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
qs@6.15.1: qs@6.15.2:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
radix-ui@1.4.3: radix-ui@1.4.3:
@@ -6421,7 +6467,7 @@ snapshots:
'@tybys/wasm-util': 0.10.2 '@tybys/wasm-util': 0.10.2
optional: true 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: dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3) '@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 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 chokidar: 4.0.3
cli-table3: 0.6.5 cli-table3: 0.6.5
commander: 4.1.1 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 glob: 13.0.6
node-emoji: 1.11.0 node-emoji: 1.11.0
ora: 5.4.1 ora: 5.4.1
tsconfig-paths: 4.2.0 tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3 typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0) webpack: 5.106.0
webpack-node-externals: 3.0.0 webpack-node-externals: 3.0.0
transitivePeerDependencies: transitivePeerDependencies:
- '@minify-html/node' - '@minify-html/node'
@@ -6999,6 +7045,16 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@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)': '@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: 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) '@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/connect': 3.4.38
'@types/node': 25.7.0 '@types/node': 25.7.0
'@types/canvas-confetti@1.9.0': {}
'@types/color-convert@2.0.4': '@types/color-convert@2.0.4':
dependencies: dependencies:
'@types/color-name': 1.1.5 '@types/color-name': 1.1.5
@@ -8125,7 +8183,7 @@ snapshots:
'@types/js-cookie': 3.0.6 '@types/js-cookie': 3.0.6
dayjs: 1.11.20 dayjs: 1.11.20
intersection-observer: 0.12.2 intersection-observer: 0.12.2
js-cookie: 3.0.5 js-cookie: 3.0.7
lodash: 4.18.1 lodash: 4.18.1
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6(react@19.2.6) react-dom: 19.2.6(react@19.2.6)
@@ -8295,7 +8353,7 @@ snapshots:
http-errors: 2.0.1 http-errors: 2.0.1
iconv-lite: 0.7.2 iconv-lite: 0.7.2
on-finished: 2.4.1 on-finished: 2.4.1
qs: 6.15.1 qs: 6.15.2
raw-body: 3.0.2 raw-body: 3.0.2
type-is: 2.0.1 type-is: 2.0.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -8369,6 +8427,8 @@ snapshots:
caniuse-lite@1.0.30001792: {} caniuse-lite@1.0.30001792: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -8733,7 +8793,7 @@ snapshots:
once: 1.4.0 once: 1.4.0
parseurl: 1.3.3 parseurl: 1.3.3
proxy-addr: 2.0.7 proxy-addr: 2.0.7
qs: 6.15.1 qs: 6.15.2
range-parser: 1.2.1 range-parser: 1.2.1
router: 2.2.0 router: 2.2.0
send: 1.2.1 send: 1.2.1
@@ -8804,7 +8864,7 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 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: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
chalk: 4.1.2 chalk: 4.1.2
@@ -8819,7 +8879,7 @@ snapshots:
semver: 7.8.0 semver: 7.8.0
tapable: 2.3.3 tapable: 2.3.3
typescript: 5.9.3 typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0) webpack: 5.106.0
form-data@4.0.5: form-data@4.0.5:
dependencies: dependencies:
@@ -9382,7 +9442,7 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
js-cookie@3.0.5: {} js-cookie@3.0.7: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -9723,6 +9783,14 @@ snapshots:
dependencies: dependencies:
ee-first: 1.1.1 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: once@1.4.0:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
@@ -9834,7 +9902,7 @@ snapshots:
pure-rand@7.0.1: {} pure-rand@7.0.1: {}
qs@6.15.1: qs@6.15.2:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
@@ -10294,7 +10362,7 @@ snapshots:
formidable: 3.5.4 formidable: 3.5.4
methods: 1.1.2 methods: 1.1.2
mime: 2.6.0 mime: 2.6.0
qs: 6.15.1 qs: 6.15.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -10330,15 +10398,13 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.11.0 '@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: dependencies:
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1 jest-worker: 27.5.1
schema-utils: 4.3.3 schema-utils: 4.3.3
terser: 5.47.1 terser: 5.47.1
webpack: 5.106.0(lightningcss@1.32.0) webpack: 5.106.0
optionalDependencies:
lightningcss: 1.32.0
terser@5.47.1: terser@5.47.1:
dependencies: dependencies:
@@ -10391,7 +10457,7 @@ snapshots:
babel-jest: 30.4.1(@babel/core@7.29.0) babel-jest: 30.4.1(@babel/core@7.29.0)
jest-util: 30.4.1 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: dependencies:
chalk: 4.1.2 chalk: 4.1.2
enhanced-resolve: 5.21.3 enhanced-resolve: 5.21.3
@@ -10399,7 +10465,7 @@ snapshots:
semver: 7.8.0 semver: 7.8.0
source-map: 0.7.6 source-map: 0.7.6
typescript: 6.0.3 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): ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
dependencies: dependencies:
@@ -10588,7 +10654,7 @@ snapshots:
webpack-sources@3.4.1: {} webpack-sources@3.4.1: {}
webpack@5.106.0(lightningcss@1.32.0): webpack@5.106.0:
dependencies: dependencies:
'@types/eslint-scope': 3.7.7 '@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9 '@types/estree': 1.0.9
@@ -10612,7 +10678,7 @@ snapshots:
neo-async: 2.6.2 neo-async: 2.6.2
schema-utils: 4.3.3 schema-utils: 4.3.3
tapable: 2.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 watchpack: 2.5.1
webpack-sources: 3.4.1 webpack-sources: 3.4.1
transitivePeerDependencies: transitivePeerDependencies:
+22
View File
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
- sharp - sharp
- sqlite3 - sqlite3
- unrs-resolver - 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]] [[package]]
name = "aes" name = "aes"
version = "0.9.0" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
dependencies = [ dependencies = [
"cipher 0.5.1", "cipher 0.5.2",
"cpubits", "cpubits",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
] ]
@@ -445,9 +445,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "av-scenechange" name = "av-scenechange"
@@ -745,9 +745,9 @@ dependencies = [
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
"alloc-stdlib", "alloc-stdlib",
@@ -756,9 +756,9 @@ dependencies = [
[[package]] [[package]]
name = "brotli-decompressor" name = "brotli-decompressor"
version = "5.0.0" version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
"alloc-stdlib", "alloc-stdlib",
@@ -785,15 +785,15 @@ dependencies = [
[[package]] [[package]]
name = "built" name = "built"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]] [[package]]
name = "byte-unit" name = "byte-unit"
@@ -962,18 +962,18 @@ dependencies = [
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225" checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [ dependencies = [
"cipher 0.5.1", "cipher 0.5.2",
] ]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.62" version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -1068,9 +1068,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
@@ -1103,11 +1103,11 @@ dependencies = [
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.5.1" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [ dependencies = [
"crypto-common 0.2.1", "crypto-common 0.2.2",
"inout 0.2.2", "inout 0.2.2",
] ]
@@ -1405,9 +1405,9 @@ dependencies = [
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [ dependencies = [
"hybrid-array", "hybrid-array",
] ]
@@ -1679,7 +1679,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer 0.12.0", "block-buffer 0.12.0",
"const-oid 0.10.2", "const-oid 0.10.2",
"crypto-common 0.2.1", "crypto-common 0.2.2",
] ]
[[package]] [[package]]
@@ -1726,9 +1726,9 @@ dependencies = [
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1784,9 +1784,9 @@ dependencies = [
[[package]] [[package]]
name = "donutbrowser" name = "donutbrowser"
version = "0.24.1" version = "0.26.0"
dependencies = [ dependencies = [
"aes 0.9.0", "aes 0.9.1",
"aes-gcm", "aes-gcm",
"argon2", "argon2",
"async-socks5", "async-socks5",
@@ -1824,10 +1824,10 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"once_cell", "once_cell",
"playwright", "playwright",
"quick-xml", "quick-xml 0.40.1",
"rand 0.10.1", "rand 0.10.1",
"regex-lite", "regex-lite",
"reqwest 0.13.3", "reqwest 0.13.4",
"resvg", "resvg",
"ring", "ring",
"rusqlite", "rusqlite",
@@ -1838,9 +1838,9 @@ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
"shadowsocks", "shadowsocks",
"smoltcp", "smoltcp",
"subtle",
"sys-locale", "sys-locale",
"sysinfo", "sysinfo",
"tao",
"tar", "tar",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1858,9 +1858,9 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"tokio-util", "tokio-util",
"toml 1.1.2+spec-1.1.0",
"tower", "tower",
"tower-http", "tower-http",
"tray-icon 0.24.0",
"url", "url",
"urlencoding", "urlencoding",
"utoipa", "utoipa",
@@ -1961,9 +1961,9 @@ dependencies = [
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
@@ -2212,9 +2212,9 @@ dependencies = [
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.28" version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
@@ -2863,14 +2863,17 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.11.0" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.17.1",
] ]
[[package]] [[package]]
@@ -2937,9 +2940,9 @@ dependencies = [
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [ dependencies = [
"bytes", "bytes",
"itoa", "itoa",
@@ -2997,9 +3000,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -3086,7 +3089,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.62.2", "windows-core 0.61.2",
] ]
[[package]] [[package]]
@@ -3430,9 +3433,9 @@ dependencies = [
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.24" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [ dependencies = [
"jiff-static", "jiff-static",
"log", "log",
@@ -3443,9 +3446,9 @@ dependencies = [
[[package]] [[package]]
name = "jiff-static" name = "jiff-static"
version = "0.2.24" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3553,12 +3556,13 @@ dependencies = [
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.13.0" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"euclid", "euclid",
"polycool",
"smallvec", "smallvec",
] ]
@@ -3621,9 +3625,9 @@ dependencies = [
[[package]] [[package]]
name = "libfuzzer-sys" name = "libfuzzer-sys"
version = "0.4.12" version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [ dependencies = [
"arbitrary", "arbitrary",
"cc", "cc",
@@ -3647,43 +3651,24 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.16" version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [ dependencies = [
"libc", "libc",
] ]
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.37.0" version = "0.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
dependencies = [ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
"vcpkg", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -3707,9 +3692,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
dependencies = [ dependencies = [
"value-bag", "value-bag",
] ]
@@ -3818,9 +3803,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]] [[package]]
name = "memmap2" name = "memmap2"
@@ -3868,9 +3853,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@@ -3913,15 +3898,14 @@ dependencies = [
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.19.1" version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dpi", "dpi",
"gtk", "gtk",
"keyboard-types", "keyboard-types",
"libxdo",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"objc2-core-foundation", "objc2-core-foundation",
@@ -4037,9 +4021,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-derive" name = "num-derive"
@@ -4381,9 +4365,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "open" name = "open"
version = "5.3.4" version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [ dependencies = [
"dunce", "dunce",
"is-wsl", "is-wsl",
@@ -4393,9 +4377,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.79" version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
@@ -4424,9 +4408,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.115" version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -4659,18 +4643,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.12" version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [ dependencies = [
"pin-project-internal", "pin-project-internal",
] ]
[[package]] [[package]]
name = "pin-project-internal" name = "pin-project-internal"
version = "1.1.12" version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4741,7 +4725,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.14.0", "indexmap 2.14.0",
"quick-xml", "quick-xml 0.39.4",
"serde", "serde",
"time", "time",
] ]
@@ -4797,6 +4781,15 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@@ -5013,6 +5006,15 @@ name = "quick-xml"
version = "0.39.4" version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" 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 = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -5325,9 +5327,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.3" version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@@ -5488,9 +5490,9 @@ dependencies = [
[[package]] [[package]]
name = "rsqlite-vfs" name = "rsqlite-vfs"
version = "0.1.0" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -5498,9 +5500,9 @@ dependencies = [
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.39.0" version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"fallible-iterator", "fallible-iterator",
@@ -5638,15 +5640,6 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@@ -5713,12 +5706,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]] [[package]]
name = "seahash" name = "seahash"
version = "4.1.0" version = "4.1.0"
@@ -5853,9 +5840,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -5918,9 +5905,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.20.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bs58", "bs58",
@@ -5938,9 +5925,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.20.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
@@ -5963,24 +5950,23 @@ dependencies = [
[[package]] [[package]]
name = "serial_test" name = "serial_test"
version = "3.4.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
dependencies = [ dependencies = [
"futures-executor", "futures-executor",
"futures-util", "futures-util",
"log", "log",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"scc",
"serial_test_derive", "serial_test_derive",
] ]
[[package]] [[package]]
name = "serial_test_derive" name = "serial_test_derive"
version = "3.4.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -6113,9 +6099,9 @@ dependencies = [
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "sigchld" name = "sigchld"
@@ -6227,9 +6213,9 @@ dependencies = [
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -6304,9 +6290,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlite-wasm-rs" name = "sqlite-wasm-rs"
version = "0.5.3" version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [ dependencies = [
"cc", "cc",
"js-sys", "js-sys",
@@ -6448,9 +6434,9 @@ dependencies = [
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.39.1" version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6" checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [ dependencies = [
"libc", "libc",
"memchr", "memchr",
@@ -6497,9 +6483,9 @@ dependencies = [
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.35.2" version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2",
@@ -6554,9 +6540,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.45" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -6571,9 +6557,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.11.1" version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -6586,6 +6572,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"image",
"jni", "jni",
"libc", "libc",
"log", "log",
@@ -6599,7 +6586,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest 0.13.3", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -6612,7 +6599,7 @@ dependencies = [
"tauri-utils", "tauri-utils",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tray-icon 0.23.1", "tray-icon",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
@@ -6622,9 +6609,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.6.1" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@@ -6643,9 +6630,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.6.1" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli",
@@ -6670,9 +6657,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.6.1" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -6684,9 +6671,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin" name = "tauri-plugin"
version = "2.6.1" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"glob", "glob",
@@ -6873,9 +6860,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.1" version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@@ -6898,9 +6885,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.11.1" version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@@ -6924,9 +6911,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.9.1" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
@@ -7383,9 +7370,9 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.10" version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"bytes", "bytes",
@@ -7486,27 +7473,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "tree_magic_mini" name = "tree_magic_mini"
version = "3.2.2" version = "3.2.2"
@@ -7564,9 +7530,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "uds_windows" name = "uds_windows"
@@ -7664,9 +7630,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.2" version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]] [[package]]
name = "unicode-vo" name = "unicode-vo"
@@ -7824,9 +7790,9 @@ dependencies = [
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.1" version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@@ -8091,7 +8057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml", "quick-xml 0.39.4",
"quote", "quote",
] ]
@@ -9040,9 +9006,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [ dependencies = [
"stable_deref_trait", "stable_deref_trait",
"yoke-derive", "yoke-derive",
@@ -9063,9 +9029,9 @@ dependencies = [
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "5.15.0" version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-executor", "async-executor",
@@ -9098,9 +9064,9 @@ dependencies = [
[[package]] [[package]]
name = "zbus_macros" name = "zbus_macros"
version = "5.15.0" version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate 3.5.0",
"proc-macro2", "proc-macro2",
@@ -9124,18 +9090,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.48" version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.48" version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -9144,9 +9110,9 @@ dependencies = [
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [ dependencies = [
"zerofrom-derive", "zerofrom-derive",
] ]
@@ -9331,9 +9297,9 @@ dependencies = [
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.11.0" version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
dependencies = [ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
@@ -9345,9 +9311,9 @@ dependencies = [
[[package]] [[package]]
name = "zvariant_derive" name = "zvariant_derive"
version = "5.11.0" version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate 3.5.0",
"proc-macro2", "proc-macro2",
@@ -9358,9 +9324,9 @@ dependencies = [
[[package]] [[package]]
name = "zvariant_utils" name = "zvariant_utils"
version = "3.3.1" version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+9 -13
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "donutbrowser" name = "donutbrowser"
version = "0.24.1" version = "0.26.0"
description = "Simple Yet Powerful Anti-Detect Browser" description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"] authors = ["zhom@github"]
edition = "2021" edition = "2021"
@@ -24,10 +24,6 @@ path = "src/main.rs"
name = "donut-proxy" name = "donut-proxy"
path = "src/bin/proxy_server.rs" path = "src/bin/proxy_server.rs"
[[bin]]
name = "donut-daemon"
path = "src/bin/donut_daemon.rs"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
resvg = "0.47" resvg = "0.47"
@@ -35,7 +31,7 @@ resvg = "0.47"
[dependencies] [dependencies]
serde_json = "1" serde_json = "1"
serde = { version = "1", features = ["derive"] } 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-opener = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
@@ -85,9 +81,10 @@ aes-gcm = "0.10"
aes = "0.9" aes = "0.9"
cbc = "0.2" cbc = "0.2"
ring = "0.17" ring = "0.17"
subtle = "2"
sha2 = "0.11" sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] } 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"] } hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1" http-body-util = "0.1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
@@ -98,21 +95,20 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
# Wayfern CDP integration # Wayfern CDP integration
tokio-tungstenite = { version = "0.29", features = ["native-tls"] } tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
rusqlite = { version = "0.39", features = ["bundled"] } rusqlite = { version = "0.40", features = ["bundled"] }
serde_yaml = "0.9" serde_yaml = "0.9"
toml = "1.1"
thiserror = "2.0" thiserror = "2.0"
regex-lite = "0.1" regex-lite = "0.1"
tempfile = "3" tempfile = "3"
maxminddb = "0.28" maxminddb = "0.28"
quick-xml = { version = "0.39", features = ["serialize"] } quick-xml = { version = "0.40", features = ["serialize"] }
# VPN support # VPN support
boringtun = "0.7" boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] } 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 decoding (main-process system tray)
tray-icon = "0.24"
tao = "0.35"
image = "0.25" image = "0.25"
dirs = "6" dirs = "6"
crossbeam-channel = "0.5" crossbeam-channel = "0.5"
@@ -144,7 +140,7 @@ windows = { version = "0.62", features = [
[dev-dependencies] [dev-dependencies]
tempfile = "3.24.0" tempfile = "3.24.0"
wiremock = "0.6" wiremock = "0.6"
hyper = { version = "1.8", features = ["full"] } hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1" http-body-util = "0.1"
tower = "0.5" tower = "0.5"
+5 -11
View File
@@ -5,7 +5,7 @@ fn main() {
// This allows running cargo test without building the frontend first // This allows running cargo test without building the frontend first
ensure_dist_folder_exists(); 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(); generate_tray_icons();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -93,19 +93,13 @@ fn external_binaries_exist() -> bool {
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries"); let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
// Check for all required external binaries (must match tauri.conf.json externalBin) // Check for all required external binaries (must match tauri.conf.json externalBin)
let (donut_proxy_name, donut_daemon_name) = if target.contains("windows") { let donut_proxy_name = if target.contains("windows") {
( format!("donut-proxy-{}.exe", target)
format!("donut-proxy-{}.exe", target),
format!("donut-daemon-{}.exe", target),
)
} else { } else {
( format!("donut-proxy-{}", target)
format!("donut-proxy-{}", target),
format!("donut-daemon-{}", 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() { fn ensure_dist_folder_exists() {
+11
View File
@@ -21,6 +21,17 @@
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"opener:default", "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", "fs:default",
"shell:allow-execute", "shell:allow-execute",
"shell:allow-kill", "shell:allow-kill",
-1
View File
@@ -77,4 +77,3 @@ function copyBinary(baseName) {
} }
copyBinary("donut-proxy"); copyBinary("donut-proxy");
copyBinary("donut-daemon");
-3
View File
@@ -102,6 +102,3 @@ copy_binary() {
# Copy donut-proxy binary # Copy donut-proxy binary
copy_binary "donut-proxy" 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::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig; use crate::camoufox_manager::CamoufoxConfig;
use crate::daemon_ws::{ws_handler, WsState};
use crate::events; use crate::events;
use crate::group_manager::GROUP_MANAGER; use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager; use crate::profile::manager::ProfileManager;
@@ -59,13 +58,25 @@ pub struct ApiProfileResponse {
pub struct CreateProfileRequest { pub struct CreateProfileRequest {
pub name: String, pub name: String,
pub browser: 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 proxy_id: Option<String>,
pub vpn_id: Option<String>, pub vpn_id: Option<String>,
pub launch_hook: Option<String>, pub launch_hook: Option<String>,
pub release_type: 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)] #[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>, 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)] #[schema(value_type = Object)]
pub wayfern_config: Option<serde_json::Value>, pub wayfern_config: Option<serde_json::Value>,
pub group_id: Option<String>, pub group_id: Option<String>,
@@ -75,7 +86,9 @@ pub struct CreateProfileRequest {
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateProfileRequest { pub struct UpdateProfileRequest {
pub name: Option<String>, 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 version: Option<String>,
pub proxy_id: Option<String>, pub proxy_id: Option<String>,
pub vpn_id: Option<String>, pub vpn_id: Option<String>,
@@ -87,6 +100,8 @@ pub struct UpdateProfileRequest {
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>, pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>, pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -215,6 +230,20 @@ struct OpenUrlRequest {
url: String, 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)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
@@ -226,6 +255,7 @@ struct OpenUrlRequest {
run_profile, run_profile,
open_url_in_profile, open_url_in_profile,
kill_profile, kill_profile,
import_profile_cookies,
get_groups, get_groups,
get_group, get_group,
create_group, create_group,
@@ -268,6 +298,8 @@ struct OpenUrlRequest {
RunProfileResponse, RunProfileResponse,
RunProfileRequest, RunProfileRequest,
OpenUrlRequest, OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
ProxySettings, ProxySettings,
)), )),
tags( tags(
@@ -277,6 +309,7 @@ struct OpenUrlRequest {
(name = "proxies", description = "Proxy management endpoints"), (name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"), (name = "vpns", description = "VPN management endpoints"),
(name = "browsers", description = "Browser management endpoints"), (name = "browsers", description = "Browser management endpoints"),
(name = "cookies", description = "Cookie management endpoints"),
), ),
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
)] )]
@@ -363,6 +396,7 @@ impl ApiServer {
.routes(routes!(run_profile)) .routes(routes!(run_profile))
.routes(routes!(open_url_in_profile)) .routes(routes!(open_url_in_profile))
.routes(routes!(kill_profile)) .routes(routes!(kill_profile))
.routes(routes!(import_profile_cookies))
.routes(routes!(get_groups, create_group)) .routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group)) .routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_tags)) .routes(routes!(get_tags))
@@ -385,22 +419,23 @@ impl ApiServer {
let api = ApiDoc::openapi(); let api = ApiDoc::openapi();
let v1_routes = v1_routes 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( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
auth_middleware, auth_middleware,
)) ))
.layer(middleware::from_fn(terms_check_middleware)); .layer(middleware::from_fn(terms_check_middleware));
// Create WebSocket route with its own state (no auth required for daemon IPC) let api_for_v1 = api.clone();
let ws_state = WsState::new();
let ws_routes = Router::new()
.route("/events", get(ws_handler))
.with_state(ws_state);
let app = Router::new() let app = Router::new()
.merge(v1_routes) .merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) })) .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 // Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was, // their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers. // and how long it took. Never logs request bodies or auth headers.
@@ -490,8 +525,14 @@ async fn auth_middleware(
} }
}; };
// Compare tokens // Constant-time comparison so the auth check doesn't leak the shared-prefix
if token != stored_token { // 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"); log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
@@ -532,6 +573,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
response 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 // Global API server instance
lazy_static! { lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new())); 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()) 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 // API Handlers - Profiles
#[utoipa::path( #[utoipa::path(
get, get,
@@ -598,10 +661,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
process_id: profile.process_id, process_id: profile.process_id,
last_launch: profile.last_launch, last_launch: profile.last_launch,
release_type: profile.release_type.clone(), release_type: profile.release_type.clone(),
camoufox_config: profile camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id.clone(), group_id: profile.group_id.clone(),
tags: profile.tags.clone(), tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id 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, process_id: profile.process_id,
last_launch: profile.last_launch, last_launch: profile.last_launch,
release_type: profile.release_type.clone(), release_type: profile.release_type.clone(),
camoufox_config: profile camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id.clone(), group_id: profile.group_id.clone(),
tags: profile.tags.clone(), tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id 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( #[utoipa::path(
post, post,
path = "/v1/profiles", path = "/v1/profiles",
request_body = CreateProfileRequest, request_body = CreateProfileRequest,
responses( responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse), (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 = 401, description = "Unauthorized"),
(status = 402, description = "Selected proxy requires payment"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
), ),
security( security(
@@ -695,6 +762,34 @@ async fn create_profile(
) -> Result<Json<ApiProfileResponse>, StatusCode> { ) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance(); 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 // Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config { let camoufox_config = if let Some(config) = &request.camoufox_config {
serde_json::from_value(config.clone()).ok() serde_json::from_value(config.clone()).ok()
@@ -709,13 +804,25 @@ async fn create_profile(
None 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 // Create profile using the async create_profile_with_group method
match profile_manager match profile_manager
.create_profile_with_group( .create_profile_with_group(
&state.app_handle, &state.app_handle,
&request.name, &request.name,
&request.browser, &request.browser,
&request.version, &version,
request.release_type.as_deref().unwrap_or("stable"), request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(), request.proxy_id.clone(),
request.vpn_id.clone(), request.vpn_id.clone(),
@@ -758,10 +865,7 @@ async fn create_profile(
process_id: profile.process_id, process_id: profile.process_id,
last_launch: profile.last_launch, last_launch: profile.last_launch,
release_type: profile.release_type, release_type: profile.release_type,
camoufox_config: profile camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id, group_id: profile.group_id,
tags: profile.tags, tags: profile.tags,
is_running: false, is_running: false,
@@ -866,6 +970,14 @@ async fn update_profile(
} }
if let Some(camoufox_config) = request.camoufox_config { 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); let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
match config { match config {
Ok(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 // Return updated profile
get_profile(Path(id), State(state)).await get_profile(Path(id), State(state)).await
} }
@@ -1675,7 +1796,7 @@ async fn run_profile(
Json(request): Json<RunProfileRequest>, Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> { ) -> Result<Json<RunProfileResponse>, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription() .can_use_browser_automation()
.await .await
{ {
return Err(StatusCode::PAYMENT_REQUIRED); return Err(StatusCode::PAYMENT_REQUIRED);
@@ -1715,13 +1836,15 @@ async fn run_profile(
port port
}; };
// Use the same launch method as the main app, but with remote debugging enabled // Use the same launch path as the main app, but force a fresh instance with
match crate::browser_runner::launch_browser_profile_with_debugging( // 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(), state.app_handle.clone(),
profile.clone(), profile.clone(),
url, url,
Some(remote_debugging_port), Some(remote_debugging_port),
headless, headless,
true,
) )
.await .await
{ {
@@ -1759,7 +1882,7 @@ async fn open_url_in_profile(
Json(request): Json<OpenUrlRequest>, Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription() .can_use_browser_automation()
.await .await
{ {
return Err(StatusCode::PAYMENT_REQUIRED); return Err(StatusCode::PAYMENT_REQUIRED);
@@ -1785,6 +1908,7 @@ async fn open_url_in_profile(
responses( responses(
(status = 204, description = "Browser process killed successfully"), (status = 204, description = "Browser process killed successfully"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan required"),
(status = 404, description = "Profile not found"), (status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
), ),
@@ -1797,6 +1921,15 @@ async fn kill_profile(
Path(id): Path<String>, Path(id): Path<String>,
State(state): State<ApiServerState>, State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> { ) -> 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 profile_manager = ProfileManager::instance();
let profiles = profile_manager let profiles = profile_manager
.list_profiles() .list_profiles()
@@ -1818,6 +1951,77 @@ async fn kill_profile(
Ok(StatusCode::NO_CONTENT) 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 // API Handler - Download Browser
#[utoipa::path( #[utoipa::path(
post, post,
@@ -1961,3 +2165,57 @@ async fn refresh_wayfern_token(
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token })) 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() 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 { pub fn app_name() -> &'static str {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
"DonutBrowserDev" "DonutBrowserDev"
@@ -46,6 +63,10 @@ pub fn data_dir() -> PathBuf {
return PathBuf::from(dir); return PathBuf::from(dir);
} }
if let Some(root) = data_root() {
return root.join("data");
}
if let Some(dir) = portable_dir() { if let Some(dir) = portable_dir() {
return dir.join("data"); return dir.join("data");
} }
@@ -65,6 +86,10 @@ pub fn cache_dir() -> PathBuf {
return PathBuf::from(dir); return PathBuf::from(dir);
} }
if let Some(root) = data_root() {
return root.join("cache");
}
if let Some(dir) = portable_dir() { if let Some(dir) = portable_dir() {
return dir.join("cache"); 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 /// `LogDir` target used in the plugin builder so the path matches what's
/// actually on disk for this OS. /// actually on disk for this OS.
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf { pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
if let Some(dir) = log_dir_override() {
return dir;
}
use tauri::Manager; use tauri::Manager;
handle handle
.path() .path()
+1
View File
@@ -703,6 +703,7 @@ mod tests {
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None, 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, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None, created_at: None,
updated_at: None,
}; };
let path = profile.get_profile_data_path(&profiles_dir); 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::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER; use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager}; use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize; use serde::Serialize;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System; 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 struct BrowserRunner {
pub profile_manager: &'static ProfileManager, pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -448,6 +381,7 @@ impl BrowserRunner {
camoufox_config, camoufox_config,
url, url,
override_profile_path, override_profile_path,
remote_debugging_port,
headless, headless,
) )
.await .await
@@ -612,32 +546,12 @@ impl BrowserRunner {
wayfern_config.proxy wayfern_config.proxy
); );
// Decide whether to (re)generate the Wayfern fingerprint for this // Check if we need to generate a new fingerprint on every launch
// launch. Two triggers:
//
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
// randomization the user opted into.
// 2. The fingerprint hasn't been refreshed since the most recent
// rollover instant. We check the per-profile marker file first
// (`.last-fp-refresh`); if it's absent we fall back to
// `profile.created_at` so brand-new profiles don't immediately
// regenerate the fingerprint they were just created with.
// Profiles with neither (truly legacy) are treated as ancient
// and refresh on next launch — once.
let mut updated_profile = profile.clone(); let mut updated_profile = profile.clone();
let stale_threshold = most_recent_rollover_epoch(); if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
let profile_id_str = profile.id.to_string();
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
let effective_last_refresh =
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
if randomize_every_launch || is_stale_profile {
log::info!( log::info!(
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})", "Generating random fingerprint for Wayfern profile: {}",
profile.name, profile.name
randomize_every_launch,
is_stale_profile
); );
// Create a config copy without the existing fingerprint to force generation of a new one // 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 // Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone()); 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. // Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default(); let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint); updated_wayfern_config.fingerprint = Some(new_fingerprint);
// Preserve the user's randomize-on-launch preference rather than // Preserve the randomize flag so it persists across launches
// forcing it on. The rollover path must not silently flip this updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
// flag for users who only opted into the scheduled refresh. // Preserve the OS setting so it's used for future fingerprint generation
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
if wayfern_config.os.is_some() { if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone(); updated_wayfern_config.os = wayfern_config.os.clone();
} }
@@ -754,6 +656,24 @@ impl BrowserRunner {
let process_id = wayfern_result.processId.unwrap_or(0); let process_id = wayfern_result.processId.unwrap_or(0);
log::info!("Wayfern launched successfully with PID: {process_id}"); 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 // Update profile with the process info
updated_profile.process_id = Some(process_id); updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
@@ -935,57 +855,19 @@ impl BrowserRunner {
remote_debugging_port: Option<u16>, remote_debugging_port: Option<u16>,
headless: bool, headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches // Camoufox and Wayfern start (and PID-reconcile) their own local proxy
let upstream_proxy = self // inside `launch_browser_internal`, so we hand it None here rather than
.resolve_launch_proxy(profile) // staging a second, orphaned proxy worker.
.await self
.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
.launch_browser_internal( .launch_browser_internal(
app_handle.clone(), app_handle,
profile, profile,
url, url,
internal_proxy_settings.as_ref(), None,
remote_debugging_port, remote_debugging_port,
headless, headless,
) )
.await; .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
} }
pub async fn launch_or_open_url( pub async fn launch_or_open_url(
@@ -1582,7 +1464,10 @@ impl BrowserRunner {
} }
if profile.password_protected { 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 { } else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
} }
@@ -1924,7 +1809,10 @@ impl BrowserRunner {
} }
if profile.password_protected { 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 { } else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
} }
@@ -2389,6 +2277,17 @@ pub async fn launch_browser_profile(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
profile: BrowserProfile, profile: BrowserProfile,
url: Option<String>, 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> { ) -> Result<BrowserProfile, String> {
log::info!( log::info!(
"Launch request received for profile: {} (ID: {})", "Launch request received for profile: {} (ID: {})",
@@ -2418,9 +2317,6 @@ pub async fn launch_browser_profile(
let browser_runner = BrowserRunner::instance(); 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 // 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 let profile_for_launch = match browser_runner
.profile_manager .profile_manager
@@ -2442,112 +2338,36 @@ pub async fn launch_browser_profile(
profile_for_launch.id 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!( log::info!(
"Starting browser launch for profile: {} (ID: {})", "Starting browser launch for profile: {} (ID: {})",
profile_for_launch.name, profile_for_launch.name,
profile_for_launch.id profile_for_launch.id
); );
// Launch browser or open URL in existing instance // Launch browser or open URL in existing instance. Camoufox and Wayfern
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| { // 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); log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
// Emit a failure event to clear loading states in the frontend // 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] #[tauri::command]
pub async fn open_url_with_profile( pub async fn open_url_with_profile(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
+6 -5
View File
@@ -376,11 +376,12 @@ impl CamoufoxConfigBuilder {
(config, target_os) (config, target_os)
}; };
// Add random window history length // Note: we used to spoof `window.history.length` to a random value in
config.insert( // [1, 5] here. Newer Camoufox builds clamp the docShell session history
"window.history.length".to_string(), // to this value, which disables the toolbar back/forward buttons when
serde_json::json!(rng.random_range(1..=5)), // 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 // Add fonts
if !self.custom_fonts_only { if !self.custom_fonts_only {
+128 -22
View File
@@ -200,6 +200,7 @@ impl CamoufoxManager {
} }
/// Launch Camoufox browser by directly spawning the process /// Launch Camoufox browser by directly spawning the process
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox( pub async fn launch_camoufox(
&self, &self,
_app_handle: &AppHandle, _app_handle: &AppHandle,
@@ -207,6 +208,7 @@ impl CamoufoxManager {
profile_path: &str, profile_path: &str,
config: &CamoufoxConfig, config: &CamoufoxConfig,
url: Option<&str>, url: Option<&str>,
remote_debugging_port: Option<u16>,
headless: bool, headless: bool,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint { 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}"))?; .map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Parse the fingerprint config JSON // 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) serde_json::from_str(&custom_config)
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?; .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 // Convert to environment variables using CAMOU_CONFIG chunking
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config) 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}"))?; .map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
@@ -243,7 +251,10 @@ impl CamoufoxManager {
.to_string(), .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}")); args.push(format!("--remote-debugging-port={cdp_port}"));
// Add URL if provided // Add URL if provided
@@ -264,13 +275,33 @@ impl CamoufoxManager {
args 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); let mut command = TokioCommand::new(&executable_path);
command command
.args(&args) .args(&args)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null());
.stderr(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 // Add environment variables
for (key, value) in &env_vars { for (key, value) in &env_vars {
@@ -287,7 +318,7 @@ impl CamoufoxManager {
} }
} }
let child = command let mut child = command
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?; .map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
@@ -296,6 +327,34 @@ impl CamoufoxManager {
log::info!("Camoufox launched with PID: {:?}", process_id); 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 // Store the instance
let instance = CamoufoxInstance { let instance = CamoufoxInstance {
id: instance_id.clone(), id: instance_id.clone(),
@@ -557,28 +616,28 @@ impl CamoufoxManager {
for (id, instance) in inner.instances.iter() { for (id, instance) in inner.instances.iter() {
if let Some(process_id) = instance.process_id { if let Some(process_id) = instance.process_id {
// Check if the process is still alive
if !self.is_server_running(process_id).await { if !self.is_server_running(process_id).await {
// Process is dead log::info!(
// Camoufox instance is no longer running "Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
id,
process_id,
instance.profile_path
);
dead_instances.push(id.clone()); dead_instances.push(id.clone());
instances_to_remove.push(id.clone()); instances_to_remove.push(id.clone());
} }
} else { } else {
// No process_id means it's likely a dead instance log::info!("Camoufox instance {} has no PID, marking as dead", id);
// Camoufox instance has no PID, marking as dead
dead_instances.push(id.clone()); dead_instances.push(id.clone());
instances_to_remove.push(id.clone()); instances_to_remove.push(id.clone());
} }
} }
} }
// Remove dead instances
if !instances_to_remove.is_empty() { if !instances_to_remove.is_empty() {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
for id in &instances_to_remove { for id in &instances_to_remove {
inner.instances.remove(id); inner.instances.remove(id);
// Removed dead Camoufox instance
} }
} }
@@ -612,6 +671,7 @@ impl CamoufoxManager {
} }
impl CamoufoxManager { impl CamoufoxManager {
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox_profile( pub async fn launch_camoufox_profile(
&self, &self,
app_handle: AppHandle, app_handle: AppHandle,
@@ -619,6 +679,7 @@ impl CamoufoxManager {
config: CamoufoxConfig, config: CamoufoxConfig,
url: Option<String>, url: Option<String>,
override_profile_path: Option<std::path::PathBuf>, override_profile_path: Option<std::path::PathBuf>,
remote_debugging_port: Option<u16>,
headless: bool, headless: bool,
) -> Result<CamoufoxLaunchResult, String> { ) -> Result<CamoufoxLaunchResult, String> {
// Get profile path // Get profile path
@@ -662,24 +723,67 @@ impl CamoufoxManager {
} }
} }
// Write explicit proxy prefs to user.js so Firefox always uses the local // Patch user.js with Camoufox-specific overrides on every launch. This
// donut-proxy and never falls back to stale proxy settings baked into prefs.js // always runs (not gated on the proxy being set) because Camoufox's
// from a previous session. user.js values override prefs.js on every launch. // bundled camoufox.cfg ships defaults that break basic browser features
if let Some(proxy_str) = &config.proxy { // and we need to override them per-profile.
{
let user_js_path = profile_path.join("user.js"); let user_js_path = profile_path.join("user.js");
let mut prefs = String::new(); 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) { 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() { 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_str(line);
prefs.push('\n'); prefs.push('\n');
} }
} }
} }
// 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",
);
// 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",
);
// 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) { if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1"); let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080); let port = parsed.port().unwrap_or(8080);
@@ -705,10 +809,11 @@ impl CamoufoxManager {
user_pref(\"network.proxy.no_proxies_on\", \"\");\n" user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
)); ));
} }
}
}
if let Err(e) = std::fs::write(&user_js_path, prefs) { if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write proxy prefs to user.js: {e}"); log::warn!("Failed to write user.js: {e}");
}
} }
} }
@@ -719,6 +824,7 @@ impl CamoufoxManager {
&profile_path_str, &profile_path_str,
&config, &config,
url.as_deref(), url.as_deref(),
remote_debugging_port,
headless, headless,
) )
.await .await
+210 -23
View File
@@ -21,6 +21,76 @@ use crate::sync;
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com"; pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
pub const CLOUD_SYNC_URL: &str = "https://sync.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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudUser { pub struct CloudUser {
pub id: String, pub id: String,
@@ -46,6 +116,36 @@ pub struct CloudUser {
pub team_name: Option<String>, pub team_name: Option<String>,
#[serde(rename = "teamRole", default)] #[serde(rename = "teamRole", default)]
pub team_role: Option<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -413,7 +513,18 @@ impl CloudAuthManager {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); 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 let result: DeviceCodeExchangeResponse = response
@@ -637,39 +748,83 @@ impl CloudAuthManager {
state.is_some() 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; let state = self.state.lock().await;
match &*state { state.as_ref().map(|auth| auth.user.entitlements())
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
} }
/// 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. /// 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 { pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() { match self.state.try_lock() {
Ok(state) => match &*state { Ok(state) => state
Some(auth) => { .as_ref()
auth.user.plan != "free" .map(|auth| auth.user.entitlements().active)
&& (auth.user.subscription_status == "active" .unwrap_or(false),
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
},
Err(_) => 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 { pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
let host_os = crate::profile::types::get_host_os(); let host_os = crate::profile::types::get_host_os();
match fingerprint_os { match fingerprint_os {
None => true, None => true,
Some(os) if os == host_os => 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(()); return Ok(());
} }
let token = self let result = self
.api_call_with_retry(|access_token| { .api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start"); let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
// Bound the request: without a timeout, an unreachable // Bound the request: without a timeout, an unreachable
@@ -1029,7 +1184,31 @@ impl CloudAuthManager {
Ok(result.token) 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; let mut wt = self.wayfern_token.lock().await;
*wt = Some(token); *wt = Some(token);
@@ -1163,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
code: String, code: String,
) -> Result<CloudAuthState, 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; let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
log::info!( log::info!(
@@ -1198,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
let _ = crate::events::emit_empty("cloud-auth-changed"); let _ = crate::events::emit_empty("cloud-auth-changed");
let _ = &app_handle; let _ = &app_handle;
state.user.entitlements = Some(state.user.entitlements());
Ok(state) Ok(state)
} }
#[tauri::command] #[tauri::command]
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> { 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] #[tauri::command]
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> { 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] #[tauri::command]
-351
View File
@@ -1,351 +0,0 @@
use directories::ProjectDirs;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
// Get log directory (use data directory instead of /tmp)
let log_dir = get_data_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("logs");
fs::create_dir_all(&log_dir)?;
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{daemon_path}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>{log_dir}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path = daemon_path.display(),
log_dir = log_dir.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn get_plist_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
if plist_path.exists() {
// First unload the launch agent if it's loaded
let _ = unload_launch_agent();
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
get_plist_path().is_some_and(|p| p.exists())
}
#[cfg(target_os = "macos")]
pub fn load_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Launch agent plist does not exist",
));
}
// Use launchctl load to start the daemon via launchd
// The -w flag writes the "disabled" key to the override plist
let output = Command::new("launchctl")
.args(["load", "-w"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// "already loaded" is not an error condition for us
if !stderr.contains("already loaded") {
return Err(io::Error::other(format!(
"launchctl load failed: {}",
stderr
)));
}
}
log::info!("Loaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Ok(());
}
let output = Command::new("launchctl")
.args(["unload"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Not being loaded is not an error
if !stderr.contains("Could not find specified service") {
log::warn!("launchctl unload warning: {}", stderr);
}
}
log::info!("Unloaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "linux")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let escaped_daemon_path = daemon_path
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod autostart;
pub mod services;
pub mod tray;
-51
View File
@@ -1,51 +0,0 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
-204
View File
@@ -1,204 +0,0 @@
use std::process::Command;
use tray_icon::menu::{Menu, MenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub fn load_icon() -> Icon {
// On Windows, use the full-color icon so it renders well on dark taskbars.
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
#[cfg(target_os = "windows")]
let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png");
#[cfg(not(target_os = "windows"))]
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&quit_item).unwrap();
Self { menu, quit_item }
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
let builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()));
// On macOS, template icons are automatically colored by the system for light/dark mode
#[cfg(target_os = "macos")]
let builder = builder.with_icon_as_template(true);
builder.build().expect("Failed to create tray icon")
}
/// Resolve the .app bundle path from the current daemon executable.
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos_dir = exe.parent()?;
let contents_dir = macos_dir.parent()?;
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
Some(app_dir.to_path_buf())
} else {
None
}
}
pub fn open_gui() {
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
// Launch the GUI binary directly. The daemon lives inside the same .app
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
// of launching the GUI. Directly running the binary avoids macOS's app
// activation machinery. The single-instance Tauri plugin in the GUI
// handles deduplication if a GUI instance is already running.
if let Some(app_bundle) = get_app_bundle_path() {
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
if gui_binary.exists() {
let _ = Command::new(&gui_binary).spawn();
} else {
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
}
} else {
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
}
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let app_path = exe_dir.join("donutbrowser.exe");
if app_path.exists() {
let _ = Command::new(app_path).spawn();
return;
}
}
}
let paths = [
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
Some(PathBuf::from(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
fn read_gui_pid() -> Option<u32> {
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
let content = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
val.get("gui_pid")?.as_u64().map(|p| p as u32)
}
fn kill_gui_by_pid() -> bool {
let Some(pid) = read_gui_pid() else {
return false;
};
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
ret == 0
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
if kill_gui_by_pid() {
log::info!("[daemon] GUI killed by PID");
return;
}
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
#[cfg(target_os = "macos")]
{
// Use spawn() instead of output() to avoid blocking the event loop.
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut\" to quit"])
.spawn();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
-152
View File
@@ -1,152 +0,0 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
pub struct DaemonClient {
app_handle: tauri::AppHandle,
connected: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
daemon_port: Arc<Mutex<Option<u16>>>,
}
impl DaemonClient {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self {
app_handle,
connected: Arc::new(AtomicBool::new(false)),
shutdown: Arc::new(AtomicBool::new(false)),
daemon_port: Arc::new(Mutex::new(None)),
}
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::SeqCst)
}
pub async fn connect(&self, port: u16) -> Result<(), String> {
*self.daemon_port.lock().await = Some(port);
let url = format!("ws://127.0.0.1:{}/ws/events", port);
log::info!("[daemon-client] Connecting to daemon at {}", url);
let (ws_stream, _) = connect_async(&url)
.await
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
self.connected.store(true, Ordering::SeqCst);
log::info!("[daemon-client] Connected to daemon");
let (mut write, mut read) = ws_stream.split();
let app_handle = self.app_handle.clone();
let connected = self.connected.clone();
let shutdown = self.shutdown.clone();
// Spawn task to handle incoming messages
tokio::spawn(async move {
while !shutdown.load(Ordering::SeqCst) {
match read.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"event" => {
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
// Forward event to Tauri frontend
if let Err(e) = app_handle.emit(&event, payload) {
log::error!("[daemon-client] Failed to emit event: {}", e);
}
}
}
"connected" => {
log::info!("[daemon-client] Received connection confirmation");
}
"pong" => {
log::debug!("[daemon-client] Received pong");
}
_ => {
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Some(Ok(Message::Ping(data))) => {
log::debug!("[daemon-client] Received ping");
if let Err(e) = write.send(Message::Pong(data)).await {
log::error!("[daemon-client] Failed to send pong: {}", e);
break;
}
}
Some(Ok(Message::Close(_))) => {
log::info!("[daemon-client] Daemon closed connection");
break;
}
Some(Err(e)) => {
log::error!("[daemon-client] WebSocket error: {}", e);
break;
}
None => {
log::info!("[daemon-client] WebSocket stream ended");
break;
}
_ => {}
}
}
connected.store(false, Ordering::SeqCst);
log::info!("[daemon-client] Disconnected from daemon");
});
Ok(())
}
pub fn disconnect(&self) {
self.shutdown.store(true, Ordering::SeqCst);
self.connected.store(false, Ordering::SeqCst);
}
}
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
let client = DaemonClient::new(app_handle);
if let Err(e) = client.connect(port).await {
log::error!("[daemon-client] Failed to connect: {}", e);
}
client
}
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
// Try default port first
let default_port = 10108;
log::info!(
"[daemon-client] Looking for daemon on port {}",
default_port
);
let client = DaemonClient::new(app_handle);
match client.connect(default_port).await {
Ok(()) => Some(client),
Err(e) => {
log::warn!(
"[daemon-client] Could not connect to daemon on default port: {}",
e
);
None
}
}
}
-360
View File
@@ -1,360 +0,0 @@
// Daemon Spawn - Start the daemon from the GUI
// Currently disabled; will be re-enabled in the future
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
{
false
}
} else {
false
}
}
#[cfg(target_os = "macos")]
fn is_dev_mode() -> bool {
if let Ok(current_exe) = std::env::current_exe() {
let path_str = current_exe.to_string_lossy();
path_str.contains("target/debug") || path_str.contains("target/release")
} else {
false
}
}
#[cfg(target_os = "macos")]
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let daemon_path = exe_dir.join("donut-daemon");
if daemon_path.exists() {
return Some(daemon_path);
}
}
}
// Try common installation paths
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
.unwrap_or_default(),
];
paths.into_iter().find(|path| path.exists())
}
#[cfg(any(target_os = "linux", windows))]
fn get_daemon_path() -> Option<PathBuf> {
// First, try to find it next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
let exe_dir = current_exe.parent()?;
// Check for daemon binary in same directory
#[cfg(target_os = "windows")]
let daemon_name = "donut-daemon.exe";
#[cfg(target_os = "linux")]
let daemon_name = "donut-daemon";
let daemon_path = exe_dir.join(daemon_name);
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
return Some(PathBuf::from(path));
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
None
}
pub fn spawn_daemon() -> Result<(), String> {
// Log the daemon state for debugging
let state = read_state();
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
// Check if already running
if is_daemon_running() {
log::info!("Daemon is already running (verified by PID check)");
return Ok(());
}
log::info!("Daemon is not running, attempting to start...");
// Log current exe location for debugging
let current_exe = std::env::current_exe().ok();
log::info!("Current exe: {:?}", current_exe);
// On macOS, use launchctl to start the daemon via launchd
// This ensures the daemon runs in the user's Aqua session with WindowServer access
// and survives app termination since it's managed by launchd, not as a child process
#[cfg(target_os = "macos")]
{
spawn_daemon_macos()?;
}
// On Linux, use direct spawn
#[cfg(target_os = "linux")]
{
spawn_daemon_unix()?;
}
#[cfg(windows)]
{
spawn_daemon_windows()?;
}
// Wait for daemon to start (max 3 seconds)
for i in 0..30 {
thread::sleep(Duration::from_millis(100));
if is_daemon_running() {
log::info!("Daemon started successfully after {}ms", (i + 1) * 100);
return Ok(());
}
}
// Check if we got a state file at least
let state = read_state();
if let Some(pid) = state.daemon_pid {
log::info!("Daemon appears to have started (PID {} in state file)", pid);
return Ok(());
}
Err("Daemon did not start within timeout".to_string())
}
#[cfg(target_os = "macos")]
fn spawn_daemon_macos() -> Result<(), String> {
use std::os::unix::process::CommandExt;
// In dev mode, use direct spawn instead of launchctl
// This avoids issues with plist paths pointing to wrong binaries
if is_dev_mode() {
log::info!("Dev mode detected, using direct spawn instead of launchctl");
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
return Ok(());
}
// Production mode: use launchctl for proper daemon management
// First, ensure the LaunchAgent plist is installed
let autostart_enabled = autostart::is_autostart_enabled();
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
if !autostart_enabled {
log::info!("Installing LaunchAgent plist for daemon management");
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
log::info!("LaunchAgent plist installed successfully");
}
// Load the launch agent via launchctl
log::info!("Loading daemon via launchctl...");
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn spawn_daemon_unix() -> Result<(), String> {
use std::os::unix::process::CommandExt;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
#[cfg(windows)]
fn spawn_daemon_windows() -> Result<(), String> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
Command::new(&daemon_path)
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
pub fn ensure_daemon_running() -> Result<(), String> {
if !is_daemon_running() {
spawn_daemon()?;
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
-134
View File
@@ -1,134 +0,0 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::events::{DaemonEmitter, DaemonEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct WsState {
event_emitter: Option<Arc<DaemonEmitter>>,
}
impl WsState {
pub fn new() -> Self {
Self {
event_emitter: None,
}
}
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
Self {
event_emitter: Some(emitter),
}
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WsState) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to daemon events if emitter is available
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
log::info!("[ws] Client connected");
// Send initial ping to confirm connection
let ping_msg = WsMessage {
msg_type: "connected".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
loop {
tokio::select! {
// Handle incoming messages from client
Some(msg) = receiver.next() => {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"ping" => {
let pong = WsMessage {
msg_type: "pong".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&pong) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
}
_ => {
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Ok(Message::Ping(data)) => {
let _ = sender.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
log::info!("[ws] Client disconnected");
break;
}
Err(e) => {
log::error!("[ws] Error receiving message: {}", e);
break;
}
_ => {}
}
}
// Forward daemon events to client
Some(daemon_event) = async {
if let Some(ref mut rx) = event_rx {
rx.recv().await.ok()
} else {
std::future::pending::<Option<DaemonEvent>>().await
}
} => {
let ws_msg = WsMessage {
msg_type: "event".to_string(),
event: Some(daemon_event.event_type),
payload: Some(daemon_event.payload),
};
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
if sender.send(Message::Text(msg_str.into())).await.is_err() {
log::error!("[ws] Failed to send event to client");
break;
}
}
}
else => break,
}
}
log::info!("[ws] WebSocket connection closed");
}
+88 -15
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 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(); std::collections::HashMap::new();
for (browser, _) in &to_remove { for (browser, version) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1; 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 let total = data
.browsers .browsers
.get(browser.as_str()) .get(browser.as_str())
.map(|v| v.len()) .map(|v| v.len())
.unwrap_or(0); .unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0); if versions.len() >= total {
if removing >= total { if let Some(latest) = versions
log::info!("Keeping last available version: {browser} {version}"); .iter()
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1; .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; return false;
} }
true true
@@ -1275,20 +1296,72 @@ pub async fn ensure_active_browsers_downloaded(
}; };
log::info!("Auto-downloading {browser} {version} (no versions found locally)"); log::info!("Auto-downloading {browser} {version} (no versions found locally)");
match crate::downloader::download_browser(
// 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(), app_handle.clone(),
browser.to_string(), browser.to_string(),
version.clone(), version.clone(),
),
) )
.await .await;
{
Ok(_) => { match result {
Ok(Ok(_)) => {
downloaded.push(format!("{browser} {version}")); downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}"); log::info!("Successfully auto-downloaded {browser} {version}");
succeeded = true;
break;
} }
Err(e) => { Ok(Err(e)) => {
log::warn!("Failed to auto-download {browser} {version}: {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);
}
}
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");
} }
} }
+115 -5
View File
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo; use crate::browser_version_manager::DownloadInfo;
use crate::events; 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 // Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> = static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
@@ -44,6 +49,11 @@ impl Downloader {
Self { Self {
client: Client::builder() client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30)) .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() .build()
.unwrap_or_else(|_| Client::new()), .unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(), api_client: ApiClient::instance(),
@@ -470,7 +480,26 @@ impl Downloader {
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
use futures_util::StreamExt; 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 let Some(token) = cancel_token {
if token.is_cancelled() { if token.is_cancelled() {
drop(file); drop(file);
@@ -694,8 +723,14 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key); tokens.remove(&download_key);
// Emit cancelled stage if the download was cancelled by user // Emit a terminal stage so the UI stops spinning. A user cancellation maps to
if cancel_token.is_cancelled() { // "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 { let progress = DownloadProgress {
browser: browser_str.clone(), browser: browser_str.clone(),
version: version.clone(), version: version.clone(),
@@ -704,10 +739,9 @@ impl Downloader {
percentage: 0.0, percentage: 0.0,
speed_bytes_per_sec: 0.0, speed_bytes_per_sec: 0.0,
eta_seconds: None, eta_seconds: None,
stage: "cancelled".to_string(), stage: stage.to_string(),
}; };
let _ = events::emit("download-progress", &progress); let _ = events::emit("download-progress", &progress);
}
return Err(format!("Failed to download browser: {e}").into()); 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. // Do not delete files on verification failure; keep archive for manual retry.
let _ = self.registry.remove_browser(&browser_str, &version); let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save(); 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 // Remove browser-version pair from downloading set on verification failure
{ {
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
downloading.contains(&download_key) 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] #[tauri::command]
pub async fn download_browser( pub async fn download_browser(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
@@ -1110,6 +1177,49 @@ mod tests {
let downloaded_content = std::fs::read(&downloaded_file).unwrap(); let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content.len(), test_content.len()); 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 // Global singleton instance
+1
View File
@@ -281,6 +281,7 @@ mod tests {
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None, created_at: None,
updated_at: None,
} }
} }
+2 -73
View File
@@ -1,10 +1,7 @@
use serde::Serialize; use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast;
/// Trait for emitting events to the frontend or connected clients. /// Trait for emitting events to the frontend.
/// This abstraction allows the same code to work in both GUI (Tauri) mode
/// and daemon mode (WebSocket broadcast).
/// ///
/// Note: This trait uses `serde_json::Value` to be dyn-compatible. /// Note: This trait uses `serde_json::Value` to be dyn-compatible.
/// Use the convenience functions `emit()` and `emit_empty()` which accept /// 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. /// No-op emitter for testing or when events are not needed.
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct NoopEmitter; pub struct NoopEmitter;
@@ -91,8 +45,7 @@ impl EventEmitter for NoopEmitter {
} }
/// Global event emitter that can be set at runtime. /// Global event emitter that can be set at runtime.
/// This allows managers to emit events without knowing whether they're /// This allows managers to emit events without holding an AppHandle directly.
/// running in GUI or daemon mode.
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new(); 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. /// Set the global event emitter. This should be called once during app startup.
@@ -136,30 +89,6 @@ mod tests {
.is_ok()); .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] #[test]
fn test_emit_convenience_function() { fn test_emit_convenience_function() {
// Test that emit() works with various types // Test that emit() works with various types
+78 -16
View File
@@ -27,6 +27,11 @@ pub struct Extension {
pub author: Option<String>, pub author: Option<String>,
#[serde(default)] #[serde(default)]
pub homepage_url: Option<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
(name, version, description, author, homepage_url) (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)> { fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
let zip_start = if file_type == "crx" { let zip_start = if file_type == "crx" {
find_zip_start(file_data) find_zip_start(file_data)
@@ -285,6 +316,7 @@ impl ExtensionManager {
name name
}; };
let gecko_id = extract_gecko_id(&file_data, &file_type);
let ext = Extension { let ext = Extension {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
name: final_name, name: final_name,
@@ -299,6 +331,7 @@ impl ExtensionManager {
description, description,
author, author,
homepage_url, homepage_url,
gecko_id,
}; };
let file_dir = self.get_file_dir(&ext.id); let file_dir = self.get_file_dir(&ext.id);
@@ -415,6 +448,7 @@ impl ExtensionManager {
ext.name = mn; 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) { 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}")); let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
@@ -893,27 +927,36 @@ impl ExtensionManager {
continue; continue;
} }
let src_file = self.get_file_dir(ext_id).join(&ext.file_name); let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
if src_file.exists() { if !src_file.exists() {
// Firefox expects .xpi files in extensions dir continue;
let dest_name = if ext.file_type == "zip" { }
format!(
"{}.xpi", // Firefox/Camoufox only loads sideloaded .xpi files whose filename
ext // matches `browser_specific_settings.gecko.id` from the manifest.
.file_name // Prefer the cached value; fall back to reading the manifest now
.rsplit('.') // for extensions added before the field existed.
.next_back() let gecko_id = if let Some(ref id) = ext.gecko_id {
.unwrap_or(&ext.file_name) Some(id.clone())
) } else if let Ok(data) = fs::read(&src_file) {
extract_gecko_id(&data, &ext.file_type)
} else { } else {
ext.file_name.clone() None
}; };
let dest = extensions_dir.join(&dest_name);
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)?; fs::copy(&src_file, &dest)?;
extension_paths.push(dest.to_string_lossy().to_string()); extension_paths.push(dest.to_string_lossy().to_string());
} }
} }
} }
}
"chromium" => { "chromium" => {
// For Chromium, unpack extensions and return paths for --load-extension // For Chromium, unpack extensions and return paths for --load-extension
let unpacked_base = extensions_base_dir().join("unpacked"); let unpacked_base = extensions_base_dir().join("unpacked");
@@ -1022,9 +1065,17 @@ 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); let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) { if let Ok(file_data) = fs::read(&file_path) {
let mut updated_ext = ext.clone();
let mut changed = false;
if needs_meta_backfill {
let (manifest_name, version, description, author, homepage_url) = let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &ext.file_type); extract_manifest_metadata(&file_data, &ext.file_type);
if version.is_some() if version.is_some()
@@ -1033,7 +1084,6 @@ impl ExtensionManager {
|| homepage_url.is_some() || homepage_url.is_some()
|| manifest_name.is_some() || manifest_name.is_some()
{ {
let mut updated_ext = ext.clone();
if let Some(v) = version { if let Some(v) = version {
updated_ext.version = Some(v); updated_ext.version = Some(v);
} }
@@ -1046,6 +1096,18 @@ impl ExtensionManager {
if let Some(h) = homepage_url { if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h); updated_ext.homepage_url = Some(h);
} }
changed = true;
}
}
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); let metadata_path = self.get_metadata_path(&ext.id);
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) { if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
let _ = fs::write(metadata_path, json); let _ = fs::write(metadata_path, json);
+11 -13
View File
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
pub sync_enabled: bool, pub sync_enabled: bool,
#[serde(default)] #[serde(default)]
pub last_sync: Option<u64>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -90,6 +94,7 @@ impl GroupManager {
name, name,
sync_enabled, sync_enabled,
last_sync: None, last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
}; };
groups_data.groups.push(group.clone()); groups_data.groups.push(group.clone());
@@ -136,6 +141,7 @@ impl GroupManager {
.ok_or_else(|| format!("Group with id '{id}' not found"))?; .ok_or_else(|| format!("Group with id '{id}' not found"))?;
group.name = name; group.name = name;
group.updated_at = Some(crate::proxy_manager::now_secs());
let updated_group = group.clone(); let updated_group = group.clone();
self.save_groups_data(&groups_data)?; self.save_groups_data(&groups_data)?;
@@ -167,6 +173,7 @@ impl GroupManager {
existing.name = group.name.clone(); existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled; existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync; existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
self.save_groups_data(&groups_data)?; self.save_groups_data(&groups_data)?;
} }
@@ -183,6 +190,7 @@ impl GroupManager {
existing.name = group.name.clone(); existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled; existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync; existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
} else { } else {
groups_data.groups.push(group.clone()); 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(); let mut result = Vec::new();
for group in groups { for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0); 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) Ok(result)
} }
} }
+287 -124
View File
@@ -1,13 +1,19 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env; use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; 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_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind}; use tauri_plugin_log::{Target, TargetKind};
// Store pending URLs that need to be handled when the window is ready // Store pending URLs that need to be handled when the window is ready
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new()); 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_client;
mod api_server; mod api_server;
mod app_auto_updater; mod app_auto_updater;
@@ -46,12 +52,8 @@ mod wayfern_terms;
pub mod cloud_auth; pub mod cloud_auth;
mod commercial_license; mod commercial_license;
mod cookie_manager; mod cookie_manager;
pub mod daemon;
pub mod daemon_client;
#[allow(dead_code)]
mod daemon_spawn;
pub mod daemon_ws;
pub mod events; pub mod events;
mod mcp_integrations;
mod mcp_server; mod mcp_server;
mod tag_manager; mod tag_manager;
mod team_lock; mod team_lock;
@@ -74,7 +76,7 @@ use profile::manager::{
use profile::password::{ use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_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::{ use browser_version_manager::{
@@ -91,18 +93,19 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser}; use downloader::{cancel_download, download_browser};
use settings_manager::{ 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_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, 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::{ 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, 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, 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_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, 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; 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> { async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}"); 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_internal() -> bool {
fn is_mcp_in_claude_desktop() -> Result<bool, String> { let Some(dir) = claude_desktop_extension_dir() else {
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?; return false;
Ok(dir.join("manifest.json").exists()) };
dir.join("manifest.json").exists()
} }
#[tauri::command] async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
let mcp_server = mcp_server::McpServer::instance(); let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?; let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance(); let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager let token = settings_manager
.get_mcp_token(&app_handle) .get_mcp_token(app_handle)
.await .await
.map_err(|e| format!("Failed to get MCP token: {e}"))? .map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?; .ok_or("MCP token not found")?;
@@ -605,8 +609,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
Ok(()) Ok(())
} }
#[tauri::command] fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?; let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
if ext_dir.exists() { if ext_dir.exists() {
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?; 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(()) Ok(())
} }
fn find_claude_cli() -> Option<std::path::PathBuf> { async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
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")?;
let mcp_server = mcp_server::McpServer::instance(); let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?; let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance(); let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager let token = settings_manager
.get_mcp_token(&app_handle) .get_mcp_token(app_handle)
.await .await
.map_err(|e| format!("Failed to get MCP token: {e}"))? .map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?; .ok_or("MCP token not found")?;
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
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(())
} }
#[tauri::command] #[tauri::command]
fn remove_mcp_from_claude_code() -> Result<(), String> { async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?; let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
let output = std::process::Command::new(&cli) Ok(mcp_integrations::list_agents_with_status(&[(
.args(["mcp", "remove", "donut-browser"]) "claude-desktop",
.output() claude_desktop_connected,
.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}")); #[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] #[tauri::command]
@@ -969,15 +929,21 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
#[tauri::command] #[tauri::command]
async fn check_vpn_validity( async fn check_vpn_validity(
vpn_id: String, 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> { ) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .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 .await
.map_err(|e| format!("Failed to start VPN worker: {e}"))?; .map_err(|e| format!("Failed to start VPN worker: {e}"))?;
@@ -1054,6 +1020,53 @@ async fn check_vpn_validity(
Ok(result) 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] #[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> { async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Start VPN worker process (detached, survives GUI shutdown) // Start VPN worker process (detached, survives GUI shutdown)
@@ -1162,6 +1175,7 @@ async fn generate_sample_fingerprint(
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None, created_at: None,
updated_at: None,
}; };
if browser == "camoufox" { 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
@@ -1200,15 +1328,25 @@ pub fn run() {
let log_file_name = app_dirs::app_name(); 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() tauri::Builder::default()
.plugin( .plugin(
tauri_plugin_log::Builder::new() tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates .clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout)) .target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview)) .target(Target::new(TargetKind::Webview))
.target(Target::new(TargetKind::LogDir { .target(file_log_target)
file_name: Some(log_file_name.to_string()),
}))
// 5 MB per rotated file × KeepAll — the previous 100 KB limit // 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB // truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure. // turned out to be excessive disk pressure.
@@ -1260,14 +1398,6 @@ pub fn run() {
mgr.ensure_icons_extracted(); 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 // Create the main window programmatically
#[allow(unused_variables)] #[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
@@ -1285,6 +1415,32 @@ pub fn run() {
#[allow(unused_variables)] #[allow(unused_variables)]
let window = win_builder.build().unwrap(); 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 // Set transparent titlebar for macOS
#[cfg(target_os = "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 // Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() { if let Some(scheduler) = sync::get_global_scheduler() {
if is_running { 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); last_running_states.insert(profile_id, is_running);
} else { } else {
// Update the state even if unchanged to ensure we have it tracked // Update the state even if unchanged to ensure we have it tracked
@@ -1990,6 +2152,9 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
confirm_quit,
hide_to_tray,
update_tray_menu,
get_supported_browsers, get_supported_browsers,
is_browser_supported_on_platform, is_browser_supported_on_platform,
download_browser, download_browser,
@@ -2020,15 +2185,14 @@ pub fn run() {
save_app_settings, save_app_settings,
read_log_files, read_log_files,
open_log_directory, open_log_directory,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
get_table_sorting_settings, get_table_sorting_settings,
save_table_sorting_settings, save_table_sorting_settings,
get_system_language, get_system_language,
get_system_info, get_system_info,
dismiss_window_resize_warning, dismiss_window_resize_warning,
get_window_resize_warning_dismissed, get_window_resize_warning_dismissed,
get_onboarding_completed,
complete_onboarding,
clear_all_version_cache_and_refetch, clear_all_version_cache_and_refetch,
is_default_browser, is_default_browser,
open_url_with_profile, open_url_with_profile,
@@ -2093,6 +2257,7 @@ pub fn run() {
get_sync_settings, get_sync_settings,
save_sync_settings, save_sync_settings,
set_profile_sync_mode, set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync, request_profile_sync,
set_proxy_sync_enabled, set_proxy_sync_enabled,
set_group_sync_enabled, set_group_sync_enabled,
@@ -2106,6 +2271,7 @@ pub fn run() {
enable_sync_for_all_entities, enable_sync_for_all_entities,
set_e2e_password, set_e2e_password,
check_has_e2e_password, check_has_e2e_password,
verify_e2e_password,
delete_e2e_password, delete_e2e_password,
rollover_encryption_for_all_entities, rollover_encryption_for_all_entities,
read_profile_cookies, read_profile_cookies,
@@ -2123,12 +2289,9 @@ pub fn run() {
stop_mcp_server, stop_mcp_server,
get_mcp_server_status, get_mcp_server_status,
get_mcp_config, get_mcp_config,
is_mcp_in_claude_desktop, list_mcp_agents,
add_mcp_to_claude_desktop, add_mcp_to_agent,
remove_mcp_from_claude_desktop, remove_mcp_from_agent,
is_mcp_in_claude_code,
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
// VPN commands // VPN commands
import_vpn_config, import_vpn_config,
list_vpn_configs, list_vpn_configs,
@@ -2141,7 +2304,6 @@ pub fn run() {
disconnect_vpn, disconnect_vpn,
get_vpn_status, get_vpn_status,
list_active_vpn_connections, list_active_vpn_connections,
handle_url_open,
// Cloud auth commands // Cloud auth commands
cloud_auth::cloud_exchange_device_code, cloud_auth::cloud_exchange_device_code,
cloud_auth::cloud_get_user, cloud_auth::cloud_get_user,
@@ -2171,6 +2333,7 @@ pub fn run() {
set_profile_password, set_profile_password,
change_profile_password, change_profile_password,
remove_profile_password, remove_profile_password,
verify_profile_password,
unlock_profile, unlock_profile,
lock_profile, lock_profile,
is_profile_locked, 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()
}
+641 -31
View File
@@ -33,6 +33,48 @@ pub struct McpTool {
pub input_schema: serde_json::Value, 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)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct McpRequest { pub struct McpRequest {
@@ -110,11 +152,11 @@ impl McpServer {
self.is_running.load(Ordering::SeqCst) self.is_running.load(Ordering::SeqCst)
} }
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> { /// Gate an MCP tool on a capability the caller already resolved (e.g.
if !CLOUD_AUTH.has_active_paid_subscription().await { /// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
// Log the failed gate so customer logs explain why an MCP tool returned /// with enough state for support to diagnose, without leaking secrets.
// an error. Include enough state (logged-in vs not, plan, status) for async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
// support to diagnose without leaking secrets. if !allowed {
let summary = match CLOUD_AUTH.get_user().await { let summary = match CLOUD_AUTH.get_user().await {
Some(state) => format!( Some(state) => format!(
"logged_in=true plan={} status={} period={:?}", "logged_in=true plan={} status={} period={:?}",
@@ -122,10 +164,10 @@ impl McpServer {
), ),
None => "logged_in=false".to_string(), 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 { return Err(McpError {
code: -32000, code: -32000,
message: format!("{feature} requires an active paid subscription"), message: format!("{feature} requires a plan that includes this feature"),
}); });
} }
Ok(()) Ok(())
@@ -244,6 +286,9 @@ impl McpServer {
.delete(Self::handle_mcp_delete), .delete(Self::handle_mcp_delete),
) )
.route("/health", get(Self::handle_health)) .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( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
Self::auth_middleware, 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( async fn auth_middleware(
State(state): State<McpHttpState>, State(state): State<McpHttpState>,
req: Request<Body>, req: Request<Body>,
@@ -297,8 +353,16 @@ impl McpServer {
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer ")); .and_then(|h| h.strip_prefix("Bearer "));
let valid = // Constant-time comparison to avoid leaking the token prefix via timing.
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str()); 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 { if !valid {
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
@@ -466,7 +530,7 @@ impl McpServer {
}, },
McpTool { McpTool {
name: "run_profile".to_string(), 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!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -488,7 +552,7 @@ impl McpServer {
}, },
McpTool { McpTool {
name: "kill_profile".to_string(), 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!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1103,6 +1167,25 @@ impl McpServer {
"required": ["profile_id"] "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 // Team lock tools
McpTool { McpTool {
name: "get_team_locks".to_string(), name: "get_team_locks".to_string(),
@@ -1354,6 +1437,76 @@ impl McpServer {
"required": ["profile_id"] "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, "list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(arguments).await, "get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => { "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 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, "create_profile" => self.handle_create_profile(arguments).await,
"update_profile" => self.handle_update_profile(arguments).await, "update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_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, "connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await, "disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(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, "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" => { "update_profile_proxy_bypass_rules" => {
self self
.handle_update_profile_proxy_bypass_rules(arguments) .handle_update_profile_proxy_bypass_rules(arguments)
@@ -1562,12 +1735,18 @@ impl McpServer {
.handle_assign_extension_group_to_profile(arguments) .handle_assign_extension_group_to_profile(arguments)
.await .await
} }
// Cookie management
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
// Team lock tools // Team lock tools
"get_team_locks" => self.handle_get_team_locks().await, "get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await, "get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
// Synchronizer tools // Synchronizer tools
"start_sync_session" => { "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 self.handle_start_sync_session(arguments).await
} }
"stop_sync_session" => self.handle_stop_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, "remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
// Browser interaction tools (require paid subscription) // Browser interaction tools (require paid subscription)
"navigate" => { "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 self.handle_navigate(arguments).await
} }
"screenshot" => { "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 self.handle_screenshot(arguments).await
} }
"evaluate_javascript" => { "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 self.handle_evaluate_javascript(arguments).await
} }
"click_element" => { "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 self.handle_click_element(arguments).await
} }
"type_text" => { "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 self.handle_type_text(arguments).await
} }
"get_page_content" => { "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 self.handle_get_page_content(arguments).await
} }
"get_page_info" => { "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 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 { _ => Err(McpError {
code: -32602, code: -32602,
message: format!("Unknown tool: {tool_name}"), message: format!("Unknown tool: {tool_name}"),
@@ -1678,6 +1909,13 @@ impl McpServer {
&self, &self,
arguments: &serde_json::Value, arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> { ) -> 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 let profile_id = arguments
.get("profile_id") .get("profile_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -1687,7 +1925,7 @@ impl McpServer {
})?; })?;
let url = arguments.get("url").and_then(|v| v.as_str()); let url = arguments.get("url").and_then(|v| v.as_str());
let _headless = arguments let headless = arguments
.get("headless") .get("headless")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
@@ -1731,13 +1969,15 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(), message: "MCP server not properly initialized".to_string(),
})?; })?;
// Launch the browser // Launch a fresh instance, honoring the requested headless mode. The CDP
crate::browser_runner::BrowserRunner::instance() // port is self-allocated and discovered later via get_cdp_port_for_profile.
.launch_browser( crate::browser_runner::launch_browser_profile_impl(
app_handle.clone(), app_handle.clone(),
profile, profile.clone(),
url.map(|s| s.to_string()), url.map(|s| s.to_string()),
None, None,
headless,
true,
) )
.await .await
.map_err(|e| McpError { .map_err(|e| McpError {
@@ -1757,6 +1997,13 @@ impl McpServer {
&self, &self,
arguments: &serde_json::Value, arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> { ) -> 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 let profile_id = arguments
.get("profile_id") .get("profile_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -2433,6 +2680,15 @@ impl McpServer {
message: "Missing proxy_type".to_string(), 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 let host = arguments
.get("host") .get("host")
.and_then(|v| v.as_str()) .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 // VPN management handlers
async fn handle_import_vpn( async fn handle_import_vpn(
&self, &self,
@@ -3016,10 +3340,10 @@ impl McpServer {
&self, &self,
arguments: &serde_json::Value, arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> { ) -> 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 { return Err(McpError {
code: -32000, 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()) .and_then(|v| v.as_str())
.unwrap_or("text"); .unwrap_or("text");
let selector = arguments.get("selector").and_then(|v| v.as_str()); 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 profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?; let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
@@ -4310,10 +4639,28 @@ impl McpServer {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .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!({ Ok(serde_json::json!({
"content": [{ "content": [{
"type": "text", "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 --- // --- Synchronizer handlers ---
async fn handle_start_sync_session( async fn handle_start_sync_session(
@@ -4560,6 +5168,8 @@ mod tests {
assert!(tool_names.contains(&"delete_extension")); assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group")); assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile")); assert!(tool_names.contains(&"assign_extension_group_to_profile"));
// Cookie tools
assert!(tool_names.contains(&"import_profile_cookies"));
// Team lock tools // Team lock tools
assert!(tool_names.contains(&"get_team_locks")); assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status")); 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::path::Path;
use std::process::Command; 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 // Platform-specific modules
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[allow(dead_code)] #[allow(dead_code)]
@@ -215,16 +251,7 @@ pub mod macos {
continue; continue;
} }
// Check if any command line argument contains the profile path if cmd_matches_profile_path(cmd, 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 {
pids.push(pid.as_u32()); pids.push(pid.as_u32());
} }
} }
@@ -832,15 +859,7 @@ pub mod linux {
continue; continue;
} }
let has_profile = cmd.iter().any(|arg| { if cmd_matches_profile_path(cmd, profile_path) {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
pids.push(pid.as_u32()); pids.push(pid.as_u32());
} }
} }
+168 -16
View File
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System}; use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url; 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 { pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager, camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager, wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
@@ -186,6 +200,7 @@ impl ProfileManager {
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None, created_at: None,
updated_at: None,
}; };
match self match self
@@ -289,6 +304,7 @@ impl ProfileManager {
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None, created_at: None,
updated_at: None,
}; };
match self match self
@@ -351,6 +367,7 @@ impl ProfileManager {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0), .unwrap_or(0),
), ),
updated_at: Some(crate::proxy_manager::now_secs()),
}; };
// Save profile info // Save profile info
@@ -363,9 +380,18 @@ impl ProfileManager {
log::info!("Profile '{name}' created successfully with ID: {profile_id}"); log::info!("Profile '{name}' created successfully with ID: {profile_id}");
// Create user.js with common Firefox preferences and apply proxy settings if provided // `apply_proxy_settings_to_profile` writes a Firefox-style user.js
// Skip for ephemeral profiles since the data dir is created at launch time // with the upstream proxy host. That is wrong for both supported
if !ephemeral { // 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_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) { 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)?; self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
@@ -396,7 +422,7 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?; create_dir_all(&profile_uuid_dir)?;
let json = serde_json::to_string_pretty(profile)?; 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 // Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -421,8 +447,26 @@ impl ProfileManager {
if path.is_dir() { if path.is_dir() {
let metadata_file = path.join("metadata.json"); let metadata_file = path.join("metadata.json");
if metadata_file.exists() { if metadata_file.exists() {
let content = fs::read_to_string(&metadata_file)?; let content = match fs::read_to_string(&metadata_file) {
let mut profile: BrowserProfile = serde_json::from_str(&content)?; 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 // Backfill host_os from browser config for profiles created before
// the field existed (or synced without it). // the field existed (or synced without it).
@@ -431,7 +475,7 @@ impl ProfileManager {
if let Some(os) = inferred_os { if let Some(os) = inferred_os {
profile.host_os = Some(os); profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) { 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) // Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string(); profile.name = new_name.to_string();
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile with new name // Save profile with new name
self.save_profile(&profile)?; 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) // Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -676,8 +723,11 @@ impl ProfileManager {
} }
profile.group_id = group_id.clone(); profile.group_id = group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled // Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id { if let Some(ref new_group_id) = group_id {
@@ -728,10 +778,13 @@ impl ProfileManager {
} }
} }
profile.tags = deduped; profile.tags = deduped;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile // Save profile
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles // Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); 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) // Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_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 // Save profile
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event // Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); 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"))?; .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?; profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) { if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}"); 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"))?; .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.proxy_bypass_rules = rules; profile.proxy_bypass_rules = rules;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); 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"))?; .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.dns_blocklist = dns_blocklist; profile.dns_blocklist = dns_blocklist;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); log::warn!("Warning: Failed to emit profiles-changed event: {e}");
} }
@@ -970,7 +1035,7 @@ impl ProfileManager {
fs::create_dir_all(&dest_dir)?; fs::create_dir_all(&dest_dir)?;
} }
let new_profile = BrowserProfile { let mut new_profile = BrowserProfile {
id: new_id, id: new_id,
name: clone_name, name: clone_name,
browser: source.browser, browser: source.browser,
@@ -1003,8 +1068,24 @@ impl ProfileManager {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0), .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)?; self.save_profile(&new_profile)?;
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
@@ -1060,6 +1141,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!( log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).", "Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name, profile.name,
@@ -1120,6 +1203,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!( log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).", "Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name, profile.name,
@@ -1166,6 +1251,7 @@ impl ProfileManager {
// Update proxy settings and clear VPN (mutual exclusion) // Update proxy settings and clear VPN (mutual exclusion)
profile.proxy_id = proxy_id.clone(); profile.proxy_id = proxy_id.clone();
profile.vpn_id = None; profile.vpn_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save the updated profile // Save the updated profile
self self
@@ -1174,6 +1260,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() 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 // Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id { if let Some(ref new_proxy_id) = proxy_id {
@@ -1184,7 +1272,13 @@ impl ProfileManager {
} }
} }
// Update on-disk browser profile config immediately // 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_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) { if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
let profiles_dir = self.get_profiles_dir(); let profiles_dir = self.get_profiles_dir();
@@ -1214,6 +1308,7 @@ impl ProfileManager {
format!("Failed to disable proxy settings: {e}").into() format!("Failed to disable proxy settings: {e}").into()
})?; })?;
} }
}
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager) // Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
if let Err(e) = events::emit("profile-updated", &profile) { if let Err(e) = events::emit("profile-updated", &profile) {
@@ -1256,6 +1351,7 @@ impl ProfileManager {
// Update VPN and clear proxy (mutual exclusion) // Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id.clone(); profile.vpn_id = vpn_id.clone();
profile.proxy_id = None; profile.proxy_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self self
.save_profile(&profile) .save_profile(&profile)
@@ -1263,6 +1359,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() 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. // Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id { 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"))?; .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id.clone(); profile.extension_group_id = extension_group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync // Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through // enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller. // 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 merged = latest_profile.clone();
let mut detected_stop = false;
if let Some(pid) = found_pid { if let Some(pid) = found_pid {
if merged.process_id != Some(pid) { if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid); merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) { if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}"); 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() { } else if merged.process_id.is_some() {
// Clear the PID if no process found // Clear the PID if no process found
@@ -1467,6 +1573,15 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) { if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}"); 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 // Emit profile update event to frontend
@@ -1481,7 +1596,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager // Check Camoufox status using CamoufoxManager
async fn check_camoufox_status( async fn check_camoufox_status(
&self, &self,
_app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
profile: &BrowserProfile, profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager; let launcher = self.camoufox_manager;
@@ -1510,10 +1625,14 @@ impl ProfileManager {
}; };
if latest.process_id != camoufox_process.processId { if latest.process_id != camoufox_process.processId {
let old_pid = latest.process_id;
latest.process_id = camoufox_process.processId; latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) { if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}"); 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 // Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) { 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}"); 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) { if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}"); 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 // Emit profile update event to frontend
if let Err(e3) = events::emit("profile-updated", &latest) { if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}"); log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1605,7 +1736,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager // Check Wayfern status using WayfernManager
async fn check_wayfern_status( async fn check_wayfern_status(
&self, &self,
_app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
profile: &BrowserProfile, profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager; let manager = self.wayfern_manager;
@@ -1634,10 +1765,14 @@ impl ProfileManager {
}; };
if latest.process_id != wayfern_process.processId { if latest.process_id != wayfern_process.processId {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId; latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) { if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}"); 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 // Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) { 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}"); 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) { if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}"); 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\", \"\");".to_string(),
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(), "user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
"user_pref(\"startup.homepage_override_url\", \"\");".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.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(), "user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
"user_pref(\"extensions.autoDisableScopes\", 0);".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 // Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(), "user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.auto\", 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()); 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 = let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?; BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group( create_browser_profile_with_group(
@@ -2364,10 +2516,10 @@ pub async fn update_camoufox_config(
) -> Result<(), String> { ) -> Result<(), String> {
if config.fingerprint.is_some() if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH && !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription() .can_use_cross_os_fingerprints()
.await .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 if !crate::cloud_auth::CLOUD_AUTH
@@ -2392,10 +2544,10 @@ pub async fn update_wayfern_config(
) -> Result<(), String> { ) -> Result<(), String> {
if config.fingerprint.is_some() if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH && !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription() .can_use_cross_os_fingerprints()
.await .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 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)?; .map_err(err_internal)?;
cache_key(id, key); cache_key(id, key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) 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] #[tauri::command]
pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> { pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?; let id = parse_uuid(&profile_id)?;
@@ -396,6 +431,7 @@ pub async fn change_profile_password(
drop_cached_key(&id); drop_cached_key(&id);
cache_key(id, new_key); cache_key(id, new_key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) Ok(())
} }
@@ -464,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
.map_err(err_internal)?; .map_err(err_internal)?;
drop_cached_key(&id); drop_cached_key(&id);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) Ok(())
} }
@@ -637,22 +674,31 @@ pub fn complete_after_quit_blocking(
result result
} }
/// Async re-encrypt of a password-protected profile's ephemeral dir back to /// Re-encrypt a password-protected profile's ephemeral dir back to the
/// disk, called after the browser process exits. Optionally purges the /// on-disk encrypted dir after the browser process exits. Optionally purges
/// ephemeral dir + cached key based on the global setting. /// the ephemeral dir + cached key based on the global setting. Returns the
pub fn complete_after_quit(profile: &crate::profile::BrowserProfile) { /// 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 { if !profile.password_protected {
return; return None;
} }
let keep_decrypted = read_keep_decrypted_setting(); let keep_decrypted = read_keep_decrypted_setting();
let profile = profile.clone(); let profile = profile.clone();
tauri::async_runtime::spawn(async move { tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
let _ = tokio::task::spawn_blocking(move || { .await
complete_after_quit_blocking(&profile, keep_decrypted); .unwrap_or_else(|e| {
log::error!("complete_after_quit_and_wait join error: {e}");
None
}) })
.await;
});
} }
#[cfg(test)] #[cfg(test)]
+6
View File
@@ -78,6 +78,12 @@ pub struct BrowserProfile {
/// any staleness check. /// any staleness check.
#[serde(default)] #[serde(default)]
pub created_at: Option<u64>, 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 { pub fn default_release_type() -> String {
+20 -414
View File
@@ -2,7 +2,7 @@ use directories::BaseDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use std::fs::{self, create_dir_all}; use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf}; use std::path::Path;
use crate::camoufox_manager::CamoufoxConfig; use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
} }
fn map_browser_type(browser: &str) -> &str { 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 { match browser {
"firefox" | "firefox-developer" | "zen" => "camoufox", "firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
"chromium" | "brave" => "wayfern",
"camoufox" => "camoufox",
"wayfern" => "wayfern",
_ => "wayfern", _ => "wayfern",
} }
} }
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
base_dirs: BaseDirs, base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
profile_manager: &'static ProfileManager, profile_manager: &'static ProfileManager,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager, wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
} }
@@ -44,7 +43,6 @@ impl ProfileImporter {
base_dirs: BaseDirs::new().expect("Failed to get base directories"), base_dirs: BaseDirs::new().expect("Failed to get base directories"),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(), downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
profile_manager: ProfileManager::instance(), profile_manager: ProfileManager::instance(),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(), wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
} }
} }
@@ -58,12 +56,12 @@ impl ProfileImporter {
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> { ) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new(); 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_chrome_profiles()?);
detected_profiles.extend(self.detect_brave_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_chromium_profiles()?);
detected_profiles.extend(self.detect_zen_browser_profiles()?);
let mut seen_paths = HashSet::new(); let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles let unique_profiles: Vec<DetectedProfile> = detected_profiles
@@ -74,80 +72,6 @@ impl ProfileImporter {
Ok(unique_profiles) 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>> { fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new(); let mut profiles = Vec::new();
@@ -235,191 +159,6 @@ impl ProfileImporter {
Ok(profiles) 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( fn scan_chrome_profiles_dir(
&self, &self,
browser_dir: &Path, browser_dir: &Path,
@@ -493,7 +232,7 @@ impl ProfileImporter {
browser_type: &str, browser_type: &str,
new_profile_name: &str, new_profile_name: &str,
proxy_id: Option<String>, proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>, _camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>, wayfern_config: Option<WayfernConfig>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let source_path = Path::new(source_path); let source_path = Path::new(source_path);
@@ -529,87 +268,9 @@ impl ProfileImporter {
let version = self.get_default_version_for_browser(mapped)?; let version = self.get_default_version_for_browser(mapped)?;
let final_camoufox_config = if mapped == "camoufox" { // Camoufox import is removed; only Wayfern profiles are imported now, so the
let mut config = camoufox_config.unwrap_or_default(); // imported profile never carries a Camoufox config.
let final_camoufox_config: Option<CamoufoxConfig> = None;
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&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
};
let final_wayfern_config = if mapped == "wayfern" { let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default(); let mut config = wayfern_config.unwrap_or_default();
@@ -668,6 +329,7 @@ impl ProfileImporter {
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None, created_at: None,
updated_at: None,
}; };
match self match self
@@ -726,6 +388,7 @@ impl ProfileImporter {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0), .unwrap_or(0),
), ),
updated_at: Some(crate::proxy_manager::now_secs()),
}; };
self.profile_manager.save_profile(&profile)?; self.profile_manager.save_profile(&profile)?;
@@ -803,6 +466,12 @@ pub async fn import_browser_profile(
camoufox_config: Option<CamoufoxConfig>, camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>, wayfern_config: Option<WayfernConfig>,
) -> Result<(), String> { ) -> 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 let fingerprint_os = camoufox_config
.as_ref() .as_ref()
.and_then(|c| c.os.as_deref()) .and_then(|c| c.os.as_deref())
@@ -894,24 +563,6 @@ mod tests {
let _profiles = result.unwrap(); 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] #[test]
fn test_scan_chrome_profiles_dir_nonexistent() { fn test_scan_chrome_profiles_dir_nonexistent() {
let (importer, temp_dir) = create_test_profile_importer(); 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] #[test]
fn test_copy_directory_recursive() { fn test_copy_directory_recursive() {
let temp_dir = TempDir::new().expect("Failed to create temp directory"); 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, pub sync_enabled: bool,
#[serde(default)] #[serde(default)]
pub last_sync: Option<u64>, 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)] #[serde(default)]
pub is_cloud_managed: bool, pub is_cloud_managed: bool,
#[serde(default)] #[serde(default)]
@@ -124,6 +129,14 @@ pub struct StoredProxy {
pub dynamic_proxy_format: Option<String>, 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 { impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self { pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured(); let sync_enabled = crate::sync::is_sync_configured();
@@ -133,6 +146,7 @@ impl StoredProxy {
proxy_settings, proxy_settings,
sync_enabled, sync_enabled,
last_sync: None, last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false, is_cloud_managed: false,
is_cloud_derived: false, is_cloud_derived: false,
geo_country: None, geo_country: None,
@@ -159,10 +173,12 @@ impl StoredProxy {
pub fn update_settings(&mut self, proxy_settings: ProxySettings) { pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
self.proxy_settings = proxy_settings; self.proxy_settings = proxy_settings;
self.updated_at = Some(now_secs());
} }
pub fn update_name(&mut self, name: String) { pub fn update_name(&mut self, name: String) {
self.name = name; self.name = name;
self.updated_at = Some(now_secs());
} }
} }
@@ -455,6 +471,7 @@ impl ProxyManager {
proxy_settings, proxy_settings,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: true, is_cloud_managed: true,
is_cloud_derived: false, is_cloud_derived: false,
geo_country: None, geo_country: None,
@@ -646,6 +663,7 @@ impl ProxyManager {
proxy_settings, proxy_settings,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false, is_cloud_managed: false,
is_cloud_derived: true, is_cloud_derived: true,
geo_country: Some(country), geo_country: Some(country),
@@ -710,6 +728,7 @@ impl ProxyManager {
&proxy.geo_isp, &proxy.geo_isp,
); );
proxy.updated_at = Some(now_secs());
proxy.proxy_settings.username = Some(geo_username); proxy.proxy_settings.username = Some(geo_username);
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone(); proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone(); proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
@@ -755,6 +774,17 @@ impl ProxyManager {
list 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 // Get a stored proxy by ID
// Update a stored proxy // Update a stored proxy
@@ -1711,12 +1741,18 @@ impl ProxyManager {
.arg("--id") .arg("--id")
.arg(&proxy_id); .arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap(); // A failed spawn (sidecar missing, permission denied, fd exhaustion) must
// not panic the cleanup task — the proxy is already removed from tracking,
if !output.status.success() { // so degrade gracefully like the non-success branch below.
let stderr = String::from_utf8_lossy(&output.stderr); match proxy_cmd.output().await {
log::warn!("Proxy stop error: {stderr}"); Ok(output) if !output.status.success() => {
// We still return Ok since we've already removed the proxy from our tracking 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 // Clear profile-to-proxy mapping if it references this proxy
@@ -1776,11 +1812,16 @@ impl ProxyManager {
.arg("--id") .arg("--id")
.arg(&proxy_id); .arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap(); // Don't panic if the sidecar can't be spawned — still clear the mapping.
match proxy_cmd.output().await {
if !output.status.success() { Ok(output) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr); log::warn!(
log::warn!("Proxy stop error: {stderr}"); "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 // Clear profile-to-proxy mapping
@@ -3154,6 +3195,7 @@ mod tests {
}, },
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
is_cloud_managed: false, is_cloud_managed: false,
is_cloud_derived: false, is_cloud_derived: false,
geo_country: Some("US".to_string()), geo_country: Some("US".to_string()),
-1
View File
@@ -28,7 +28,6 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
{ {
match base_name { match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(), "donut-proxy" => "donut-proxy.exe".to_string(),
"donut-daemon" => "donut-daemon.exe".to_string(),
_ => String::new(), _ => 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) // Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await { // hostname locally: tokio::net::lookup_host would call the HOST resolver
Ok(mut addrs) => { // (getaddrinfo), leaking the destination domain to the host's DNS server and
if let Some(addr) = addrs.next() { // defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
match addr.ip() { // send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
std::net::IpAddr::V4(ipv4) => ipv4.octets(), // the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
std::net::IpAddr::V6(_) => { // SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
log::error!("SOCKS4 does not support IPv6"); // HTTP — prefer SOCKS5 there.)
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
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT 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_port.to_be_bytes());
socks_request.extend_from_slice(&target_ip); socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
socks_request.push(0); // NULL terminator for userid 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 // Send SOCKS4 CONNECT request
if let Err(e) = socks_stream.write_all(&socks_request).await { 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)? Proxy::http(upstream_url)?
} }
"socks5" => { "socks5" => {
// For SOCKS5, reqwest supports it directly // Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
Proxy::all(upstream_url)? // 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" => {
// SOCKS4 is handled manually in handle_http_via_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, stream,
full_request, full_request,
upstream_url, upstream_url,
bypass_matcher, bypass_matcher,
blocklist_matcher, blocklist_matcher,
) )
.await; .await
{
log::warn!("CONNECT tunnel ended with error: {e}");
}
return; return;
} }
@@ -1449,6 +1436,13 @@ async fn handle_connect_from_buffer(
tracker.record_request(&domain, 0, 0); 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). // Connect to target (directly or via upstream proxy).
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS, // Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
// Shadowsocks) share the same bidirectional-copy tunnel code below. // 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 mut buffer = [0u8; 4096];
let n = proxy_stream.read(&mut buffer).await?; 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") { if !response_full.starts_with("HTTP/1.1 200")
return Err(format!("Upstream proxy CONNECT failed: {}", response).into()); && !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) Box::new(proxy_stream)
} }
"socks4" | "socks5" => { "socks4" | "socks5" => {
+26 -62
View File
@@ -50,12 +50,12 @@ pub struct AppSettings {
#[serde(default)] #[serde(default)]
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file) pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
#[serde(default)] #[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
#[serde(default)] #[serde(default)]
pub window_resize_warning_dismissed: bool, pub window_resize_warning_dismissed: bool,
#[serde(default)] #[serde(default)]
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
#[serde(default)]
pub disable_auto_updates: bool, pub disable_auto_updates: bool,
/// When true, the decrypted in-RAM copy of a password-protected profile is /// When true, the decrypted in-RAM copy of a password-protected profile is
/// preserved between launches for faster subsequent startups. The on-disk /// preserved between launches for faster subsequent startups. The on-disk
@@ -93,9 +93,9 @@ impl Default for AppSettings {
mcp_enabled: false, mcp_enabled: false,
mcp_port: None, mcp_port: None,
mcp_token: None, mcp_token: None,
launch_on_login_declined: false,
language: None, language: None,
window_resize_warning_dismissed: false, window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false, disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false, keep_decrypted_profiles_in_ram: false,
} }
@@ -183,17 +183,6 @@ impl SettingsManager {
Ok(()) 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 { fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_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(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) { if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed; 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(()) 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] #[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> { pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::instance(); let manager = SettingsManager::instance();
@@ -1047,6 +1013,27 @@ pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
Ok(settings.window_resize_warning_dismissed) 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] #[tauri::command]
pub fn get_system_language() -> String { pub fn get_system_language() -> String {
sys_locale::get_locale() sys_locale::get_locale()
@@ -1182,9 +1169,9 @@ mod tests {
mcp_enabled: false, mcp_enabled: false,
mcp_port: None, mcp_port: None,
mcp_token: None, mcp_token: None,
launch_on_login_declined: false,
language: None, language: None,
window_resize_warning_dismissed: false, window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false, disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false, keep_decrypted_profiles_in_ram: false,
}; };
@@ -1247,29 +1234,6 @@ mod tests {
); );
} }
#[test]
fn test_should_show_launch_on_login_prompt() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.should_show_launch_on_login_prompt();
assert!(result.is_ok(), "Should not fail");
let _should_show = result.unwrap();
}
#[test]
fn test_decline_launch_on_login() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings = manager.load_settings().unwrap();
assert!(!settings.launch_on_login_declined);
manager.decline_launch_on_login().unwrap();
let settings = manager.load_settings().unwrap();
assert!(settings.launch_on_login_declined);
}
#[test] #[test]
fn test_load_corrupted_settings_file() { fn test_load_corrupted_settings_file() {
let (manager, _temp_dir, _guard) = create_test_settings_manager(); let (manager, _temp_dir, _guard) = create_test_settings_manager();
+37
View File
@@ -49,6 +49,21 @@ impl SyncClient {
&self, &self,
key: &str, key: &str,
content_type: Option<&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> { ) -> SyncResult<PresignUploadResponse> {
let response = self let response = self
.client .client
@@ -58,6 +73,7 @@ impl SyncClient {
key: key.to_string(), key: key.to_string(),
content_type: content_type.map(|s| s.to_string()), content_type: content_type.map(|s| s.to_string()),
expires_in: Some(3600), expires_in: Some(3600),
metadata,
}) })
.send() .send()
.await .await
@@ -186,6 +202,21 @@ impl SyncClient {
presigned_url: &str, presigned_url: &str,
data: &[u8], data: &[u8],
content_type: Option<&str>, 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<()> { ) -> SyncResult<()> {
let mut req = self let mut req = self
.client .client
@@ -197,6 +228,12 @@ impl SyncClient {
req = req.header("Content-Type", ct); 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 let response = req
.send() .send()
.await .await
+8
View File
@@ -346,6 +346,14 @@ pub fn check_has_e2e_password() -> bool {
has_e2e_password() 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] #[tauri::command]
pub async fn delete_e2e_password() -> Result<(), String> { pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?; enforce_team_owner_for_encryption_change().await?;
+342 -265
View File
@@ -10,11 +10,53 @@ use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant; use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore}; 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 /// Upload/download concurrency limit
const SYNC_CONCURRENCY: usize = 32; const SYNC_CONCURRENCY: usize = 32;
@@ -252,7 +294,10 @@ impl SyncProgressTracker {
/// Check if sync is configured (cloud or self-hosted) /// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool { 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; return true;
} }
let manager = SettingsManager::instance(); let manager = SettingsManager::instance();
@@ -321,6 +366,67 @@ impl SyncEngine {
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await !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( pub async fn sync_profile(
&self, &self,
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
@@ -391,6 +497,9 @@ impl SyncEngine {
let profile_dir = profiles_dir.join(profile.id.to_string()); let profile_dir = profiles_dir.join(profile.id.to_string());
let profile_id = 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 // Determine team key prefix for team profiles
let key_prefix = Self::get_team_key_prefix(profile).await; let key_prefix = Self::get_team_key_prefix(profile).await;
@@ -514,10 +623,16 @@ impl SyncEngine {
&diff.files_to_upload, &diff.files_to_upload,
encryption_key.as_ref(), encryption_key.as_ref(),
&key_prefix, &key_prefix,
&cancel_flag,
) )
.await?; .await?;
} }
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after uploads", profile_id);
return Err(SyncError::Cancelled);
}
// Perform downloads // Perform downloads
if !diff.files_to_download.is_empty() { if !diff.files_to_download.is_empty() {
self self
@@ -529,10 +644,16 @@ impl SyncEngine {
&diff.files_to_download, &diff.files_to_download,
encryption_key.as_ref(), encryption_key.as_ref(),
&key_prefix, &key_prefix,
&cancel_flag,
) )
.await?; .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) // Delete local files that don't exist remotely (when remote is newer)
for path in &diff.files_to_delete_local { for path in &diff.files_to_delete_local {
let file_path = profile_dir.join(path); let file_path = profile_dir.join(path);
@@ -823,6 +944,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry], files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>, encryption_key: Option<&[u8; 32]>,
key_prefix: &str, key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> { ) -> SyncResult<()> {
if files.is_empty() { if files.is_empty() {
return Ok(()); return Ok(());
@@ -930,6 +1052,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0)); let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process { 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 sem = semaphore.clone();
let file_path = profile_dir.join(&file.path); let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone(); let relative_path = file.path.clone();
@@ -958,6 +1087,7 @@ impl SyncEngine {
let resume_state = resume_state.clone(); let resume_state = resume_state.clone();
let save_counter = save_counter.clone(); let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone(); let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
let content_type = mime_guess::from_path(&file.path) let content_type = mime_guess::from_path(&file.path)
.first() .first()
.map(|m| m.to_string()); .map(|m| m.to_string());
@@ -965,6 +1095,10 @@ impl SyncEngine {
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap(); 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) { let data = match fs::read(&file_path) {
Ok(d) => d, Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => { Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
@@ -1095,6 +1229,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry], files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>, encryption_key: Option<&[u8; 32]>,
key_prefix: &str, key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> { ) -> SyncResult<()> {
if files.is_empty() { if files.is_empty() {
return Ok(()); return Ok(());
@@ -1194,6 +1329,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0)); let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process { 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 sem = semaphore.clone();
let file_path = profile_dir.join(&file.path); let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone(); let relative_path = file.path.clone();
@@ -1222,13 +1364,21 @@ impl SyncEngine {
let resume_state = resume_state.clone(); let resume_state = resume_state.clone();
let save_counter = save_counter.clone(); let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone(); let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap(); 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 // Retry loop for network downloads
let mut last_err = String::new(); let mut last_err = String::new();
for attempt in 0..MAX_FILE_RETRIES { 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 { match client.download_bytes(&url).await {
Ok(data) => { Ok(data) => {
let write_data = if let Some(ref key) = enc_key { let write_data = if let Some(ref key) = enc_key {
@@ -1350,21 +1500,13 @@ impl SyncEngine {
match (local_proxy, stat.exists) { match (local_proxy, stat.exists) {
(Some(proxy), true) => { (Some(proxy), true) => {
// Both exist - compare timestamps // Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = proxy.last_sync.unwrap_or(0); let local_updated = proxy.updated_at.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
.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;
if remote_ts > local_updated { if remote_updated > local_updated {
// Remote is newer - download
self.download_proxy(proxy_id, app_handle).await?; self.download_proxy(proxy_id, app_handle).await?;
} else if local_updated > remote_ts { } else if local_updated > remote_updated {
// Local is newer - upload
self.upload_proxy(&proxy).await?; self.upload_proxy(&proxy).await?;
} }
} }
@@ -1397,17 +1539,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_proxy) let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?; .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 remote_key = format!("proxies/{}.json", proxy.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self self
.client .upload_config_json(&remote_key, &json, updated_proxy.updated_at.unwrap_or(0))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?; .await?;
// Update local proxy with new last_sync (always write plaintext locally) // 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 // Emit event for UI update
if let Some(_handle) = app_handle { if let Some(_handle) = app_handle {
let _ = events::emit("stored-proxies-changed", ()); let _ = events::emit("stored-proxies-changed", ());
@@ -1498,21 +1639,13 @@ impl SyncEngine {
match (local_group, stat.exists) { match (local_group, stat.exists) {
(Some(group), true) => { (Some(group), true) => {
// Both exist - compare timestamps // Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.last_sync.unwrap_or(0); let local_updated = group.updated_at.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
.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;
if remote_ts > local_updated { if remote_updated > local_updated {
// Remote is newer - download
self.download_group(group_id, app_handle).await?; self.download_group(group_id, app_handle).await?;
} else if local_updated > remote_ts { } else if local_updated > remote_updated {
// Local is newer - upload
self.upload_group(&group).await?; self.upload_group(&group).await?;
} }
} }
@@ -1545,17 +1678,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_group) let json = serde_json::to_string_pretty(&updated_group)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?; .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 remote_key = format!("groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self self
.client .upload_config_json(&remote_key, &json, updated_group.updated_at.unwrap_or(0))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?; .await?;
// Update local group with new last_sync // Update local group with new last_sync
@@ -1714,18 +1839,13 @@ impl SyncEngine {
match (local_vpn, stat.exists) { match (local_vpn, stat.exists) {
(Some(vpn), true) => { (Some(vpn), true) => {
let local_updated = vpn.last_sync.unwrap_or(0); // Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated: DateTime<Utc> = stat let local_updated = vpn.updated_at.unwrap_or(0);
.last_modified let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
.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;
if remote_ts > local_updated { if remote_updated > local_updated {
self.download_vpn(vpn_id, app_handle).await?; 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?; self.upload_vpn(&vpn).await?;
} }
} }
@@ -1755,17 +1875,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_vpn) let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?; .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 remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self self
.client .upload_config_json(&remote_key, &json, updated_vpn.updated_at.unwrap_or(0))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?; .await?;
// Update local VPN with new last_sync // Update local VPN with new last_sync
@@ -1865,18 +1977,13 @@ impl SyncEngine {
match (local_ext, stat.exists) { match (local_ext, stat.exists) {
(Some(ext), true) => { (Some(ext), true) => {
let local_updated = ext.last_sync.unwrap_or(0); // Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated: DateTime<Utc> = stat let local_updated = ext.updated_at;
.last_modified let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
.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;
if remote_ts > local_updated { if remote_updated > local_updated {
self.download_extension(ext_id, app_handle).await?; 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?; self.upload_extension(&ext).await?;
} }
} }
@@ -1906,17 +2013,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_ext) let json = serde_json::to_string_pretty(&updated_ext)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?; .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 remote_key = format!("extensions/{}.json", ext.id);
let presign = self
.client
.presign_upload(&remote_key, Some(meta_content_type))
.await?;
self self
.client .upload_config_json(&remote_key, &json, updated_ext.updated_at)
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
.await?; .await?;
// Also upload the extension file data — encrypted as a sealed envelope // Also upload the extension file data — encrypted as a sealed envelope
@@ -2070,18 +2169,13 @@ impl SyncEngine {
match (local_group, stat.exists) { match (local_group, stat.exists) {
(Some(group), true) => { (Some(group), true) => {
let local_updated = group.last_sync.unwrap_or(0); // Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated: DateTime<Utc> = stat let local_updated = group.updated_at;
.last_modified let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
.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;
if remote_ts > local_updated { if remote_updated > local_updated {
self.download_extension_group(group_id, app_handle).await?; 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?; self.upload_extension_group(&group).await?;
} }
} }
@@ -2115,17 +2209,9 @@ impl SyncEngine {
SyncError::SerializationError(format!("Failed to serialize extension group: {e}")) 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 remote_key = format!("extension_groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self self
.client .upload_config_json(&remote_key, &json, updated_group.updated_at)
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?; .await?;
// Update local group with new last_sync // Update local group with new last_sync
@@ -2361,6 +2447,8 @@ impl SyncEngine {
); );
} }
if !manifest.files.is_empty() { if !manifest.files.is_empty() {
let cancel_flag = register_sync_cancel(profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
self self
.download_profile_files( .download_profile_files(
app_handle, app_handle,
@@ -2370,6 +2458,7 @@ impl SyncEngine {
&manifest.files, &manifest.files,
encryption_key.as_ref(), encryption_key.as_ref(),
key_prefix, key_prefix,
&cancel_flag,
) )
.await?; .await?;
} }
@@ -2506,8 +2595,46 @@ impl SyncEngine {
profiles_to_check.len() 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 { 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 match self
.download_profile_if_missing(app_handle, profile_id, key_prefix) .download_profile_if_missing(app_handle, profile_id, key_prefix)
.await .await
@@ -2571,6 +2698,24 @@ impl SyncEngine {
}; };
if has_personal_tombstone || has_team_tombstone { 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!( log::info!(
"Profile {} has remote tombstone, deleting locally (deleted on another device)", "Profile {} has remote tombstone, deleting locally (deleted on another device)",
pid 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()); 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 { if profile.ephemeral {
return Err("Cannot enable sync for an ephemeral profile".to_string()); 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", ()); 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 { if enabling {
let is_running = profile.process_id.is_some(); 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"); log::warn!("Scheduler not initialized, sync will not start");
} }
} else { } 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 { if old_mode != SyncMode::Disabled {
let profile_id_clone = profile_id.clone(); match SyncEngine::create_from_settings(&app_handle).await {
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
match SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => { Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id_clone).await { if let Err(e) = engine.delete_profile(&profile_id).await {
log::warn!( log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
"Failed to delete profile {} from sync: {}",
profile_id_clone,
e
);
} else { } else {
log::info!("Profile {} deleted from sync service", profile_id_clone); log::info!("Profile {} deleted from sync service", profile_id);
} }
} }
Err(e) => { Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e); log::debug!("Sync not configured, skipping remote deletion: {}", e);
} }
} }
});
} }
let _ = events::emit( 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 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( pub async fn trigger_sync_for_profile(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
profile_id: String, profile_id: String,
@@ -3222,43 +3407,29 @@ pub async fn set_proxy_sync_enabled(
let proxy = proxies let proxy = proxies
.iter() .iter()
.find(|p| p.id == proxy_id) .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 // Block modifying sync for cloud-managed proxies
if proxy.is_cloud_managed { 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 disabling, check if proxy is used by any synced profile
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) { 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 enabling, check that sync settings are configured
if enabled { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let new_last_sync = if enabled { proxy.last_sync } else { None }; 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", ()); let _ = events::emit("stored-proxies-changed", ());
@@ -3299,36 +3470,18 @@ pub async fn set_group_sync_enabled(
groups groups
.iter() .iter()
.find(|g| g.id == group_id) .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() .clone()
}; };
// If disabling, check if group is used by any synced profile // If disabling, check if group is used by any synced profile
if !enabled && is_group_used_by_synced_profile(&group_id) { 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 enabling, check that sync settings are configured
if enabled { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let mut updated_group = group.clone(); 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(); let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
if let Err(e) = group_manager.update_group_internal(&updated_group) { 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(); let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage storage
.load_config(&vpn_id) .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 disabling, check if VPN is used by any synced profile
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) { 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 enabling, check that sync settings are configured
if enabled { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let last_sync = if enabled { vpn.last_sync } else { None }; 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(); let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage storage
.update_sync_fields(&vpn_id, enabled, last_sync) .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", ()); let _ = events::emit("vpn-configs-changed", ());
@@ -3526,48 +3667,10 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command] #[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> { pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all eligible profiles. Without this the user would see // Intentionally excludes profiles: enabling profile sync uploads the entire
// groups/proxies/vpns syncing while their profiles stay local-only — the // browser data dir per profile, which is destructive if the user expected
// long-standing source of issue #352. Encrypted mode wins when an E2E // an opt-in. Profile sync stays under explicit per-profile control via
// password is already configured; otherwise we fall back to plain Regular. // set_profile_sync_mode. This command only touches metadata-sized entities.
{
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let desired_mode = if encryption::has_e2e_password() {
SyncMode::Encrypted
} else {
SyncMode::Regular
};
let desired_mode_str = match desired_mode {
SyncMode::Encrypted => "Encrypted",
SyncMode::Regular => "Regular",
SyncMode::Disabled => "Disabled",
};
for profile in &profiles {
// Skip profiles that are already syncing (any non-Disabled mode),
// ephemeral profiles (data wipes on quit, sync is meaningless), and
// cross-OS profiles (the OS-specific binary isn't installed locally
// so a sync round-trip would be one-sided).
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
continue;
}
if let Err(e) = set_profile_sync_mode(
app_handle.clone(),
profile.id.to_string(),
desired_mode_str.to_string(),
)
.await
{
log::warn!(
"Failed to enable sync for profile {} ({}): {e}",
profile.name,
profile.id
);
}
}
}
// Enable sync for all unsynced proxies // Enable sync for all unsynced proxies
{ {
@@ -3664,26 +3767,11 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager
.get_extension(&extension_id) .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 { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let mut updated_ext = ext; let mut updated_ext = ext;
@@ -3696,7 +3784,10 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager
.update_extension_internal(&updated_ext) .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", ()); let _ = events::emit("extensions-changed", ());
@@ -3720,26 +3811,11 @@ pub async fn set_extension_group_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager
.get_group(&extension_group_id) .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 { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let mut updated_group = group; let mut updated_group = group;
@@ -3750,9 +3826,10 @@ pub async fn set_extension_group_sync_enabled(
{ {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager.update_group_internal(&updated_group).map_err(|e| {
.update_group_internal(&updated_group) serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.map_err(|e| format!("Failed to update extension group sync: {e}"))?; .to_string()
})?;
} }
let _ = events::emit("extensions-changed", ()); let _ = events::emit("extensions-changed", ());
+13 -3
View File
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/startupCache/**", "**/startupCache/**",
"**/safebrowsing/**", "**/safebrowsing/**",
"**/storage/temporary/**", "**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/sessionstore-backups/**",
"**/sessions/**",
"**/serviceworker.txt",
"**/AlternateServices.bin",
"**/SiteSecurityServiceState.bin",
"**/favicons.sqlite",
"**/favicons.sqlite-*",
"**/crashes/**", "**/crashes/**",
"**/minidumps/**", "**/minidumps/**",
"*.tmp", "*.tmp",
@@ -52,9 +62,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*", "**/BrowserMetrics*",
"**/.DS_Store", "**/.DS_Store",
".donut-sync/**", ".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's // Orphaned local-only marker from earlier rollover-based fingerprint
// fingerprint. Each device decides its own refresh cadence, so syncing // regeneration. Keep excluding it so any markers left on disk from
// this would cause one device's refresh to silence others. // prior builds never get uploaded.
".last-fp-refresh", ".last-fp-refresh",
]; ];
+24 -4
View File
@@ -7,11 +7,13 @@ pub mod subscription;
pub mod types; pub mod types;
pub use client::SyncClient; 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::{ pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed, cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts, enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, 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_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, 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, 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 scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem}; pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult}; 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() { match entity_type.as_str() {
"profile" => { "profile" => {
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let has_profile = { let local_sync_enabled = {
if let Ok(profiles) = profile_manager.list_profiles() { if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok(); 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 { } else {
false false
} }
}; };
if has_profile { if local_sync_enabled {
log::info!( log::info!(
"Profile {} was deleted remotely, deleting locally", "Profile {} was deleted remotely, deleting locally",
entity_id entity_id
@@ -733,6 +735,11 @@ impl SyncScheduler {
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) { if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e); 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" => { "proxy" => {
+16
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatRequest { pub struct StatRequest {
@@ -11,6 +12,11 @@ pub struct StatResponse {
#[serde(rename = "lastModified")] #[serde(rename = "lastModified")]
pub last_modified: Option<String>, pub last_modified: Option<String>,
pub size: Option<u64>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
pub content_type: Option<String>, pub content_type: Option<String>,
#[serde(rename = "expiresIn")] #[serde(rename = "expiresIn")]
pub expires_in: Option<u64>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
pub url: String, pub url: String,
#[serde(rename = "expiresAt")] #[serde(rename = "expiresAt")]
pub expires_at: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -166,6 +180,7 @@ pub enum SyncError {
SerializationError(String), SerializationError(String),
ConflictError(String), ConflictError(String),
InvalidData(String), InvalidData(String),
Cancelled,
} }
impl std::fmt::Display for SyncError { 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::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"), SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
SyncError::InvalidData(msg) => write!(f, "Invalid data: {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, pub sync_enabled: bool,
#[serde(default)] #[serde(default)]
pub last_sync: Option<u64>, 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 /// Parsed WireGuard configuration
+12
View File
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
sync_enabled: bool, sync_enabled: bool,
#[serde(default)] #[serde(default)]
last_sync: Option<u64>, last_sync: Option<u64>,
#[serde(default)]
updated_at: Option<u64>,
} }
/// VPN storage manager with encryption /// VPN storage manager with encryption
@@ -247,6 +249,7 @@ impl VpnStorage {
last_used: config.last_used, last_used: config.last_used,
sync_enabled: config.sync_enabled, sync_enabled: config.sync_enabled,
last_sync: config.last_sync, last_sync: config.last_sync,
updated_at: config.updated_at,
}; };
// Update existing or add new // Update existing or add new
@@ -280,6 +283,7 @@ impl VpnStorage {
last_used: stored.last_used, last_used: stored.last_used,
sync_enabled: stored.sync_enabled, sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync, last_sync: stored.last_sync,
updated_at: stored.updated_at,
}) })
} }
@@ -300,6 +304,7 @@ impl VpnStorage {
last_used: stored.last_used, last_used: stored.last_used,
sync_enabled: stored.sync_enabled, sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync, last_sync: stored.last_sync,
updated_at: stored.updated_at,
}) })
.collect(), .collect(),
) )
@@ -356,6 +361,7 @@ impl VpnStorage {
last_used: None, last_used: None,
sync_enabled, sync_enabled,
last_sync: None, last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
}; };
self.save_config(&config)?; self.save_config(&config)?;
@@ -367,6 +373,7 @@ impl VpnStorage {
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> { pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
let mut config = self.load_config(id)?; let mut config = self.load_config(id)?;
config.name = new_name.to_string(); config.name = new_name.to_string();
config.updated_at = Some(crate::proxy_manager::now_secs());
self.save_config(&config)?; self.save_config(&config)?;
Ok(config) Ok(config)
} }
@@ -420,6 +427,7 @@ impl VpnStorage {
last_used: None, last_used: None,
sync_enabled, sync_enabled,
last_sync: None, last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
}; };
self.save_config(&config)?; self.save_config(&config)?;
@@ -463,6 +471,7 @@ mod tests {
last_used: None, last_used: None,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
}; };
storage.save_config(&config).unwrap(); storage.save_config(&config).unwrap();
@@ -487,6 +496,7 @@ mod tests {
last_used: None, last_used: None,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
}; };
let config2 = VpnConfig { let config2 = VpnConfig {
@@ -498,6 +508,7 @@ mod tests {
last_used: Some(3000), last_used: Some(3000),
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
}; };
storage.save_config(&config1).unwrap(); storage.save_config(&config1).unwrap();
@@ -524,6 +535,7 @@ mod tests {
last_used: None, last_used: None,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
}; };
storage.save_config(&config).unwrap(); storage.save_config(&config).unwrap();
+159 -3
View File
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
pub profilePath: Option<String>, pub profilePath: Option<String>,
pub url: Option<String>, pub url: Option<String>,
pub cdp_port: Option<u16>, 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 { struct WayfernInstance {
@@ -132,6 +138,46 @@ impl WayfernManager {
fingerprint 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( async fn wait_for_cdp_ready(
&self, &self,
port: u16, port: u16,
@@ -605,13 +651,30 @@ impl WayfernManager {
"--disable-session-crashed-bubble".to_string(), "--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(), "--hide-crash-restore-bubble".to_string(),
"--disable-infobars".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(), "--use-mock-keychain".to_string(),
"--password-store=basic".to_string(), "--password-store=basic".to_string(),
]; ];
if headless { if headless {
args.push("--headless=new".to_string()); 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")] #[cfg(target_os = "linux")]
@@ -703,6 +766,7 @@ impl WayfernManager {
log::info!("Found {} page targets", page_targets.len()); log::info!("Found {} page targets", page_targets.len());
// Apply fingerprint if configured // Apply fingerprint if configured
let mut used_fingerprint: Option<String> = None;
if let Some(fingerprint_json) = &config.fingerprint { if let Some(fingerprint_json) = &config.fingerprint {
log::info!( log::info!(
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars", "Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
@@ -781,10 +845,30 @@ impl WayfernManager {
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone()) .send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await .await
{ {
Ok(result) => log::info!( Ok(result) => {
log::info!(
"Successfully applied fingerprint to page target: {:?}", "Successfully applied fingerprint to page target: {:?}",
result 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}"), Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
} }
} }
@@ -849,6 +933,7 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()), profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()), url: url.map(|s| s.to_string()),
cdp_port: Some(port), cdp_port: Some(port),
used_fingerprint,
}) })
} }
@@ -990,6 +1075,7 @@ impl WayfernManager {
profilePath: instance.profile_path.clone(), profilePath: instance.profile_path.clone(),
url: instance.url.clone(), url: instance.url.clone(),
cdp_port: instance.cdp_port, cdp_port: instance.cdp_port,
used_fingerprint: None,
}); });
} else { } else {
log::info!( log::info!(
@@ -1032,6 +1118,7 @@ impl WayfernManager {
profilePath: Some(found_profile_path), profilePath: Some(found_profile_path),
url: None, url: None,
cdp_port, cdp_port,
used_fingerprint: None,
}); });
} }
@@ -1168,3 +1255,72 @@ impl WayfernManager {
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new(); 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", "$schema": "https://schema.tauri.app/config/2",
"productName": "Donut", "productName": "Donut",
"version": "0.24.1", "version": "0.26.0",
"identifier": "com.donutbrowser", "identifier": "com.donutbrowser",
"build": { "build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev", "beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -19,7 +19,7 @@
"active": true, "active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"], "targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity", "category": "Productivity",
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"], "externalBin": ["binaries/donut-proxy"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -42,11 +42,11 @@
"linux": { "linux": {
"deb": { "deb": {
"desktopTemplate": "donutbrowser.desktop", "desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo3"] "depends": ["xdg-utils", "libxdo3", "libayatana-appindicator3-1"]
}, },
"rpm": { "rpm": {
"desktopTemplate": "donutbrowser.desktop", "desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo"] "depends": ["xdg-utils", "libxdo", "libayatana-appindicator-gtk3"]
}, },
"appimage": { "appimage": {
"files": { "files": {
+4
View File
@@ -135,6 +135,7 @@ fn test_vpn_storage_save_and_load() {
last_used: None, last_used: None,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
}; };
let save_result = storage.save_config(&config); let save_result = storage.save_config(&config);
@@ -174,6 +175,7 @@ fn test_vpn_storage_list() {
last_used: None, last_used: None,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
}; };
storage.save_config(&config).unwrap(); storage.save_config(&config).unwrap();
} }
@@ -201,6 +203,7 @@ fn test_vpn_storage_delete() {
last_used: None, last_used: None,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
}; };
storage.save_config(&config).unwrap(); 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, last_used: None,
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
updated_at: None,
} }
} }
+330 -56
View File
@@ -3,11 +3,15 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useOnborda } from "onborda";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page"; import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-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 { CommercialTrialModal } from "@/components/commercial-trial-modal";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-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 HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-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 { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table"; import { ProfilesDataTable } from "@/components/profile-data-table";
import { import {
@@ -34,10 +38,13 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog"; import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { type AppPage, RailNav } from "@/components/rail-nav"; import { type AppPage, RailNav } from "@/components/rail-nav";
import { SettingsDialog } from "@/components/settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog";
import { ShortcutsPage } from "@/components/shortcuts-page";
import { SyncAllDialog } from "@/components/sync-all-dialog"; import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog"; import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog"; import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { ThankYouDialog } from "@/components/thank-you-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog"; import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WelcomeDialog } from "@/components/welcome-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog"; import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth"; 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 { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors"; 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 { import {
dismissToast, dismissToast,
showErrorToast, showErrorToast,
@@ -87,6 +105,95 @@ export default function Home() {
error: profilesError, error: profilesError,
} = useProfileEvents(); } = 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 { const {
groups: groupsData, groups: groupsData,
isLoading: groupsLoading, isLoading: groupsLoading,
@@ -120,10 +227,7 @@ export default function Home() {
// Cloud auth for cross-OS unlock // Cloud auth for cross-OS unlock
const { user: cloudUser } = useCloudAuth(); const { user: cloudUser } = useCloudAuth();
const crossOsUnlocked = const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
cloudUser?.plan !== "free" &&
(cloudUser?.subscriptionStatus === "active" ||
cloudUser?.planPeriod === "lifetime");
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] = const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
useState(false); useState(false);
@@ -149,6 +253,11 @@ export default function Home() {
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState< const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
"proxies" | "vpns" "proxies" | "vpns"
>("proxies"); >("proxies");
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
useState<"extensions" | "groups">("extensions");
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
"api" | "mcp"
>("api");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false); const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -201,8 +310,6 @@ export default function Home() {
const [passwordDialogMode, setPasswordDialogMode] = const [passwordDialogMode, setPasswordDialogMode] =
useState<PasswordDialogMode>("set"); useState<PasswordDialogMode>("set");
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null); const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false); const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] = const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
useState<string | undefined>(undefined); useState<string | undefined>(undefined);
@@ -221,6 +328,11 @@ export default function Home() {
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false); const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] = const [currentProfileForSync, setCurrentProfileForSync] =
useState<BrowserProfile | null>(null); 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 } = const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions(); usePermissions();
@@ -273,9 +385,134 @@ export default function Home() {
case "account": case "account":
setAccountDialogOpen(true); setAccountDialogOpen(true);
break; 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 // Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => { const checkMissingBinaries = useCallback(async () => {
try { try {
@@ -402,24 +639,6 @@ export default function Home() {
} }
}, [handleUrlOpen, hasCheckedStartupUrl]); }, [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 // Handle profile errors from useProfileEvents hook
useEffect(() => { useEffect(() => {
if (profilesError) { if (profilesError) {
@@ -613,7 +832,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig, wayfernConfig: profileData.wayfernConfig,
groupId: groupId:
profileData.groupId ?? profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined), (selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral, ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist, dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook, launchHook: profileData.launchHook,
@@ -650,9 +871,12 @@ export default function Home() {
} catch (error) { } catch (error) {
showErrorToast( showErrorToast(
t("errors.createProfileFailed", { 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], [selectedGroupId, t],
@@ -943,11 +1167,14 @@ export default function Home() {
profileId: profile.id, profileId: profile.id,
syncMode: enabling ? "Regular" : "Disabled", syncMode: enabling ? "Regular" : "Disabled",
}); });
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", { showSuccessToast(
description: enabling t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
? "Profile sync has been enabled" {
: "Profile sync has been disabled", description: t(
}); enabling ? "sync.enabledDescription" : "sync.disabledDescription",
),
},
);
} catch (error) { } catch (error) {
console.error("Failed to toggle sync:", error); console.error("Failed to toggle sync:", error);
showErrorToast(t("errors.updateSyncSettingsFailed")); showErrorToast(t("errors.updateSyncSettingsFailed"));
@@ -1029,7 +1256,7 @@ export default function Home() {
failed_count: payload.failed_count ?? 0, failed_count: payload.failed_count ?? 0,
phase: payload.phase, phase: payload.phase,
}, },
{ id: toastId }, { id: toastId, profileId: payload.profile_id },
); );
} }
}); });
@@ -1044,9 +1271,6 @@ export default function Home() {
}, [profiles, t]); }, [profiles, t]);
useEffect(() => { useEffect(() => {
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events and get cleanup function // Listen for URL open events and get cleanup function
const setupListeners = async () => { const setupListeners = async () => {
const cleanup = await listenForUrlEvents(); const cleanup = await listenForUrlEvents();
@@ -1089,7 +1313,6 @@ export default function Home() {
}; };
}, [ }, [
checkForUpdates, checkForUpdates,
checkStartupPrompt,
listenForUrlEvents, listenForUrlEvents,
checkCurrentUrl, checkCurrentUrl,
checkMissingBinaries, checkMissingBinaries,
@@ -1104,6 +1327,7 @@ export default function Home() {
let unlistenStarted: (() => void) | undefined; let unlistenStarted: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined; let unlistenProgress: (() => void) | undefined;
let unlistenCompleted: (() => void) | undefined; let unlistenCompleted: (() => void) | undefined;
let unlistenWayfernBlocked: (() => void) | undefined;
void (async () => { void (async () => {
unlistenRequired = await listen( unlistenRequired = await listen(
@@ -1165,6 +1389,16 @@ export default function Home() {
duration: 5000, 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 () => { return () => {
@@ -1172,6 +1406,7 @@ export default function Home() {
unlistenStarted?.(); unlistenStarted?.();
unlistenProgress?.(); unlistenProgress?.();
unlistenCompleted?.(); unlistenCompleted?.();
unlistenWayfernBlocked?.();
}; };
}, [t]); }, [t]);
@@ -1191,11 +1426,13 @@ export default function Home() {
showToast({ showToast({
id: "browser-support-ending-warning", id: "browser-support-ending-warning",
type: "error", type: "error",
title: "Browser support ending soon", title: t("browserSupport.endingSoonTitle"),
description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`, description: t("browserSupport.endingSoonDescription", {
profiles: unsupportedNames,
}),
duration: 15000, duration: 15000,
action: { action: {
label: "Learn more", label: t("common.buttons.learnMore"),
onClick: () => { onClick: () => {
const event = new CustomEvent("url-open-request", { const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions", 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 // Re-check Wayfern terms when a browser download completes
useEffect(() => { useEffect(() => {
@@ -1226,12 +1463,14 @@ export default function Home() {
}; };
}, [checkTerms]); }, [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(() => { useEffect(() => {
if (isInitialized) { if (isInitialized && firstRunOnboarding === false) {
checkAllPermissions(); checkAllPermissions();
} }
}, [isInitialized, checkAllPermissions]); }, [isInitialized, firstRunOnboarding, checkAllPermissions]);
// Check self-hosted sync config on mount and when cloud user changes // Check self-hosted sync config on mount and when cloud user changes
useEffect(() => { useEffect(() => {
@@ -1243,11 +1482,10 @@ export default function Home() {
let filtered = profiles; let filtered = profiles;
// Filter by group. "__all__" is a virtual filter that shows every // Filter by group. "__all__" is a virtual filter that shows every
// profile regardless of group; "default" shows ungrouped profiles. // profile (including ungrouped ones). Any other value is a real
if (selectedGroupId === "__all__") { // group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles; filtered = profiles;
} else if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else { } else {
filtered = profiles.filter( filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId, (profile) => profile.group_id === selectedGroupId,
@@ -1287,11 +1525,14 @@ export default function Home() {
return ( return (
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)"> <div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<CloseConfirmDialog />
<CamoufoxDeprecationDialog profiles={profiles} />
<HomeHeader <HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen} onCreateProfileDialogOpen={setCreateProfileDialogOpen}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
groups={groupsData} groups={groupsData}
totalProfiles={profiles.length}
selectedGroupId={selectedGroupId} selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup} onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle} pageTitle={subPageTitle}
@@ -1304,6 +1545,8 @@ export default function Home() {
{isLoading && groupsData.length === 0 ? null : null} {isLoading && groupsData.length === 0 ? null : null}
<ProfilesDataTable <ProfilesDataTable
profiles={filteredProfiles} profiles={filteredProfiles}
infoDialogProfile={profileInfoDialog}
onInfoDialogProfileChange={setProfileInfoDialog}
onLaunchProfile={launchProfile} onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile} onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile} onCloneProfile={handleCloneProfile}
@@ -1342,6 +1585,10 @@ export default function Home() {
</div> </div>
)} )}
{currentPage === "shortcuts" && (
<ShortcutsPage groupTargets={orderedGroupTargets} />
)}
{settingsDialogOpen && ( {settingsDialogOpen && (
<SettingsDialog <SettingsDialog
isOpen={settingsDialogOpen} isOpen={settingsDialogOpen}
@@ -1366,6 +1613,7 @@ export default function Home() {
setCurrentPage("profiles"); setCurrentPage("profiles");
}} }}
subPage={currentPage === "integrations"} subPage={currentPage === "integrations"}
initialTab={integrationsInitialTab}
/> />
)} )}
@@ -1402,6 +1650,7 @@ export default function Home() {
}} }}
limitedMode={false} limitedMode={false}
subPage={currentPage === "extensions"} subPage={currentPage === "extensions"}
initialTab={extensionManagementInitialTab}
/> />
)} )}
@@ -1445,6 +1694,29 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked} 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) => ( {pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog <ProfileSelectorDialog
key={pendingUrl.id} key={pendingUrl.id}
@@ -1469,6 +1741,16 @@ export default function Home() {
onPermissionGranted={checkNextPermission} onPermissionGranted={checkNextPermission}
/> />
<WelcomeDialog
isOpen={welcomeOpen}
needsSetup={profiles.length === 0}
onComplete={handleWelcomeComplete}
/>
<ThankYouDialog
isOpen={thankYouOpen}
onClose={() => setThankYouOpen(false)}
/>
<CloneProfileDialog <CloneProfileDialog
isOpen={!!cloneProfile} isOpen={!!cloneProfile}
onClose={() => { onClose={() => {
@@ -1673,14 +1955,6 @@ export default function Home() {
onClose={checkTrialStatus} onClose={checkTrialStatus}
/> />
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
<LaunchOnLoginDialog
isOpen={launchOnLoginDialogOpen}
onClose={() => {
setLaunchOnLoginDialogOpen(false);
}}
/>
<WindowResizeWarningDialog <WindowResizeWarningDialog
isOpen={windowResizeWarningOpen} isOpen={windowResizeWarningOpen}
browserType={windowResizeWarningBrowserType} browserType={windowResizeWarningBrowserType}
+57 -39
View File
@@ -12,16 +12,21 @@ import {
LuUser, LuUser,
} from "react-icons/lu"; } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors"; import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types"; import type { SyncSettings } from "@/types";
interface AccountPageProps { interface AccountPageProps {
@@ -194,25 +199,12 @@ export function AccountPage({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}> <Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col"> <DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
<Tabs defaultValue="account"> <AnimatedTabs defaultValue="account">
<TabsList <AnimatedTabsList>
className={cn( <AnimatedTabsTrigger value="account">
"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",
)}
>
{t("account.tabs.account")} {t("account.tabs.account")}
</TabsTrigger> </AnimatedTabsTrigger>
<TabsTrigger <AnimatedTabsTrigger
value="self-hosted" value="self-hosted"
disabled={selfHostedDisabled} disabled={selfHostedDisabled}
title={ title={
@@ -220,21 +212,16 @@ export function AccountPage({
? t("account.selfHosted.disabledWhileLoggedIn") ? t("account.selfHosted.disabledWhileLoggedIn")
: undefined : undefined
} }
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs disabled:opacity-50 disabled:hover:text-muted-foreground",
)}
> >
{t("account.tabs.selfHosted")} {t("account.tabs.selfHosted")}
</TabsTrigger> </AnimatedTabsTrigger>
</TabsList> </AnimatedTabsList>
<TabsContent value="account" className="mt-4"> <AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-3"> <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"> <div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="w-6 h-6" /> <LuUser className="size-6" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{isLoggedIn && user ? ( {isLoggedIn && user ? (
@@ -294,8 +281,39 @@ export function AccountPage({
<p className="mt-0.5">{user.planPeriod}</p> <p className="mt-0.5">{user.planPeriod}</p>
</div> </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>
)} )}
</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"> <div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? ( {isLoggedIn ? (
@@ -309,7 +327,7 @@ export function AccountPage({
disabled={isRefreshing} disabled={isRefreshing}
className="h-8 text-xs gap-1.5" className="h-8 text-xs gap-1.5"
> >
<LuRefreshCw className="w-3 h-3" /> <LuRefreshCw className="size-3" />
{t("account.refresh")} {t("account.refresh")}
</Button> </Button>
<LoadingButton <LoadingButton
@@ -322,7 +340,7 @@ export function AccountPage({
}} }}
className="h-8 text-xs gap-1.5" className="h-8 text-xs gap-1.5"
> >
<LuLogOut className="w-3 h-3" /> <LuLogOut className="size-3" />
{t("account.logout")} {t("account.logout")}
</LoadingButton> </LoadingButton>
</> </>
@@ -332,15 +350,15 @@ export function AccountPage({
onClick={onOpenSignIn} onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5" className="h-8 text-xs gap-1.5"
> >
<LuCloud className="w-3 h-3" /> <LuCloud className="size-3" />
{t("account.signIn")} {t("account.signIn")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</TabsContent> </AnimatedTabsContent>
<TabsContent value="self-hosted" className="mt-4"> <AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? ( {selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is // Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI — // logged in, so this branch shouldn't be reachable via UI —
@@ -410,9 +428,9 @@ export function AccountPage({
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground" className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
> >
{showToken ? ( {showToken ? (
<LuEyeOff className="w-3.5 h-3.5" /> <LuEyeOff className="size-3.5" />
) : ( ) : (
<LuEye className="w-3.5 h-3.5" /> <LuEye className="size-3.5" />
)} )}
</button> </button>
</div> </div>
@@ -481,8 +499,8 @@ export function AccountPage({
</div> </div>
</div> </div>
)} )}
</TabsContent> </AnimatedTabsContent>
</Tabs> </AnimatedTabs>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+5 -5
View File
@@ -37,7 +37,7 @@ export function AppUpdateToast({
return ( 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="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"> <div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 w-5 h-5" /> <LuCheckCheck className="shrink-0 size-5" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -59,9 +59,9 @@ export function AppUpdateToast({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onDismiss} 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> </Button>
</div> </div>
@@ -72,7 +72,7 @@ export function AppUpdateToast({
size="sm" size="sm"
className="flex gap-2 items-center text-xs" className="flex gap-2 items-center text-xs"
> >
<LuCheckCheck className="w-3 h-3" /> <LuCheckCheck className="size-3" />
{t("appUpdate.toast.restartNow")} {t("appUpdate.toast.restartNow")}
</RippleButton> </RippleButton>
) : ( ) : (
@@ -83,7 +83,7 @@ export function AppUpdateToast({
size="sm" size="sm"
className="flex gap-2 items-center text-xs" className="flex gap-2 items-center text-xs"
> >
<FaExternalLinkAlt className="w-3 h-3" /> <FaExternalLinkAlt className="size-3" />
{t("appUpdate.toast.viewRelease")} {t("appUpdate.toast.viewRelease")}
</RippleButton> </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 { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider"; import { I18nProvider } from "@/components/i18n-provider";
import { OnboardingProvider } from "@/components/onboarding-provider";
import { CustomThemeProvider } from "@/components/theme-provider"; import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@@ -17,7 +18,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
<I18nProvider> <I18nProvider>
<CustomThemeProvider> <CustomThemeProvider>
<WindowDragArea /> <WindowDragArea />
<TooltipProvider>{children}</TooltipProvider> <TooltipProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</TooltipProvider>
<Toaster /> <Toaster />
</CustomThemeProvider> </CustomThemeProvider>
</I18nProvider> </I18nProvider>
+9 -7
View File
@@ -36,16 +36,18 @@ export function CloneProfileDialog({
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (isOpen && profile) { if (!(isOpen && profile)) {
const defaultName = `${profile.name} (Copy)`; setIsLoading(false);
setName(defaultName); return;
setTimeout(() => { }
setName(`${profile.name} (Copy)`);
const handle = window.setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
inputRef.current?.select(); inputRef.current?.select();
}, 0); }, 0);
} else { return () => {
setIsLoading(false); window.clearTimeout(handle);
} };
}, [isOpen, profile]); }, [isOpen, profile]);
if (!profile) return null; 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"> <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" /> <LuCookie className="size-5" />
{t("cookies.copy.title")} {t("cookies.copy.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
@@ -372,7 +372,7 @@ export function CookieCopyDialog({
disabled={isRunning} disabled={isRunning}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{IconComponent && <IconComponent className="w-4 h-4" />} {IconComponent && <IconComponent className="size-4" />}
<span>{profile.name}</span> <span>{profile.name}</span>
{isRunning && ( {isRunning && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
</div> </div>
<div className="relative"> <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 <Input
placeholder={t("cookies.copy.searchPlaceholder")} placeholder={t("cookies.copy.searchPlaceholder")}
value={searchQuery} value={searchQuery}
@@ -450,7 +450,7 @@ export function CookieCopyDialog({
{isLoadingCookies ? ( {isLoadingCookies ? (
<div className="flex items-center justify-center h-40"> <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> </div>
) : error ? ( ) : error ? (
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md"> <div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
@@ -565,9 +565,9 @@ function DomainRow({
}} }}
> >
{isExpanded ? ( {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="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
+7 -7
View File
@@ -15,9 +15,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple"; import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Select, Select,
SelectContent, 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"> <p className="text-sm text-muted-foreground text-center">
{t("cookies.management.dropPrompt")} {t("cookies.management.dropPrompt")}
<br /> <br />
@@ -556,14 +556,14 @@ export function CookieManagementDialog({
{isLoadingExportCookies ? ( {isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24"> <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> </div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? ( ) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md"> <div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
{t("cookies.management.noCookies")} {t("cookies.management.noCookies")}
</div> </div>
) : ( ) : (
<ScrollArea className="h-[200px] border rounded-md"> <FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => ( {exportCookieData.domains.map((domain) => (
<ExportDomainRow <ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/> />
))} ))}
</div> </div>
</ScrollArea> </FadingScrollArea>
)} )}
</div> </div>
@@ -645,9 +645,9 @@ function ExportDomainRow({
}} }}
> >
{isExpanded ? ( {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="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0"> <span className="text-xs text-muted-foreground shrink-0">
+144 -269
View File
@@ -1,14 +1,19 @@
"use client"; "use client";
import { invoke } from "@tauri-apps/api/core"; 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 { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go"; 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 { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; 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 { useVpnEvents } from "@/hooks/use-vpn-events";
import { getBrowserIcon } from "@/lib/browser-utils"; import { getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
BrowserReleaseTypes,
CamoufoxConfig,
CamoufoxOS,
WayfernConfig,
WayfernOS,
} from "@/types";
const getCurrentOS = (): CamoufoxOS => { const getCurrentOS = (): WayfernOS => {
if (typeof navigator === "undefined") return "linux"; if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase(); const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows"; if (platform.includes("win")) return "windows";
@@ -79,7 +78,6 @@ interface CreateProfileDialogProps {
releaseType: string; releaseType: string;
proxyId?: string; proxyId?: string;
vpnId?: string; vpnId?: string;
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig; wayfernConfig?: WayfernConfig;
groupId?: string; groupId?: string;
extensionGroupId?: string; extensionGroupId?: string;
@@ -98,10 +96,6 @@ interface BrowserOption {
} }
const browserOptions: BrowserOption[] = [ const browserOptions: BrowserOption[] = [
{
value: "camoufox",
label: "Camoufox",
},
{ {
value: "wayfern", value: "wayfern",
label: "Wayfern", label: "Wayfern",
@@ -116,29 +110,27 @@ export function CreateProfileDialog({
crossOsUnlocked = false, crossOsUnlocked = false,
}: CreateProfileDialogProps) { }: CreateProfileDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState(""); 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< const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config" "browser-selection" | "browser-config"
>("browser-selection"); >("browser-config");
const [activeTab, setActiveTab] = useState("anti-detect"); const [activeTab, setActiveTab] = useState("anti-detect");
// Browser selection states // Browser selection states. Defaults to Wayfern — the only creatable browser.
const [selectedBrowser, setSelectedBrowser] = const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>(null); useState<BrowserTypeString>("wayfern");
const [selectedProxyId, setSelectedProxyId] = useState<string>(); const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false); const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [dnsBlocklist, setDnsBlocklist] = useState<string>(""); const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
const [launchHook, setLaunchHook] = useState(""); 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 // Wayfern anti-detect states
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({ 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 // Handle browser selection from the initial screen
@@ -147,22 +139,23 @@ export function CreateProfileDialog({
setCurrentStep("browser-config"); setCurrentStep("browser-config");
}; };
// Handle back button // Reset the form fields without leaving the Wayfern config step — Camoufox is
const handleBack = () => { // deprecated, so there is no browser-selection screen to go back to.
setCurrentStep("browser-selection"); const resetForm = () => {
setSelectedBrowser(null); setSelectedBrowser("wayfern");
setProfileName(""); setProfileName("");
setSelectedProxyId(undefined); setSelectedProxyId(undefined);
setLaunchHook(""); setLaunchHook("");
}; };
// Handle back button
const handleBack = () => {
resetForm();
};
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
setCurrentStep("browser-selection"); resetForm();
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
}; };
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]); const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
@@ -298,12 +291,15 @@ export function CreateProfileDialog({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
void loadSupportedBrowsers(); 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 // Load release types when a browser is selected
if (selectedBrowser) { if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser); void loadReleaseTypes(selectedBrowser);
} }
// Check and download GeoIP database if needed for Camoufox or Wayfern // Wayfern needs the GeoIP database for fingerprint generation.
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") { if (selectedBrowser === "wayfern") {
void checkAndDownloadGeoIPDatabase(); void checkAndDownloadGeoIPDatabase();
} }
} }
@@ -311,6 +307,7 @@ export function CreateProfileDialog({
isOpen, isOpen,
loadSupportedBrowsers, loadSupportedBrowsers,
loadReleaseTypes, loadReleaseTypes,
loadDownloadedVersions,
checkAndDownloadGeoIPDatabase, checkAndDownloadGeoIPDatabase,
selectedBrowser, selectedBrowser,
]); ]);
@@ -396,14 +393,14 @@ export function CreateProfileDialog({
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId; const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId = const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined; isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet = const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password ? password
: undefined; : undefined;
try { try {
if (activeTab === "anti-detect") { if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected // Camoufox is deprecated — only Wayfern anti-detect profiles are created.
if (selectedBrowser === "wayfern") {
const bestWayfernVersion = getCreatableVersion("wayfern"); const bestWayfernVersion = getCreatableVersion("wayfern");
if (!bestWayfernVersion) { if (!bestWayfernVersion) {
console.error("No Wayfern version available"); console.error("No Wayfern version available");
@@ -422,42 +419,15 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId, vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig, wayfernConfig: finalWayfernConfig,
groupId: groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined, selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId, extensionGroupId: selectedExtensionGroupId,
ephemeral, ephemeral,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined, launchHook: launchHook.trim() || undefined,
password: passwordToSet, 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,
});
}
} else { } else {
// Regular browser // Regular browser
if (!selectedBrowser) { if (!selectedBrowser) {
@@ -478,7 +448,10 @@ export function CreateProfileDialog({
version: bestVersion.version, version: bestVersion.version,
releaseType: bestVersion.releaseType, releaseType: bestVersion.releaseType,
proxyId: selectedProxyId, proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined, launchHook: launchHook.trim() || undefined,
password: passwordToSet, password: passwordToSet,
@@ -497,22 +470,19 @@ export function CreateProfileDialog({
// Cancel any ongoing loading // Cancel any ongoing loading
loadingBrowserRef.current = null; 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(""); setProfileName("");
setCurrentStep("browser-selection"); setCurrentStep("browser-config");
setActiveTab("anti-detect"); setActiveTab("anti-detect");
setSelectedBrowser(null); setSelectedBrowser("wayfern");
setSelectedProxyId(undefined); setSelectedProxyId(undefined);
setLaunchHook(""); setLaunchHook("");
setReleaseTypes({}); setReleaseTypes({});
setIsLoadingReleaseTypes(false); setIsLoadingReleaseTypes(false);
setReleaseTypesError(null); setReleaseTypesError(null);
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
os: getCurrentOS(), // Reset to current OS
});
setWayfernConfig({ setWayfernConfig({
os: getCurrentOS() as WayfernOS, // Reset to current OS os: getCurrentOS(), // Reset to current OS
}); });
setEphemeral(false); setEphemeral(false);
setEnablePassword(false); setEnablePassword(false);
@@ -522,10 +492,6 @@ export function CreateProfileDialog({
onClose(); onClose();
}; };
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
};
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => { const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value })); setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}; };
@@ -569,7 +535,7 @@ export function CreateProfileDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col"> <DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle> <DialogTitle>
{currentStep === "browser-selection" {currentStep === "browser-selection"
? t("createProfile.title") ? t("createProfile.title")
@@ -602,52 +568,42 @@ export function CreateProfileDialog({
onClick={() => { onClick={() => {
handleBrowserSelect("wayfern"); 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" className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline" variant="outline"
> >
<div className="flex justify-center items-center w-8 h-8"> <div className="flex justify-center items-center size-8">
{(() => { {isBrowserCurrentlyDownloading("wayfern") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent = getBrowserIcon("wayfern"); const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? ( return IconComponent ? (
<IconComponent className="w-6 h-6" /> <IconComponent className="size-6" />
) : null; ) : null;
})()} })()
)}
</div> </div>
<div className="text-left"> <div className="text-left">
<div className="font-medium"> <div className="font-medium">
{t("createProfile.chromiumLabel")} {t("createProfile.chromiumLabel")}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("createProfile.chromiumSubtitle")} {isBrowserCurrentlyDownloading("wayfern")
? t("createProfile.downloadingSubtitle")
: t("createProfile.chromiumSubtitle")}
</div> </div>
</div> </div>
</Button> </Button>
{/* Camoufox (Firefox) - Second */} {/* Camoufox is deprecated no longer offered for new
<Button profiles. Only Wayfern can be created. */}
onClick={() => {
handleBrowserSelect("camoufox"); {!getCreatableVersion("wayfern") && (
}} <p className="pt-2 text-sm text-center text-muted-foreground">
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50" {t("createProfile.browsersDownloading")}
variant="outline" </p>
> )}
<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>
</div> </div>
</TabsContent> </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" className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline" variant="outline"
> >
<div className="flex justify-center items-center w-8 h-8"> <div className="flex justify-center items-center size-8">
{IconComponent && ( {IconComponent && (
<IconComponent className="w-6 h-6" /> <IconComponent className="size-6" />
)} )}
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -729,7 +685,7 @@ export function CreateProfileDialog({
{/* Ephemeral Option */} {/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30"> <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 <Checkbox
id="ephemeral" id="ephemeral"
checked={ephemeral} checked={ephemeral}
@@ -749,7 +705,7 @@ export function CreateProfileDialog({
{/* Password Option */} {/* Password Option */}
{!ephemeral && ( {!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30"> <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 <Checkbox
id="enable-password" id="enable-password"
checked={enablePassword} checked={enablePassword}
@@ -814,7 +770,7 @@ export function CreateProfileDialog({
{/* Wayfern Download Status */} {/* Wayfern Download Status */}
{isLoadingReleaseTypes && ( {isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border"> <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"> <p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")} {t("createProfile.version.fetching")}
</p> </p>
@@ -851,7 +807,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes && {!isLoadingReleaseTypes &&
!releaseTypesError && !releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") && !isBrowserCurrentlyDownloading("wayfern") &&
!isBrowserVersionAvailable("wayfern") && !getCreatableVersion("wayfern") &&
getBestAvailableVersion("wayfern") && ( getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border"> <div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -883,15 +839,51 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes && {!isLoadingReleaseTypes &&
!releaseTypesError && !releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") && !isBrowserCurrentlyDownloading("wayfern") &&
isBrowserVersionAvailable("wayfern") && ( getCreatableVersion("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground"> <div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "} {" "}
{t("createProfile.version.available", { {t("createProfile.version.available", {
browser: "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", browser: "Wayfern",
version: version:
getBestAvailableVersion("wayfern") getBestAvailableVersion("wayfern")
?.version, ?.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> </div>
)} )}
{isBrowserCurrentlyDownloading("wayfern") && ( {isBrowserCurrentlyDownloading("wayfern") && (
@@ -911,137 +903,20 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked} crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked} limitedMode={!crossOsUnlocked}
profileVersion={ profileVersion={
getBestAvailableVersion("wayfern")?.version getCreatableVersion("wayfern")?.version
} }
profileBrowser="wayfern" profileBrowser="wayfern"
/> />
</div> </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"> <div className="space-y-4">
{selectedBrowser && ( {selectedBrowser && (
<div className="space-y-3"> <div className="space-y-3">
{isLoadingReleaseTypes && ( {isLoadingReleaseTypes && (
<div className="flex gap-3 items-center"> <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"> <p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")} {t("createProfile.version.fetching")}
</p> </p>
@@ -1061,7 +936,7 @@ export function CreateProfileDialog({
size="sm" size="sm"
variant="outline" variant="outline"
> >
Retry {t("common.buttons.retry")}
</RippleButton> </RippleButton>
</div> </div>
)} )}
@@ -1070,7 +945,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading( !isBrowserCurrentlyDownloading(
selectedBrowser, selectedBrowser,
) && ) &&
!isBrowserVersionAvailable(selectedBrowser) && !getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && ( getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -1106,18 +981,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading( !isBrowserCurrentlyDownloading(
selectedBrowser, selectedBrowser,
) && ) &&
isBrowserVersionAvailable( getCreatableVersion(selectedBrowser) && (
selectedBrowser,
) && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{" "} {" "}
{t( {t(
"createProfile.version.latestAvailable", "createProfile.version.latestAvailable",
{ {
version: version:
getBestAvailableVersion( getCreatableVersion(selectedBrowser)
selectedBrowser, ?.version,
)?.version,
}, },
)} )}
</div> </div>
@@ -1154,7 +1026,7 @@ export function CreateProfileDialog({
}} }}
className="px-2 h-7 text-xs" 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")} {t("createProfile.proxy.addProxy")}
</RippleButton> </RippleButton>
</div> </div>
@@ -1168,6 +1040,7 @@ export function CreateProfileDialog({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={proxyPopoverOpen} aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdAntiDetect}
className="w-full justify-between font-normal" className="w-full justify-between font-normal"
> >
{(() => { {(() => {
@@ -1190,10 +1063,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy") 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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
id={proxyListboxIdAntiDetect}
className="w-[240px] p-0" className="w-[240px] p-0"
sideOffset={8} sideOffset={8}
> >
@@ -1217,7 +1091,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
!selectedProxyId !selectedProxyId
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1236,7 +1110,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === proxy.id selectedProxyId === proxy.id
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1261,7 +1135,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === selectedProxyId ===
`vpn-${vpn.id}` `vpn-${vpn.id}`
? "opacity-100" ? "opacity-100"
@@ -1412,9 +1286,9 @@ export function CreateProfileDialog({
<div className="space-y-3"> <div className="space-y-3">
{isLoadingReleaseTypes && ( {isLoadingReleaseTypes && (
<div className="flex gap-3 items-center"> <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"> <p className="text-sm text-muted-foreground">
Fetching available versions... {t("createProfile.version.fetching")}
</p> </p>
</div> </div>
)} )}
@@ -1440,7 +1314,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading( !isBrowserCurrentlyDownloading(
selectedBrowser, selectedBrowser,
) && ) &&
!isBrowserVersionAvailable(selectedBrowser) && !getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && ( getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -1476,16 +1350,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading( !isBrowserCurrentlyDownloading(
selectedBrowser, selectedBrowser,
) && ) &&
isBrowserVersionAvailable(selectedBrowser) && ( getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{" "} {" "}
{t( {t(
"createProfile.version.latestAvailable", "createProfile.version.latestAvailable",
{ {
version: version:
getBestAvailableVersion( getCreatableVersion(selectedBrowser)
selectedBrowser, ?.version,
)?.version,
}, },
)} )}
</div> </div>
@@ -1520,7 +1393,7 @@ export function CreateProfileDialog({
}} }}
className="px-2 h-7 text-xs" 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")} {t("createProfile.proxy.addProxy")}
</RippleButton> </RippleButton>
</div> </div>
@@ -1534,6 +1407,7 @@ export function CreateProfileDialog({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={proxyPopoverOpen} aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdRegular}
className="w-full justify-between font-normal" className="w-full justify-between font-normal"
> >
{(() => { {(() => {
@@ -1556,10 +1430,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy") 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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
id={proxyListboxIdRegular}
className="w-[240px] p-0" className="w-[240px] p-0"
sideOffset={8} sideOffset={8}
> >
@@ -1583,7 +1458,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
!selectedProxyId !selectedProxyId
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1602,7 +1477,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === proxy.id selectedProxyId === proxy.id
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1627,7 +1502,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === selectedProxyId ===
`vpn-${vpn.id}` `vpn-${vpn.id}`
? "opacity-100" ? "opacity-100"
@@ -1681,7 +1556,7 @@ export function CreateProfileDialog({
</ScrollArea> </ScrollArea>
</Tabs> </Tabs>
<DialogFooter className="flex-shrink-0 pt-4 border-t"> <DialogFooter className="shrink-0 pt-4 border-t">
{currentStep === "browser-config" ? ( {currentStep === "browser-config" ? (
<> <>
<RippleButton variant="outline" onClick={handleBack}> <RippleButton variant="outline" onClick={handleBack}>
+30 -66
View File
@@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps {
interface DownloadToastProps extends BaseToastProps { interface DownloadToastProps extends BaseToastProps {
type: "download"; type: "download";
stage?: stage?: "downloading" | "extracting" | "verifying" | "completed";
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
progress?: { progress?: {
percentage: number; percentage: number;
speed?: string; speed?: string;
@@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps {
browserName?: string; browserName?: string;
} }
interface TwilightUpdateToastProps extends BaseToastProps {
type: "twilight-update";
browserName?: string;
hasUpdate?: boolean;
}
interface SyncProgressToastProps extends BaseToastProps { interface SyncProgressToastProps extends BaseToastProps {
type: "sync-progress"; type: "sync-progress";
progress?: { progress?: {
@@ -138,7 +127,6 @@ type ToastProps =
| DownloadToastProps | DownloadToastProps
| VersionUpdateToastProps | VersionUpdateToastProps
| FetchingToastProps | FetchingToastProps
| TwilightUpdateToastProps
| SyncProgressToastProps; | SyncProgressToastProps;
function formatBytesCompact(bytes: number): string { function formatBytesCompact(bytes: number): string {
@@ -174,42 +162,34 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) { function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) { switch (type) {
case "success": 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": case "error":
return ( return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
);
case "download": case "download":
if (stage === "completed") { if (stage === "completed") {
return ( return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
<LuCheckCheck className="flex-shrink-0 w-4 h-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": case "version-update":
return ( 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": case "fetching":
return ( 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 "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
); );
case "sync-progress": case "sync-progress":
return ( 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": case "loading":
return ( 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: default:
return ( 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 <button
type="button" type="button"
onClick={onCancel} 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")} aria-label={t("common.buttons.cancel")}
> >
<LuX className="w-3 h-3" /> <LuX className="size-3" />
</button> </button>
)} )}
</div> </div>
@@ -250,7 +230,8 @@ export function UnifiedToast(props: ToastProps) {
<p className="flex-1 min-w-0 text-xs text-muted-foreground"> <p className="flex-1 min-w-0 text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}% {progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`} {progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`} {progress.eta &&
`${t("toasts.progress.remaining", { time: progress.eta })}`}
</p> </p>
</div> </div>
<div className="w-full bg-muted rounded-full h-1.5"> <div className="w-full bg-muted rounded-full h-1.5">
@@ -268,11 +249,12 @@ export function UnifiedToast(props: ToastProps) {
"current_browser" in progress && ( "current_browser" in progress && (
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{progress.current_browser && ( {progress.current_browser &&
<>Looking for updates for {progress.current_browser}</> t("versionUpdater.toast.lookingForUpdates", {
)} browser: progress.current_browser,
})}
</p> </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="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div <div
className="bg-foreground h-1.5 rounded-full transition-all duration-150" className="bg-foreground h-1.5 rounded-full transition-all duration-150"
@@ -297,7 +279,10 @@ export function UnifiedToast(props: ToastProps) {
{progress.phase === "uploading" {progress.phase === "uploading"
? t("appUpdate.toast.uploading") ? t("appUpdate.toast.uploading")
: t("appUpdate.toast.downloading")}{" "} : t("appUpdate.toast.downloading")}{" "}
{progress.completed_files}/{progress.total_files} files {t("toasts.progress.filesProgress", {
completed: progress.completed_files,
total: progress.total_files,
})}
{" \u2022 "} {" \u2022 "}
{formatBytesCompact(progress.completed_bytes)} /{" "} {formatBytesCompact(progress.completed_bytes)} /{" "}
{formatBytesCompact(progress.total_bytes)} {formatBytesCompact(progress.total_bytes)}
@@ -308,32 +293,16 @@ export function UnifiedToast(props: ToastProps) {
</> </>
)} )}
{progress.eta_seconds > 0 && {progress.eta_seconds > 0 &&
progress.completed_files < progress.total_files && ( progress.completed_files < progress.total_files &&
<> ` \u2022 ${t("toasts.progress.remaining", {
{" \u2022 ~"} time: `~${formatEtaCompact(progress.eta_seconds)}`,
{formatEtaCompact(progress.eta_seconds)} remaining })}`}
</>
)}
</p> </p>
{progress.failed_count > 0 && ( {progress.failed_count > 0 && (
<p className="text-xs text-destructive mt-0.5"> <p className="text-xs text-destructive mt-0.5">
{progress.failed_count} file(s) failed {t("toasts.progress.filesFailed", {
</p> count: progress.failed_count,
)} })}
</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> </p>
)} )}
</div> </div>
@@ -359,11 +328,6 @@ export function UnifiedToast(props: ToastProps) {
{t("browserDownload.toast.verifying")} {t("browserDownload.toast.verifying")}
</p> </p>
)} )}
{stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-muted-foreground">
{t("browserDownload.toast.downloadingRolling")}
</p>
)}
</> </>
)} )}
{action && {action &&
+1 -1
View File
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
{...props} {...props}
> >
{isPending ? ( {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 children
)} )}

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