Compare commits

...

52 Commits

Author SHA1 Message Date
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
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
136 changed files with 9418 additions and 3733 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
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
+108
View File
@@ -0,0 +1,108 @@
name: Compliance Close
on:
schedule:
# Every 30 minutes; the actual close decision uses comment age, so the cron
# cadence only bounds how stale the closure can get past the 24-hour mark.
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-non-compliant:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 24 hours
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: items } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'needs:compliance',
state: 'open',
per_page: 100,
});
if (items.length === 0) {
core.info('No open issues/PRs with needs:compliance label');
return;
}
const now = Date.now();
const window_ms = 24 * 60 * 60 * 1000;
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
});
// Use the OLDEST compliance sentinel as the start of the 24-hour
// window so back-and-forth edits don't reset the clock.
const sentinel = comments
.filter(c => c.body && c.body.includes('<!-- issue-compliance -->'))
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0];
if (!sentinel) {
core.info(`${kind} #${item.number} has needs:compliance label but no compliance comment; skipping`);
continue;
}
const age_ms = now - new Date(sentinel.created_at).getTime();
if (age_ms < window_ms) {
const hours = (age_ms / (60 * 60 * 1000)).toFixed(1);
core.info(`${kind} #${item.number} still within 24-hour window (${hours}h elapsed)`);
continue;
}
const closeMessage = isPR
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
body: closeMessage,
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {
core.info(`Could not remove needs:compliance label from #${item.number}: ${e.message}`);
}
if (isPR) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: item.number,
state: 'closed',
});
} else {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
core.info(`Closed non-compliant ${kind} #${item.number} after 24-hour window`);
}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
+3 -3
View File
@@ -33,10 +33,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
echo "Tags: ${TAGS}"
- name: Build and push Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
with:
context: .
file: ./donut-sync/Dockerfile
+8
View File
@@ -47,3 +47,11 @@ jobs:
- name: Run flake info app
run: nix run .#info
# `nix flake show` above only evaluates the flake. This step actually
# compiles the app inside the Nix environment, which is what catches a
# missing build-time dependency — in particular libayatana-appindicator
# (required by libappindicator-sys for the Linux system tray). The build
# fails here if that dependency is dropped from the flake.
- name: Build the app via the flake
run: nix run .#build
+269
View File
@@ -0,0 +1,269 @@
name: Issue Compliance Check
on:
issues:
types: [opened, edited]
permissions:
contents: read
issues: write
env:
MODEL: z-ai/glm-5.1
jobs:
check-compliance:
if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Gather context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
- name: Build prompt
run: |
cat > /tmp/system.txt <<'PROMPT'
You are 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.
## 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 noop result so the workflow doesn't fail the issue author's run.
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
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('<!-- issue-compliance -->')
parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).")
parts.append('')
parts.append('**What needs to be fixed:**')
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
parts.append('')
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
comment = '\n'.join(parts).strip()
open('/tmp/comment.md', 'w').write(comment)
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
fh.write(f'has_comment={"true" if comment else "false"}\n')
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
EOF
id: build
- name: Post comment
if: steps.build.outputs.has_comment == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
- name: Apply needs:compliance label
if: steps.build.outputs.non_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "needs:compliance"
recheck-compliance:
# When a flagged issue is edited, re-check. If now compliant: remove label,
# delete the previous compliance comment, and thank the author. If still
# non-compliant: leave label and post an updated note.
if: >
github.repository == 'zhom/donutbrowser' &&
github.event.action == 'edited' &&
contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Gather context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
- name: Build prompt
run: |
cat > /tmp/system.txt <<'PROMPT'
You are re-checking a GitHub issue that was previously flagged as not meeting template requirements. Return ONLY a single JSON object, no prose, no markdown fences.
Project: Donut Browser. There are three valid templates:
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
- Feature Request (description + verification checkbox)
- Question (free form)
## Flag NON-compliant ONLY when at least one of these is true
- The issue body is empty or contains only placeholder text from the template
- The issue is an obvious AI-generated wall of text with no real specifics
- A bug report has no reproduction information or no error description
- A feature request gives no use case at all
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...]
}
PROMPT
- name: Call OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
PAYLOAD=$(jq -n \
--arg model "$MODEL" \
--rawfile system_prompt /tmp/system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user", content: ("Title: " + $title + "\n\nBody:\n" + $body) }
],
response_format: { type: "json_object" }
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; assuming still non-compliant"
echo '{"is_compliant": false, "non_compliance_reasons": ["unable to parse model output"]}' > /tmp/result.json
fi
- name: Resolve compliance state
id: resolve
run: |
IS_COMPLIANT=$(jq -r '.is_compliant // false' /tmp/result.json)
echo "is_compliant=$IS_COMPLIANT" >> "$GITHUB_OUTPUT"
- name: Clear compliance label and acknowledge fix
if: steps.resolve.outputs.is_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "needs:compliance" || true
# Delete the previous <!-- issue-compliance --> sentinel comment so
# the thread is clean once the author has addressed the issue.
COMMENT_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
--jq '[.[] | select(.body | contains("<!-- issue-compliance -->"))][-1].id // empty')
if [ -n "$COMMENT_ID" ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" || true
fi
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--body "Thanks for updating the issue."
- name: Build follow-up comment
if: steps.resolve.outputs.is_compliant != 'true'
run: |
python3 - <<'EOF'
import json
r = json.load(open('/tmp/result.json'))
reasons = r.get('non_compliance_reasons') or []
parts = [
'<!-- issue-compliance -->',
'This issue still does not meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).',
'',
'**What still needs to be fixed:**',
]
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
open('/tmp/comment.md', 'w').write('\n'.join(parts))
EOF
- name: Post follow-up comment
if: steps.resolve.outputs.is_compliant != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
+14 -9
View File
@@ -18,8 +18,8 @@ permissions:
env:
# Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: anthropic/claude-opus-4.7
COMPOSER_MODEL: anthropic/claude-opus-4.7
TRIAGE_MODEL: z-ai/glm-5.1
COMPOSER_MODEL: z-ai/glm-5.1
jobs:
analyze-issue:
@@ -102,12 +102,14 @@ jobs:
its API, MCP server, and the bundled `donut-sync` self-hosted server.
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
bugs are in-scope here unless they are obviously upstream Chromium issues.
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
contribute to Camoufox and CANNOT fix bugs in it.
- **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
separate repository. Bugs about Camoufox's *internal* behavior are outside
the scope of this project.
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
https://github.com/daijro/camoufox/issues.
checkbox/radio quirks) are out of scope here. Ask the user to first
search https://github.com/daijro/camoufox/issues for a matching report,
and if they don't find one, to open it there themselves.
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
in-scope here.
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
@@ -146,7 +148,10 @@ jobs:
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
which exact version was the last working one and what changed.
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
behavior. Redirect, do not collect logs.
behavior. Tell the user it's outside the scope of this project and ask
them to search the Camoufox repo and, if no matching issue exists, file
one there. Do NOT say the maintainer doesn't contribute / can't fix it
— keep it strictly about project scope. Do not collect logs.
- **Fork-support request**: asks the maintainer to support an alternative
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
"clear", "reasonable", "well-thought-out", etc.
@@ -342,7 +347,7 @@ jobs:
The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then say this is outside the scope of this project — ask the user to first search https://github.com/daijro/camoufox/issues for a matching report and, if none exists, to open one there themselves. Do NOT phrase it as "the maintainer does not contribute" or anything personal — keep it strictly about scope. Do NOT ask for Donut logs. Stop after that.
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
@@ -615,7 +620,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
+1 -5
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -88,7 +88,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --release
cargo build --bin donut-daemon --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -97,12 +96,9 @@ jobs:
HOST_TARGET="${{ steps.host_target.outputs.target }}"
if [[ "$HOST_TARGET" == *"windows"* ]]; then
cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
cp src-tauri/target/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${HOST_TARGET}.exe
else
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
cp src-tauri/target/release/donut-daemon src-tauri/binaries/donut-daemon-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-daemon-${HOST_TARGET}
fi
- name: Run rustfmt check
+45 -30
View File
@@ -22,6 +22,7 @@ on:
permissions:
contents: read
models: read
jobs:
notify:
@@ -105,21 +106,12 @@ jobs:
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Post release announcement to Telegram
- name: Collect commits between previous tag and current tag
id: commits
if: steps.gate.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# Find the previous stable tag (skip the current one) so the
# changelog range is well-defined.
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
@@ -127,29 +119,52 @@ jobs:
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${TAG}" --no-merges > commits.txt
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
version: ${{ steps.tag.outputs.tag }}
file_input: |
commits: ./commits.txt
max-tokens: 1024
# Build a plain bullet list from feat / fix / refactor commits.
# Other commit types (chore, docs, ci, test, deps) are intentionally
# filtered out to keep the channel focused on user-visible changes.
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
;;
esac
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
if [ -z "$CHANGES" ]; then
CHANGES="• See release notes."$'\n'
- name: Post release announcement to Telegram
if: steps.gate.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
AI_RESPONSE: ${{ steps.ai.outputs.response }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# HTML-escape the changelog before injecting into Telegram HTML
# mode — commit messages can legitimately contain `<`, `>`, `&`.
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
# Prefer the file output — `response` can be truncated for longer summaries.
if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
SUMMARY=$(cat "$AI_RESPONSE_FILE")
else
SUMMARY="$AI_RESPONSE"
fi
if [ -z "${SUMMARY//[[:space:]]/}" ]; then
echo "::error::AI summary is empty"
exit 1
fi
# HTML-escape the AI summary before injecting into Telegram HTML mode —
# commit messages can legitimately contain `<`, `>`, `&` and the AI may echo them.
ESCAPED_CHANGES=$(printf '%s' "$SUMMARY" \
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
VERSION="${TAG}"
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
@@ -58,7 +58,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
@@ -82,7 +82,7 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
+2 -6
View File
@@ -20,7 +20,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
@@ -108,7 +108,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -162,7 +162,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -170,12 +169,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
+2 -6
View File
@@ -19,7 +19,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -161,7 +161,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -169,12 +168,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
+91
View File
@@ -53,6 +53,18 @@ donutbrowser/
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
## Logs (when debugging a running app)
Three log surfaces, in order of usefulness:
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
## Code Quality
@@ -122,6 +134,34 @@ A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center pos
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 seven 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 seven 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
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
@@ -176,6 +216,57 @@ The `.github/workflows/publish-repos.yml` workflow runs automatically after stab
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
## Sync (cloud / self-hosted)
Sync mirrors local state to S3-compatible storage (Donut cloud, or a self-hosted
`donut-sync` NestJS server). Two distinct mechanisms live in `src-tauri/src/sync/`:
- **Profile browser files** (the Chromium/Firefox profile directory): a
**content-hash manifest** (`manifest.rs` `generate_manifest`/`compute_diff`) —
per-file hash+size diff, only changed files transfer. `sync_profile` in
`engine.rs`.
- **Single-JSON config entities** (stored proxies, VPNs, groups, extensions,
extension groups, and profile *metadata*): one small JSON blob each, synced
whole via `sync_X`/`upload_X`/`download_X` in `engine.rs`.
### Conflict resolution — one rule everywhere: `updated_at` last-write-wins
Every config entity carries `updated_at: Option<u64>` (unix seconds;
`extension_manager` uses a non-Optional `u64`). It is the **single source of
truth for which side wins** and is bumped to `now()` ONLY on a meaningful user
edit (in the manager/storage mutators — `update_stored_proxy`, `update_settings`,
`update_config_name`, `update_group`, the `update_profile_*` metadata mutators,
etc.), NEVER by sync bookkeeping. Use `crate::proxy_manager::now_secs()`.
`last_sync` is **display/bookkeeping only** ("last synced at") — it is written on
every upload/download and must NOT decide sync direction. (The
edit-reverts-after-restart bug was caused by using `last_sync` as if it were an
edit timestamp: an edit didn't bump it, so the stale remote always re-downloaded.)
Reconcile (`engine.rs::remote_updated_at` + each `sync_X`):
1. `stat` (HEAD) the remote object. Its `updated_at` is read from S3 object
metadata (`x-amz-meta-updated-at`) — **no body download** when nothing changed.
2. Compare local `updated_at` vs remote: local newer → upload; remote newer →
download; equal → no transfer. Legacy objects with no timestamp resolve to 0,
so any real edit wins.
3. **Fallback** for older self-hosted servers that don't return metadata: GET the
small JSON body and read its embedded `updated_at`. Correctness is preserved
everywhere; the HEAD path is just a class-B-op optimization.
Uploads go through `engine.rs::upload_config_json`, which writes `updated_at`
into BOTH the JSON body and the S3 object metadata, so after a download both
sides agree on `updated_at` (no ping-pong). Adding a new synced config field?
Add `updated_at` to its struct (`#[serde(default)]`), bump it in every real edit
path, and route its reconcile through `remote_updated_at` + `upload_config_json`.
### Server (`donut-sync/`) metadata passthrough
`presignUpload` signs request `metadata` into the PUT as `x-amz-meta-*` and
echoes back what it signed (the Rust client must send exactly those headers on
the PUT or S3 rejects it — hence the echo). `stat` returns `response.Metadata`.
Older servers omit `metadata` → client falls back to the body-GET path. DTOs:
`donut-sync/src/sync/dto/sync.dto.ts`; logic: `sync.service.ts`.
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+75
View File
@@ -1,6 +1,81 @@
# Changelog
## 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
+22 -8
View File
@@ -19,9 +19,6 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
</a>
</p>
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
@@ -30,6 +27,7 @@
- **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
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- **VPN support** — WireGuard configs per profile
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
@@ -48,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64.dmg) |
Or install via Homebrew:
@@ -58,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -137,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Hassiy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/webees">
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
<br />
<sub><b>JockLee</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/yb403">
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
@@ -158,12 +163,21 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Jory Severijnse</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/ThiagoMafra-Integrare">
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
<br />
<sub><b>Thiago Mafra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td>
</tr>
<tbody>
+3 -1
View File
@@ -3,7 +3,9 @@ extend-exclude = [
"src-tauri/src/camoufox/data/*.json",
"src-tauri/src/camoufox/data/*.xml",
"src/i18n/locales/*.json",
"src-tauri/build.rs",
# Auto-generated from commit subjects by release.yml; typos here originate
# in commit messages, which are immutable, so don't spell-check it.
"CHANGELOG.md",
]
[default.extend-words]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 508 KiB

+8
View File
@@ -6,17 +6,25 @@ export class StatResponseDto {
exists: boolean;
lastModified?: string;
size?: number;
// User-defined S3 object metadata (lowercased keys, no `x-amz-meta-` prefix).
// Carries `updated-at` for sync conflict resolution via HEAD (no body GET).
metadata?: Record<string, string>;
}
export class PresignUploadRequestDto {
key: string;
contentType?: string;
expiresIn?: number;
// Object metadata to sign into the presigned PUT as `x-amz-meta-*`.
metadata?: Record<string, string>;
}
export class PresignUploadResponseDto {
url: string;
expiresAt: string;
// Metadata the server actually signed; the client must echo it as
// `x-amz-meta-*` headers on the PUT (older clients/servers omit it).
metadata?: Record<string, string>;
}
export class PresignDownloadRequestDto {
+7
View File
@@ -256,6 +256,10 @@ export class SyncService implements OnModuleInit {
exists: true,
lastModified: response.LastModified?.toISOString(),
size: response.ContentLength,
// S3 returns user metadata with lowercased keys and no `x-amz-meta-`
// prefix. Clients read `updated-at` from here to resolve sync conflicts
// without downloading the object body.
metadata: response.Metadata,
};
} catch (error: unknown) {
if (
@@ -289,6 +293,9 @@ export class SyncService implements OnModuleInit {
Bucket: this.bucket,
Key: key,
ContentType: dto.contentType || "application/octet-stream",
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
// exactly these headers on the PUT, so we echo them in the response.
Metadata: dto.metadata,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
+7 -5
View File
@@ -34,6 +34,7 @@
libsoup_3
glib
gtk3
libayatana-appindicator
cairo
gdk-pixbuf
pango
@@ -84,6 +85,7 @@
pkgs.gdk-pixbuf
pkgs.glib
pkgs.gtk3
pkgs.libayatana-appindicator
pkgs.libsoup_3
pkgs.libxkbcommon
pkgs.openssl
@@ -94,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.24.1";
releaseVersion = "0.24.4";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage";
hash = "sha256-nJ4WmbXQcnXWDaneucOlwzZmlOOBx+G/qDeCHH6/Vno=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage";
hash = "sha256-YNXPed96GmuMhJVERxa2gYtiaQoMfdB0az5O5J0b/No=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage";
hash = "sha256-aLzHAdn+o9YsnKtK5BpjjrzAAbp/itsN1QdELTpHyTQ=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage";
hash = "sha256-kdEzMO53bCUH7E8GPDewnIDLRIO5pWlO8B4TdpLAQIg=";
}
else
null;
+7 -12
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.2",
"version": "0.24.4",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -37,6 +37,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.10",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -54,16 +55,19 @@
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"framer-motion": "^12.38.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -78,6 +82,7 @@
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
@@ -89,17 +94,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
},
"pnpm": {
"overrides": {
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12",
"fast-xml-parser@<5.7.0": ">=5.7.2",
"fast-uri@<3.1.2": ">=3.1.2",
"fast-xml-builder@<1.2.0": ">=1.2.0"
}
},
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@11.2.2",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+92 -26
View File
@@ -11,6 +11,8 @@ overrides:
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
importers:
@@ -31,6 +33,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-portal':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -82,6 +87,9 @@ importers:
ahooks:
specifier: ^3.9.7
version: 3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -97,6 +105,9 @@ importers:
flag-icons:
specifier: ^7.5.0
version: 7.5.0
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
i18next:
specifier: ^26.1.0
version: 26.1.0(typescript@6.0.3)
@@ -112,6 +123,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
onborda:
specifier: ^1.2.5
version: 1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -149,6 +163,9 @@ importers:
'@tauri-apps/cli':
specifier: ~2.11.1
version: 2.11.1
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/color':
specifier: ^4.2.1
version: 4.2.1
@@ -212,7 +229,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^11.0.21
version: 11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)
version: 11.0.21(@types/node@25.7.0)
'@nestjs/schematics':
specifier: ^11.1.0
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3)
@@ -248,7 +265,7 @@ importers:
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3)))(typescript@6.0.3)
ts-loader:
specifier: ^9.5.7
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0))
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
@@ -1671,6 +1688,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.10':
resolution: {integrity: sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@@ -2060,6 +2090,7 @@ packages:
'@smithy/core@3.24.1':
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
engines: {node: '>=18.0.0'}
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
'@smithy/credential-provider-imds@4.3.1':
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
@@ -2480,6 +2511,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/color-convert@2.0.4':
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
@@ -3009,6 +3043,9 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -3872,9 +3909,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4282,6 +4319,15 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
onborda@1.2.5:
resolution: {integrity: sha512-S9EtQpKr8oYz7j0Bmr0w7BdG4Q4ud6QuNxBsSShzcf9khhuLEEjkbhYYMmdMlVa56QK/rXW/9pc8JJvBXUhOeA==}
peerDependencies:
'@radix-ui/react-portal': '>=1.1.1'
framer-motion: '>=11'
next: '>=13'
react: '>=18'
react-dom: '>=18'
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -4401,8 +4447,8 @@ packages:
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
radix-ui@1.4.3:
@@ -6421,7 +6467,7 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@nestjs/cli@11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)':
'@nestjs/cli@11.0.21(@types/node@25.7.0)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
@@ -6432,14 +6478,14 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0))
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0)
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- '@minify-html/node'
@@ -6999,6 +7045,16 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -7819,6 +7875,8 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.7.0
'@types/canvas-confetti@1.9.0': {}
'@types/color-convert@2.0.4':
dependencies:
'@types/color-name': 1.1.5
@@ -8125,7 +8183,7 @@ snapshots:
'@types/js-cookie': 3.0.6
dayjs: 1.11.20
intersection-observer: 0.12.2
js-cookie: 3.0.5
js-cookie: 3.0.7
lodash: 4.18.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
@@ -8295,7 +8353,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.1
qs: 6.15.2
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -8369,6 +8427,8 @@ snapshots:
caniuse-lite@1.0.30001792: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -8733,7 +8793,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.1
qs: 6.15.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -8804,7 +8864,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0)):
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -8819,7 +8879,7 @@ snapshots:
semver: 7.8.0
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
form-data@4.0.5:
dependencies:
@@ -9382,7 +9442,7 @@ snapshots:
jiti@2.7.0: {}
js-cookie@3.0.5: {}
js-cookie@3.0.7: {}
js-tokens@4.0.0: {}
@@ -9723,6 +9783,14 @@ snapshots:
dependencies:
ee-first: 1.1.1
onborda@1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@radix-ui/react-portal': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
framer-motion: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -9834,7 +9902,7 @@ snapshots:
pure-rand@7.0.1: {}
qs@6.15.1:
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -10294,7 +10362,7 @@ snapshots:
formidable: 3.5.4
methods: 1.1.2
mime: 2.6.0
qs: 6.15.1
qs: 6.15.2
transitivePeerDependencies:
- supports-color
@@ -10330,15 +10398,13 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.11.0
terser-webpack-plugin@5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0)):
terser-webpack-plugin@5.6.0(webpack@5.106.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.47.1
webpack: 5.106.0(lightningcss@1.32.0)
optionalDependencies:
lightningcss: 1.32.0
webpack: 5.106.0
terser@5.47.1:
dependencies:
@@ -10391,7 +10457,7 @@ snapshots:
babel-jest: 30.4.1(@babel/core@7.29.0)
jest-util: 30.4.1
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0)):
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0):
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.21.3
@@ -10399,7 +10465,7 @@ snapshots:
semver: 7.8.0
source-map: 0.7.6
typescript: 6.0.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
dependencies:
@@ -10588,7 +10654,7 @@ snapshots:
webpack-sources@3.4.1: {}
webpack@5.106.0(lightningcss@1.32.0):
webpack@5.106.0:
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -10612,7 +10678,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0))
terser-webpack-plugin: 5.6.0(webpack@5.106.0)
watchpack: 2.5.1
webpack-sources: 3.4.1
transitivePeerDependencies:
+22
View File
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
- sharp
- sqlite3
- unrs-resolver
# Husky and lint-staged shell out to pnpm without a TTY, so the interactive
# "purge modules dir?" prompt errors out (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY)
# and aborts the commit. Skipping the prompt lets the hook proceed.
confirmModulesPurge: false
# Pinned for security. Moved from package.json#pnpm.overrides — pnpm 11
# no longer reads that field; settings live here now.
overrides:
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.10: '>=8.5.12'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
allowBuilds:
'@nestjs/core': true
sharp: true
unrs-resolver: true
+184 -192
View File
@@ -31,11 +31,11 @@ dependencies = [
[[package]]
name = "aes"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
dependencies = [
"cipher 0.5.1",
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.0",
]
@@ -169,7 +169,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -214,7 +214,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -445,9 +445,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "av-scenechange"
@@ -745,9 +745,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "8.0.2"
version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -756,9 +756,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -785,15 +785,15 @@ dependencies = [
[[package]]
name = "built"
version = "0.8.0"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byte-unit"
@@ -871,6 +871,15 @@ dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
@@ -962,18 +971,18 @@ dependencies = [
[[package]]
name = "cbc"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.1",
"cipher 0.5.2",
]
[[package]]
name = "cc"
version = "1.2.62"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1103,11 +1112,11 @@ dependencies = [
[[package]]
name = "cipher"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.1",
"crypto-common 0.2.2",
"inout 0.2.2",
]
@@ -1405,9 +1414,9 @@ dependencies = [
[[package]]
name = "crypto-common"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
@@ -1679,7 +1688,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.1",
"crypto-common 0.2.2",
]
[[package]]
@@ -1709,7 +1718,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1726,9 +1735,9 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
@@ -1784,9 +1793,9 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.24.2"
version = "0.24.4"
dependencies = [
"aes 0.9.0",
"aes 0.9.1",
"aes-gcm",
"argon2",
"async-socks5",
@@ -1795,7 +1804,7 @@ dependencies = [
"base64 0.22.1",
"blake3",
"boringtun",
"bzip2",
"bzip2 0.6.1",
"cbc",
"chrono",
"chrono-tz",
@@ -1824,10 +1833,10 @@ dependencies = [
"objc2-app-kit",
"once_cell",
"playwright",
"quick-xml",
"quick-xml 0.40.1",
"rand 0.10.1",
"regex-lite",
"reqwest 0.13.3",
"reqwest 0.13.4",
"resvg",
"ring",
"rusqlite",
@@ -1840,7 +1849,6 @@ dependencies = [
"smoltcp",
"sys-locale",
"sysinfo",
"tao",
"tar",
"tauri",
"tauri-build",
@@ -1858,10 +1866,9 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"tower",
"tower-http",
"tray-icon 0.24.0",
"url",
"urlencoding",
"utoipa",
@@ -1962,9 +1969,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "embed-resource"
@@ -2099,7 +2106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2213,9 +2220,9 @@ dependencies = [
[[package]]
name = "filetime"
version = "0.2.28"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
@@ -2938,9 +2945,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@@ -2998,9 +3005,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -3431,9 +3438,9 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.24"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [
"jiff-static",
"log",
@@ -3444,9 +3451,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.24"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [
"proc-macro2",
"quote",
@@ -3554,12 +3561,13 @@ dependencies = [
[[package]]
name = "kurbo"
version = "0.13.0"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"smallvec",
]
@@ -3605,6 +3613,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.186"
@@ -3648,43 +3662,24 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.16"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libxdo"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
dependencies = [
"libxdo-sys",
]
[[package]]
name = "libxdo-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
dependencies = [
"libc",
"x11",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3708,9 +3703,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
dependencies = [
"value-bag",
]
@@ -3819,9 +3814,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
@@ -3869,9 +3864,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
@@ -3914,15 +3909,14 @@ dependencies = [
[[package]]
name = "muda"
version = "0.19.1"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
dependencies = [
"crossbeam-channel",
"dpi",
"gtk",
"keyboard-types",
"libxdo",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -3931,7 +3925,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4038,9 +4032,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
@@ -4108,7 +4102,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -4382,9 +4376,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.4"
version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [
"dunce",
"is-wsl",
@@ -4394,9 +4388,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.79"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
@@ -4425,9 +4419,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.115"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
@@ -4468,7 +4462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -4660,18 +4654,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.12"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.12"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
@@ -4742,7 +4736,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -4798,6 +4792,15 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -5014,6 +5017,15 @@ name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
dependencies = [
"memchr",
"serde",
@@ -5326,9 +5338,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.13.3"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -5489,9 +5501,9 @@ dependencies = [
[[package]]
name = "rsqlite-vfs"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
@@ -5499,9 +5511,9 @@ dependencies = [
[[package]]
name = "rusqlite"
version = "0.39.0"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
@@ -5564,7 +5576,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5854,9 +5866,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -6114,9 +6126,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
@@ -6228,12 +6240,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6305,9 +6317,9 @@ dependencies = [
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
@@ -6449,9 +6461,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.39.1"
version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
"libc",
"memchr",
@@ -6498,9 +6510,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.35.2"
version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.11.1",
"block2",
@@ -6555,9 +6567,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -6572,9 +6584,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [
"anyhow",
"bytes",
@@ -6587,6 +6599,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -6600,7 +6613,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.3",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -6613,7 +6626,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon 0.23.1",
"tray-icon",
"url",
"webkit2gtk",
"webview2-com",
@@ -6623,9 +6636,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
dependencies = [
"anyhow",
"cargo_toml",
@@ -6644,9 +6657,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -6671,9 +6684,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -6685,9 +6698,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
dependencies = [
"anyhow",
"glob",
@@ -6874,9 +6887,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
dependencies = [
"cookie",
"dpi",
@@ -6899,9 +6912,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [
"gtk",
"http",
@@ -6925,9 +6938,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
dependencies = [
"anyhow",
"brotli",
@@ -6979,10 +6992,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7384,9 +7397,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.10"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.11.1",
"bytes",
@@ -7484,28 +7497,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"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",
"windows-sys 0.60.2",
]
[[package]]
@@ -7565,9 +7557,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
@@ -7577,7 +7569,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7825,9 +7817,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -8092,7 +8084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.39.4",
"quote",
]
@@ -8235,7 +8227,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -8761,7 +8753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -9064,9 +9056,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [
"async-broadcast",
"async-executor",
@@ -9099,9 +9091,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -9125,18 +9117,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
@@ -9145,9 +9137,9 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
@@ -9225,7 +9217,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes 0.8.4",
"arbitrary",
"bzip2",
"bzip2 0.5.2",
"constant_time_eq 0.3.1",
"crc32fast",
"crossbeam-utils",
@@ -9332,9 +9324,9 @@ dependencies = [
[[package]]
name = "zvariant"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
dependencies = [
"endi",
"enumflags2",
@@ -9346,9 +9338,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -9359,9 +9351,9 @@ dependencies = [
[[package]]
name = "zvariant_utils"
version = "3.3.1"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
dependencies = [
"proc-macro2",
"quote",
+8 -14
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.2"
version = "0.24.4"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -24,10 +24,6 @@ path = "src/main.rs"
name = "donut-proxy"
path = "src/bin/proxy_server.rs"
[[bin]]
name = "donut-daemon"
path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.47"
@@ -35,7 +31,7 @@ resvg = "0.47"
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "test"] }
tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
@@ -87,7 +83,7 @@ cbc = "0.2"
ring = "0.17"
sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
hyper = { version = "1.8", features = ["full"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
clap = { version = "4", features = ["derive"] }
@@ -98,22 +94,20 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
# Wayfern CDP integration
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
rusqlite = { version = "0.39", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
serde_yaml = "0.9"
toml = "0.9"
toml = "1.1"
thiserror = "2.0"
regex-lite = "0.1"
tempfile = "3"
maxminddb = "0.28"
quick-xml = { version = "0.39", features = ["serialize"] }
quick-xml = { version = "0.40", features = ["serialize"] }
# VPN support
boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.24"
tao = "0.35"
# Tray icon decoding (main-process system tray)
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
@@ -145,7 +139,7 @@ windows = { version = "0.62", features = [
[dev-dependencies]
tempfile = "3.24.0"
wiremock = "0.6"
hyper = { version = "1.8", features = ["full"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
tower = "0.5"
+5 -11
View File
@@ -5,7 +5,7 @@ fn main() {
// This allows running cargo test without building the frontend first
ensure_dist_folder_exists();
// Generate tray icon PNGs from SVG (macOS template icon format)
// Generate tray icon PNG files from SVG (macOS template icon format)
generate_tray_icons();
#[cfg(target_os = "macos")]
@@ -93,19 +93,13 @@ fn external_binaries_exist() -> bool {
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
// Check for all required external binaries (must match tauri.conf.json externalBin)
let (donut_proxy_name, donut_daemon_name) = if target.contains("windows") {
(
format!("donut-proxy-{}.exe", target),
format!("donut-daemon-{}.exe", target),
)
let donut_proxy_name = if target.contains("windows") {
format!("donut-proxy-{}.exe", target)
} else {
(
format!("donut-proxy-{}", target),
format!("donut-daemon-{}", target),
)
format!("donut-proxy-{}", target)
};
binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&donut_daemon_name).exists()
binaries_dir.join(&donut_proxy_name).exists()
}
fn ensure_dist_folder_exists() {
+11
View File
@@ -21,6 +21,17 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"opener:default",
{
"identifier": "opener:allow-open-url",
"allow": [
{
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
},
{
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera"
}
]
},
"fs:default",
"shell:allow-execute",
"shell:allow-kill",
-1
View File
@@ -77,4 +77,3 @@ function copyBinary(baseName) {
}
copyBinary("donut-proxy");
copyBinary("donut-daemon");
-3
View File
@@ -102,6 +102,3 @@ copy_binary() {
# Copy donut-proxy binary
copy_binary "donut-proxy"
# Copy donut-daemon binary
copy_binary "donut-daemon"
+152 -22
View File
@@ -1,6 +1,5 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::daemon_ws::{ws_handler, WsState};
use crate::events;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
@@ -87,6 +86,8 @@ pub struct UpdateProfileRequest {
pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
}
#[derive(Clone)]
@@ -215,6 +216,20 @@ struct OpenUrlRequest {
url: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct ImportCookiesRequest {
/// Raw cookie file content. Format is auto-detected: a JSON array
/// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
content: String,
}
#[derive(Debug, Serialize, ToSchema)]
struct ImportCookiesResponse {
cookies_imported: usize,
cookies_replaced: usize,
errors: Vec<String>,
}
#[derive(OpenApi)]
#[openapi(
paths(
@@ -226,6 +241,7 @@ struct OpenUrlRequest {
run_profile,
open_url_in_profile,
kill_profile,
import_profile_cookies,
get_groups,
get_group,
create_group,
@@ -268,6 +284,8 @@ struct OpenUrlRequest {
RunProfileResponse,
RunProfileRequest,
OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
ProxySettings,
)),
tags(
@@ -277,6 +295,7 @@ struct OpenUrlRequest {
(name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"),
(name = "browsers", description = "Browser management endpoints"),
(name = "cookies", description = "Cookie management endpoints"),
),
modifiers(&SecurityAddon),
)]
@@ -363,6 +382,7 @@ impl ApiServer {
.routes(routes!(run_profile))
.routes(routes!(open_url_in_profile))
.routes(routes!(kill_profile))
.routes(routes!(import_profile_cookies))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_tags))
@@ -391,16 +411,14 @@ impl ApiServer {
))
.layer(middleware::from_fn(terms_check_middleware));
// Create WebSocket route with its own state (no auth required for daemon IPC)
let ws_state = WsState::new();
let ws_routes = Router::new()
.route("/events", get(ws_handler))
.with_state(ws_state);
let api_for_v1 = api.clone();
let app = Router::new()
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.route(
"/v1/openapi.json",
get(move || async move { Json(api_for_v1) }),
)
// Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers.
@@ -568,6 +586,24 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
Ok(server_guard.get_port())
}
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response,
/// dropping the `fingerprint` field unless the user has an active paid plan.
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated
/// API/MCP callers) must never receive it. `is_paid` is resolved once per
/// handler via `has_active_paid_subscription()`.
fn config_to_api_value<T: serde::Serialize>(
config: Option<&T>,
is_paid: bool,
) -> Option<serde_json::Value> {
let mut value = serde_json::to_value(config?).ok()?;
if !is_paid {
if let Some(obj) = value.as_object_mut() {
obj.remove("fingerprint");
}
}
Some(value)
}
// API Handlers - Profiles
#[utoipa::path(
get,
@@ -584,6 +620,9 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
)]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
let api_profiles: Vec<ApiProfile> = profiles
@@ -598,10 +637,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -641,6 +677,9 @@ async fn get_profile(
State(_state): State<ApiServerState>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
@@ -655,10 +694,7 @@ async fn get_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -694,6 +730,9 @@ async fn create_profile(
Json(request): Json<CreateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
// Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config {
@@ -709,6 +748,18 @@ async fn create_profile(
None
};
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
// (expired proxy subscription) maps to 402; anything else is a 400.
if let Err(err) =
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
{
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
StatusCode::PAYMENT_REQUIRED
} else {
StatusCode::BAD_REQUEST
});
}
// Create profile using the async create_profile_with_group method
match profile_manager
.create_profile_with_group(
@@ -758,10 +809,7 @@ async fn create_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type,
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
@@ -929,6 +977,15 @@ async fn update_profile(
}
}
if let Some(sync_mode) = request.sync_mode {
if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode)
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
// Return updated profile
get_profile(Path(id), State(state)).await
}
@@ -1715,13 +1772,15 @@ async fn run_profile(
port
};
// Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging(
// Use the same launch path as the main app, but force a fresh instance with
// remote debugging enabled so the returned port is the one the browser binds.
match crate::browser_runner::launch_browser_profile_impl(
state.app_handle.clone(),
profile.clone(),
url,
Some(remote_debugging_port),
headless,
true,
)
.await
{
@@ -1818,6 +1877,77 @@ async fn kill_profile(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/cookies/import",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = ImportCookiesRequest,
responses(
(status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
(status = 400, description = "Invalid cookie file or unsupported browser"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Browser is currently running"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn import_profile_cookies(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<ImportCookiesRequest>,
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !profiles.iter().any(|p| p.id.to_string() == id) {
return Err(StatusCode::NOT_FOUND);
}
match crate::cookie_manager::CookieManager::import_cookies(
&state.app_handle,
&id,
&request.content,
)
.await
{
Ok(result) => {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
if profile.is_sync_enabled() {
let pid = id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
Ok(Json(ImportCookiesResponse {
cookies_imported: result.cookies_imported,
cookies_replaced: result.cookies_replaced,
errors: result.errors,
}))
}
Err(e) => {
let msg = e.to_lowercase();
if msg.contains("running") {
Err(StatusCode::CONFLICT)
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
Err(StatusCode::BAD_REQUEST)
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
// API Handler - Download Browser
#[utoipa::path(
post,
+28
View File
@@ -26,6 +26,23 @@ pub fn is_portable() -> bool {
portable_dir().is_some()
}
/// Optional single-root override for all on-disk state. Set
/// `DONUTBROWSER_DATA_ROOT=/path` (e.g. a tmpfs mount) to relocate
/// data/cache/logs under `<root>/{data,cache,logs}` without touching the real
/// dev/prod directories. The more specific `DONUTBROWSER_DATA_DIR` /
/// `DONUTBROWSER_CACHE_DIR` overrides still take precedence over this.
fn data_root() -> Option<PathBuf> {
std::env::var_os("DONUTBROWSER_DATA_ROOT")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
/// otherwise, in which case the platform default app log dir is used.
pub fn log_dir_override() -> Option<PathBuf> {
data_root().map(|root| root.join("logs"))
}
pub fn app_name() -> &'static str {
if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -46,6 +63,10 @@ pub fn data_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("data");
}
if let Some(dir) = portable_dir() {
return dir.join("data");
}
@@ -65,6 +86,10 @@ pub fn cache_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("cache");
}
if let Some(dir) = portable_dir() {
return dir.join("cache");
}
@@ -112,6 +137,9 @@ pub fn dns_blocklist_dir() -> PathBuf {
/// `LogDir` target used in the plugin builder so the path matches what's
/// actually on disk for this OS.
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
if let Some(dir) = log_dir_override() {
return dir;
}
use tauri::Manager;
handle
.path()
+1
View File
@@ -703,6 +703,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
-498
View File
@@ -1,498 +0,0 @@
// Donut Browser Daemon - Background process for tray icon and services
// This runs independently of the main Tauri GUI
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
use tray_icon::menu::MenuEvent;
use tray_icon::TrayIcon;
#[cfg(not(target_os = "macos"))]
use tray_icon::{MouseButton, TrayIconEvent};
use donutbrowser_lib::daemon::{autostart, services, tray};
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
enum ServiceStatus {
Ready {
api_port: Option<u16>,
mcp_running: bool,
},
Failed(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
api_port: Option<u16>,
mcp_running: bool,
version: String,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn ensure_data_dir() -> std::io::Result<()> {
if let Some(data_dir) = autostart::get_data_dir() {
fs::create_dir_all(&data_dir)?;
}
Ok(())
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
fn write_state(state: &DaemonState) -> std::io::Result<()> {
let path = get_state_path();
let content = serde_json::to_string_pretty(state)?;
fs::write(path, content)
}
fn set_high_priority() {
#[cfg(unix)]
{
// Set high priority so the daemon is killed last under resource pressure
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
unsafe {
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
}
}
#[cfg(windows)]
{
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
};
// Set high priority so the daemon is killed last under resource pressure
unsafe {
let handle = GetCurrentProcess();
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
// GetCurrentProcess returns a pseudo-handle that doesn't need to be closed,
// but we do it anyway for consistency
let _ = CloseHandle(handle);
}
}
}
fn run_daemon() {
// Set high priority so the daemon is less likely to be killed under resource pressure
set_high_priority();
// Initialize logging to file for debugging (since stdout/stderr may be redirected)
let log_path = autostart::get_data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("daemon.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path);
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.target(if let Ok(file) = log_file {
env_logger::Target::Pipe(Box::new(file))
} else {
env_logger::Target::Stderr
})
.init();
if let Err(e) = ensure_data_dir() {
eprintln!("Failed to create data directory: {}", e);
process::exit(1);
}
log::info!("[daemon] Starting with PID {}", process::id());
// Create tokio runtime for async operations
let rt = Runtime::new().expect("Failed to create tokio runtime");
// Create channel for service status updates
let (tx, rx) = mpsc::channel::<ServiceStatus>();
// Spawn services in a background thread so we don't block the event loop
let rt_handle = rt.handle().clone();
std::thread::spawn(move || {
let result = rt_handle.block_on(async { services::DaemonServices::start().await });
let status = match result {
Ok(s) => ServiceStatus::Ready {
api_port: s.api_port,
mcp_running: s.mcp_running,
},
Err(e) => ServiceStatus::Failed(e),
};
let _ = tx.send(status);
});
// Write initial state (services still starting)
let state = DaemonState {
daemon_pid: Some(process::id()),
api_port: None,
mcp_running: false,
version: env!("CARGO_PKG_VERSION").to_string(),
};
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
// Prepare tray menu and icon (but don't create the tray icon yet)
let tray_menu = tray::TrayMenu::new();
let icon = tray::load_icon();
let menu_channel = MenuEvent::receiver();
// Create the event loop IMMEDIATELY (critical for macOS tray icon)
let event_loop = EventLoopBuilder::new().build();
// Store tray icon in Option - created after event loop starts
let mut tray_icon: Option<TrayIcon> = None;
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
#[cfg(unix)]
unsafe {
extern "C" fn signal_handler(_sig: libc::c_int) {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
}
libc::signal(
libc::SIGTERM,
signal_handler as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGINT,
signal_handler as *const () as libc::sighandler_t,
);
}
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
1 // TRUE
}
unsafe {
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
}
}
// Run the event loop
event_loop.run(move |event, _, control_flow| {
// Use WaitUntil to check for menu events periodically while staying low on CPU
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100));
match event {
Event::NewEvents(StartCause::Init) => {
// Hide from dock on macOS (must be done after event loop starts)
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
// Create tray icon after event loop has started (required for macOS)
tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu));
log::info!("[daemon] Tray icon created");
}
Event::MainEventsCleared => {
// Check for service status updates from background thread
if let Ok(status) = rx.try_recv() {
match status {
ServiceStatus::Ready {
api_port,
mcp_running,
} => {
log::info!("[daemon] Services started successfully");
// Update state file
let mut state = read_state();
state.api_port = api_port;
state.mcp_running = mcp_running;
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
}
ServiceStatus::Failed(e) => {
log::error!("Failed to start services: {}", e);
}
}
}
// Process menu events
while let Ok(event) = menu_channel.try_recv() {
if event.id == tray_menu.quit_item.id() {
log::info!("[daemon] Quit requested");
SHOULD_QUIT.store(true, Ordering::SeqCst);
}
}
// Handle tray icon click (left-click opens the app)
// On macOS, left-click already shows the menu, so don't also launch the GUI.
#[cfg(not(target_os = "macos"))]
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
if let TrayIconEvent::Click {
button: MouseButton::Left,
..
} = event
{
tray::open_gui();
}
}
// Use swap to only run cleanup once
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
// Remove tray icon from status bar immediately so the UI feels responsive
tray_icon = None;
tray::quit_gui();
let mut state = read_state();
state.daemon_pid = None;
let _ = write_state(&state);
log::info!("[daemon] Exiting");
// Use process::exit for immediate termination instead of ControlFlow::Exit.
// ControlFlow::Exit can delay because tao's macOS event loop defers exit,
// and dropping the tokio runtime blocks until all spawned tasks finish.
process::exit(0);
}
}
Event::Reopen { .. } => {
tray::open_gui();
// Re-hide daemon from Dock. macOS activates the daemon (making it
// visible) when the user clicks the Dock icon, overriding the
// Accessory policy set at init.
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
}
_ => {}
}
// Keep tray_icon alive
let _ = &tray_icon;
// Keep runtime alive
let _ = &rt;
});
}
fn stop_daemon() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let state_path = get_state_path();
if let Ok(content) = fs::read_to_string(&state_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
let _ = Command::new("taskkill")
.args(["/PID", &gui_pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
}
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
} else {
eprintln!("Daemon is not running");
}
}
fn show_status() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
#[cfg(windows)]
let is_running = win_process_exists(pid);
#[cfg(not(any(unix, windows)))]
let is_running = false;
if is_running {
eprintln!("Daemon is running (PID {})", pid);
if let Some(port) = state.api_port {
eprintln!(" API: Running on port {}", port);
} else {
eprintln!(" API: Stopped");
}
eprintln!(
" MCP: {}",
if state.mcp_running {
"Running"
} else {
"Stopped"
}
);
} else {
eprintln!("Daemon is not running (stale PID in state file)");
}
} else {
eprintln!("Daemon is not running");
}
}
fn print_usage() {
eprintln!("Donut Browser Daemon");
eprintln!();
eprintln!("Usage: donut-daemon <command>");
eprintln!();
eprintln!("Commands:");
eprintln!(" start Start the daemon (detaches from terminal)");
eprintln!(" stop Stop the running daemon");
eprintln!(" status Show daemon status");
eprintln!(" run Run in foreground (for debugging)");
eprintln!(" autostart Manage autostart settings");
eprintln!(" enable Enable autostart on login");
eprintln!(" disable Disable autostart on login");
eprintln!(" status Show autostart status");
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_usage();
process::exit(1);
}
match args[1].as_str() {
"start" => {
run_daemon();
}
"stop" => {
stop_daemon();
}
"status" => {
show_status();
}
"run" => {
run_daemon();
}
"autostart" => {
if args.len() < 3 {
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
process::exit(1);
}
match args[2].as_str() {
"enable" => {
if let Err(e) = autostart::enable_autostart() {
eprintln!("Failed to enable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart enabled");
}
"disable" => {
if let Err(e) = autostart::disable_autostart() {
eprintln!("Failed to disable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart disabled");
}
"status" => {
if autostart::is_autostart_enabled() {
eprintln!("Autostart is enabled");
} else {
eprintln!("Autostart is disabled");
}
}
_ => {
eprintln!("Unknown autostart command: {}", args[2]);
process::exit(1);
}
}
}
_ => {
print_usage();
process::exit(1);
}
}
}
+1
View File
@@ -1220,6 +1220,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+68 -276
View File
@@ -7,78 +7,11 @@ use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System;
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
/// low-traffic window for the average user; everyone shares the same UTC
/// instant so the value here doesn't track any one user's local schedule.
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
/// File name of the per-profile marker recording the last fingerprint
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
/// and is excluded from cloud sync (see `sync::manifest`) so each device
/// runs its own refresh schedule.
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
/// Most recent rollover instant on or before `now` — used as a staleness
/// threshold for Wayfern fingerprints. Anything generated before this
/// timestamp is considered stale and gets regenerated on next launch.
fn most_recent_rollover_epoch() -> u64 {
let now = Utc::now();
let today_threshold = Utc
.with_ymd_and_hms(
now.year(),
now.month(),
now.day(),
FINGERPRINT_ROLLOVER_HOUR_UTC,
0,
0,
)
.single()
.unwrap_or(now);
let threshold = if now >= today_threshold {
today_threshold
} else {
today_threshold - chrono::Duration::days(1)
};
threshold.timestamp().max(0) as u64
}
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
}
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
/// Returns `None` if the file doesn't exist or its content can't be parsed —
/// both signal "needs a refresh" to the caller.
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
let path = last_fp_refresh_path(profile_id, profiles_dir);
let content = std::fs::read_to_string(&path).ok()?;
content.trim().parse::<u64>().ok()
}
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
/// this profile. Failure is logged but never propagated — a missing marker
/// only costs an extra regen on the next launch, never blocks one.
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
let path = last_fp_refresh_path(profile_id, profiles_dir);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
return;
}
}
}
if let Err(e) = std::fs::write(&path, ts.to_string()) {
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
}
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -448,6 +381,7 @@ impl BrowserRunner {
camoufox_config,
url,
override_profile_path,
remote_debugging_port,
headless,
)
.await
@@ -612,32 +546,12 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Decide whether to (re)generate the Wayfern fingerprint for this
// launch. Two triggers:
//
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
// randomization the user opted into.
// 2. The fingerprint hasn't been refreshed since the most recent
// rollover instant. We check the per-profile marker file first
// (`.last-fp-refresh`); if it's absent we fall back to
// `profile.created_at` so brand-new profiles don't immediately
// regenerate the fingerprint they were just created with.
// Profiles with neither (truly legacy) are treated as ancient
// and refresh on next launch — once.
// Check if we need to generate a new fingerprint on every launch
let mut updated_profile = profile.clone();
let stale_threshold = most_recent_rollover_epoch();
let profile_id_str = profile.id.to_string();
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
let effective_last_refresh =
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
if randomize_every_launch || is_stale_profile {
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
log::info!(
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
profile.name,
randomize_every_launch,
is_stale_profile
"Generating random fingerprint for Wayfern profile: {}",
profile.name
);
// Create a config copy without the existing fingerprint to force generation of a new one
@@ -659,24 +573,12 @@ impl BrowserRunner {
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
// Write the marker so the next launch within the same rollover
// window skips this branch. The marker is excluded from cloud
// sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each
// device's refresh schedule is independent.
let now_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(stale_threshold);
write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch);
// Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint);
// Preserve the user's randomize-on-launch preference rather than
// forcing it on. The rollover path must not silently flip this
// flag for users who only opted into the scheduled refresh.
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
// Preserve the randomize flag so it persists across launches
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
// Preserve the OS setting so it's used for future fingerprint generation
if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone();
}
@@ -754,6 +656,24 @@ impl BrowserRunner {
let process_id = wayfern_result.processId.unwrap_or(0);
log::info!("Wayfern launched successfully with PID: {process_id}");
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
// stored fingerprint targets an older browser version). Persist it so the
// next launch starts from the upgraded value — saved below via
// save_process_info(&updated_profile).
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) {
log::info!(
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
profile.name,
used_fp.len()
);
cfg.fingerprint = Some(used_fp);
updated_profile.wayfern_config = Some(cfg);
}
}
// Update profile with the process info
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
@@ -935,57 +855,19 @@ impl BrowserRunner {
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches
let upstream_proxy = self
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Start local proxy - if this fails, DO NOT launch browser
let blocklist_file = Self::resolve_blocklist_file(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let internal_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
.map_err(|e| {
let error_msg = format!("Failed to start local proxy: {e}");
log::error!("{}", error_msg);
error_msg
})?;
let internal_proxy_settings = Some(internal_proxy.clone());
let result = self
// Camoufox and Wayfern start (and PID-reconcile) their own local proxy
// inside `launch_browser_internal`, so we hand it None here rather than
// staging a second, orphaned proxy worker.
self
.launch_browser_internal(
app_handle.clone(),
app_handle,
profile,
url,
internal_proxy_settings.as_ref(),
None,
remote_debugging_port,
headless,
)
.await;
// Update proxy with correct PID if launch succeeded
if let Ok(ref updated_profile) = result {
if let Some(actual_pid) = updated_profile.process_id {
let _ = PROXY_MANAGER.update_proxy_pid(temp_pid, actual_pid);
}
}
result
.await
}
pub async fn launch_or_open_url(
@@ -2395,6 +2277,17 @@ pub async fn launch_browser_profile(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
) -> Result<BrowserProfile, String> {
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
}
pub async fn launch_browser_profile_impl(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
force_new: bool,
) -> Result<BrowserProfile, String> {
log::info!(
"Launch request received for profile: {} (ID: {})",
@@ -2424,9 +2317,6 @@ pub async fn launch_browser_profile(
let browser_runner = BrowserRunner::instance();
// Store the internal proxy settings for passing to launch_browser
let mut internal_proxy_settings: Option<ProxySettings> = None;
// Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state
let profile_for_launch = match browser_runner
.profile_manager
@@ -2448,112 +2338,36 @@ pub async fn launch_browser_profile(
profile_for_launch.id
);
// Always start a local proxy before launching (non-Camoufox/Wayfern handled here; they have their own flow)
// This ensures all traffic goes through the local proxy for monitoring and future features
if profile.browser != "camoufox" && profile.browser != "wayfern" {
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
// Refresh cloud proxy credentials and inject profile-specific sid
let mut upstream_proxy = BrowserRunner::instance()
.resolve_launch_proxy(&profile_for_launch)
.await?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile_for_launch.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
});
log::info!("VPN worker started for profile on port {}", port);
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}"));
}
}
}
}
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Always start a local proxy, even if there's no upstream proxy
// This allows for traffic monitoring and future features
let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?;
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile_for_launch.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
{
Ok(internal_proxy) => {
// Use internal proxy for subsequent launch
internal_proxy_settings = Some(internal_proxy.clone());
// For Firefox-based browsers, always apply PAC/user.js to point to the local proxy
if matches!(
profile_for_launch.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = browser_runner.profile_manager.get_profiles_dir();
let profile_path = profiles_dir
.join(profile_for_launch.id.to_string())
.join("profile");
// Provide a dummy upstream (ignored when internal proxy is provided)
let dummy_upstream = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: internal_proxy.port,
username: None,
password: None,
};
browser_runner
.profile_manager
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
log::info!(
"Local proxy prepared for profile: {} on port: {} (upstream: {})",
profile_for_launch.name,
internal_proxy.port,
upstream_proxy
.as_ref()
.map(|p| format!("{}:{}", p.host, p.port))
.unwrap_or_else(|| "DIRECT".to_string())
);
}
Err(e) => {
let error_msg = format!("Failed to start local proxy: {e}");
log::error!("{}", error_msg);
// DO NOT launch browser if proxy startup fails - all browsers must use local proxy
return Err(error_msg);
}
}
}
log::info!(
"Starting browser launch for profile: {} (ID: {})",
profile_for_launch.name,
profile_for_launch.id
);
// Launch browser or open URL in existing instance
let updated_profile = browser_runner.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()).await.map_err(|e| {
// Launch browser or open URL in existing instance. Camoufox and Wayfern
// start their own local proxies inside `launch_browser_internal`; any
// other browser type is rejected there (we only support those for import,
// not launch), so no proxy needs to be staged here.
//
// `force_new` callers (API/MCP) always start a fresh instance with the
// requested debug port and headless mode, bypassing the "open URL in the
// existing window" path which would otherwise ignore both.
let launch_result = if force_new {
browser_runner
.launch_browser_with_debugging(
app_handle.clone(),
&profile_for_launch,
url,
remote_debugging_port,
headless,
)
.await
} else {
browser_runner
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
.await
};
let updated_profile = launch_result.map_err(|e| {
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
// Emit a failure event to clear loading states in the frontend
@@ -2710,28 +2524,6 @@ pub async fn kill_browser_profile(
}
}
pub async fn launch_browser_profile_with_debugging(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
let browser_runner = BrowserRunner::instance();
browser_runner
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
.await
.map_err(|e| format!("Failed to launch browser with debugging: {e}"))
}
#[tauri::command]
pub async fn open_url_with_profile(
app_handle: tauri::AppHandle,
+6 -5
View File
@@ -376,11 +376,12 @@ impl CamoufoxConfigBuilder {
(config, target_os)
};
// Add random window history length
config.insert(
"window.history.length".to_string(),
serde_json::json!(rng.random_range(1..=5)),
);
// Note: we used to spoof `window.history.length` to a random value in
// [1, 5] here. Newer Camoufox builds clamp the docShell session history
// to this value, which disables the toolbar back/forward buttons when
// the spoof rolls a small number. The fingerprint value drifts on every
// user navigation anyway, so a constant spoof is detectable and not
// worth the broken navigation UX.
// Add fonts
if !self.custom_fonts_only {
+152 -46
View File
@@ -200,6 +200,7 @@ impl CamoufoxManager {
}
/// Launch Camoufox browser by directly spawning the process
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox(
&self,
_app_handle: &AppHandle,
@@ -207,6 +208,7 @@ impl CamoufoxManager {
profile_path: &str,
config: &CamoufoxConfig,
url: Option<&str>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
@@ -222,10 +224,16 @@ impl CamoufoxManager {
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Parse the fingerprint config JSON
let fingerprint_config: HashMap<String, serde_json::Value> =
let mut fingerprint_config: HashMap<String, serde_json::Value> =
serde_json::from_str(&custom_config)
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
// Strip `window.history.length` even when present in a previously-saved
// fingerprint. Newer Camoufox clamps the docShell session history to the
// spoofed value, which disables the toolbar back/forward buttons. See
// the matching note in camoufox/config.rs.
fingerprint_config.remove("window.history.length");
// Convert to environment variables using CAMOU_CONFIG chunking
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
@@ -243,7 +251,10 @@ impl CamoufoxManager {
.to_string(),
];
let cdp_port = Self::find_free_port().await?;
let cdp_port = match remote_debugging_port {
Some(p) => p,
None => Self::find_free_port().await?,
};
args.push(format!("--remote-debugging-port={cdp_port}"));
// Add URL if provided
@@ -264,13 +275,33 @@ impl CamoufoxManager {
args
);
// Spawn the browser process
// Spawn the browser process. Camoufox prints NSS/PSM and proxy failures
// to stderr (e.g. cert errors, CONNECT failures) and the user otherwise
// sees only an opaque "Secure Connection Failed" page — capture stderr
// to a per-launch file so diagnostics survive without a TTY.
let stderr_log_path = std::env::temp_dir().join(format!("camoufox-stderr-{}.log", profile.id));
let mut command = TokioCommand::new(&executable_path);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stdout(Stdio::null());
match std::fs::File::create(&stderr_log_path) {
Ok(file) => {
log::info!(
"Camoufox stderr will be logged to: {}",
stderr_log_path.display()
);
command.stderr(Stdio::from(file));
}
Err(e) => {
log::warn!(
"Failed to open Camoufox stderr log {}: {e}",
stderr_log_path.display()
);
command.stderr(Stdio::null());
}
}
// Add environment variables
for (key, value) in &env_vars {
@@ -287,7 +318,7 @@ impl CamoufoxManager {
}
}
let child = command
let mut child = command
.spawn()
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
@@ -296,6 +327,34 @@ impl CamoufoxManager {
log::info!("Camoufox launched with PID: {:?}", process_id);
// Watch the child so its exit status (signal / non-zero code) lands in
// the log. Without this, all we see is "PID X is no longer running" via
// the periodic sysinfo poll, with no clue why it died.
let watch_profile_path = profile_path.to_string();
tokio::spawn(async move {
match child.wait().await {
Ok(status) => {
if status.success() {
log::info!(
"Camoufox PID {:?} for {} exited cleanly (status=0)",
process_id,
watch_profile_path
);
} else {
log::warn!(
"Camoufox PID {:?} for {} exited abnormally: {}",
process_id,
watch_profile_path,
status
);
}
}
Err(e) => {
log::warn!("Failed to await Camoufox PID {:?} exit: {}", process_id, e);
}
}
});
// Store the instance
let instance = CamoufoxInstance {
id: instance_id.clone(),
@@ -557,28 +616,28 @@ impl CamoufoxManager {
for (id, instance) in inner.instances.iter() {
if let Some(process_id) = instance.process_id {
// Check if the process is still alive
if !self.is_server_running(process_id).await {
// Process is dead
// Camoufox instance is no longer running
log::info!(
"Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
id,
process_id,
instance.profile_path
);
dead_instances.push(id.clone());
instances_to_remove.push(id.clone());
}
} else {
// No process_id means it's likely a dead instance
// Camoufox instance has no PID, marking as dead
log::info!("Camoufox instance {} has no PID, marking as dead", id);
dead_instances.push(id.clone());
instances_to_remove.push(id.clone());
}
}
}
// Remove dead instances
if !instances_to_remove.is_empty() {
let mut inner = self.inner.lock().await;
for id in &instances_to_remove {
inner.instances.remove(id);
// Removed dead Camoufox instance
}
}
@@ -612,6 +671,7 @@ impl CamoufoxManager {
}
impl CamoufoxManager {
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox_profile(
&self,
app_handle: AppHandle,
@@ -619,6 +679,7 @@ impl CamoufoxManager {
config: CamoufoxConfig,
url: Option<String>,
override_profile_path: Option<std::path::PathBuf>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<CamoufoxLaunchResult, String> {
// Get profile path
@@ -662,54 +723,98 @@ impl CamoufoxManager {
}
}
// Write explicit proxy prefs to user.js so Firefox always uses the local
// donut-proxy and never falls back to stale proxy settings baked into prefs.js
// from a previous session. user.js values override prefs.js on every launch.
if let Some(proxy_str) = &config.proxy {
// Patch user.js with Camoufox-specific overrides on every launch. This
// always runs (not gated on the proxy being set) because Camoufox's
// bundled camoufox.cfg ships defaults that break basic browser features
// and we need to override them per-profile.
{
let user_js_path = profile_path.join("user.js");
let mut prefs = String::new();
// Preserve existing user.js content (ephemeral prefs, etc.)
// Preserve existing user.js lines, but strip any keys we're about to
// re-emit so they never duplicate.
let managed_keys = [
"network.proxy.",
"network.http.http3.enable",
"network.http.http3.enabled",
"xpinstall.signatures.required",
"extensions.startupScanScopes",
"browser.sessionhistory.max_entries",
"browser.sessionhistory.max_total_viewers",
];
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
// Strip old proxy prefs so we don't duplicate
for line in existing.lines() {
if !line.contains("network.proxy.") {
if !managed_keys.iter().any(|k| line.contains(k)) {
prefs.push_str(line);
prefs.push('\n');
}
}
}
if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
// Camoufox's bundled camoufox.cfg sets these to 0, which makes
// docShell remember zero prior pages and leaves the toolbar
// back/forward buttons permanently disabled no matter how much
// the user navigates. Restore Firefox defaults.
prefs.push_str(
"user_pref(\"browser.sessionhistory.max_entries\", 50);\n\
user_pref(\"browser.sessionhistory.max_total_viewers\", -1);\n",
);
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
// Required for sideloaded extensions:
// - signatures.required=false accepts unsigned .xpi (Camoufox is built
// without MOZ_REQUIRE_SIGNING so this is honored).
// - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly
// dropped .xpi files in <profile>/extensions/ get registered.
prefs.push_str(
"user_pref(\"xpinstall.signatures.required\", false);\n\
user_pref(\"extensions.startupScanScopes\", 1);\n",
);
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write proxy prefs to user.js: {e}");
// Disable HTTP/3 / QUIC. Camoufox always sits behind the local
// donut-proxy, and Firefox-150's QUIC stack bypasses configured HTTP
// proxies and goes direct UDP to the remote host. With an upstream
// proxy that's the only allowed egress, that traffic silently fails
// and pages won't load. (Chromium suppresses QUIC under a proxy on
// its own, so Wayfern doesn't need the equivalent toggle.) Both
// pref names are emitted because they've been renamed across FF
// versions and either could be the active one at runtime.
prefs.push_str(
"user_pref(\"network.http.http3.enable\", false);\n\
user_pref(\"network.http.http3.enabled\", false);\n",
);
if let Some(proxy_str) = &config.proxy {
if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
}
}
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write user.js: {e}");
}
}
self
@@ -719,6 +824,7 @@ impl CamoufoxManager {
&profile_path_str,
&config,
url.as_deref(),
remote_debugging_port,
headless,
)
.await
+22 -1
View File
@@ -46,6 +46,16 @@ pub struct CloudUser {
pub team_name: Option<String>,
#[serde(rename = "teamRole", default)]
pub team_role: Option<String>,
// This desktop session's position among the user's active devices, oldest
// first. Ordinal 1 is the primary device — the only one that can run browser
// automation. `default` keeps older login/state payloads (which lack these
// fields) deserializing cleanly.
#[serde(rename = "deviceOrdinal", default)]
pub device_ordinal: Option<i64>,
#[serde(rename = "deviceCount", default)]
pub device_count: Option<i64>,
#[serde(rename = "isPrimaryDevice", default)]
pub is_primary_device: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -413,7 +423,18 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Login failed ({status}): {body}"));
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
// limit or a temporary security block). Surface the human-readable
// message rather than the raw JSON so the sign-in screen is clear.
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("message")
.and_then(|m| m.as_str())
.map(std::string::ToString::to_string)
})
.unwrap_or_else(|| format!("Login failed ({status})"));
return Err(message);
}
let result: DeviceCodeExchangeResponse = response
-351
View File
@@ -1,351 +0,0 @@
use directories::ProjectDirs;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
// Get log directory (use data directory instead of /tmp)
let log_dir = get_data_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("logs");
fs::create_dir_all(&log_dir)?;
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{daemon_path}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>{log_dir}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path = daemon_path.display(),
log_dir = log_dir.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn get_plist_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
if plist_path.exists() {
// First unload the launch agent if it's loaded
let _ = unload_launch_agent();
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
get_plist_path().is_some_and(|p| p.exists())
}
#[cfg(target_os = "macos")]
pub fn load_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Launch agent plist does not exist",
));
}
// Use launchctl load to start the daemon via launchd
// The -w flag writes the "disabled" key to the override plist
let output = Command::new("launchctl")
.args(["load", "-w"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// "already loaded" is not an error condition for us
if !stderr.contains("already loaded") {
return Err(io::Error::other(format!(
"launchctl load failed: {}",
stderr
)));
}
}
log::info!("Loaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Ok(());
}
let output = Command::new("launchctl")
.args(["unload"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Not being loaded is not an error
if !stderr.contains("Could not find specified service") {
log::warn!("launchctl unload warning: {}", stderr);
}
}
log::info!("Unloaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "linux")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let escaped_daemon_path = daemon_path
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod autostart;
pub mod services;
pub mod tray;
-51
View File
@@ -1,51 +0,0 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
-204
View File
@@ -1,204 +0,0 @@
use std::process::Command;
use tray_icon::menu::{Menu, MenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub fn load_icon() -> Icon {
// On Windows, use the full-color icon so it renders well on dark taskbars.
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
#[cfg(target_os = "windows")]
let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png");
#[cfg(not(target_os = "windows"))]
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&quit_item).unwrap();
Self { menu, quit_item }
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
let builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()));
// On macOS, template icons are automatically colored by the system for light/dark mode
#[cfg(target_os = "macos")]
let builder = builder.with_icon_as_template(true);
builder.build().expect("Failed to create tray icon")
}
/// Resolve the .app bundle path from the current daemon executable.
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos_dir = exe.parent()?;
let contents_dir = macos_dir.parent()?;
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
Some(app_dir.to_path_buf())
} else {
None
}
}
pub fn open_gui() {
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
// Launch the GUI binary directly. The daemon lives inside the same .app
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
// of launching the GUI. Directly running the binary avoids macOS's app
// activation machinery. The single-instance Tauri plugin in the GUI
// handles deduplication if a GUI instance is already running.
if let Some(app_bundle) = get_app_bundle_path() {
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
if gui_binary.exists() {
let _ = Command::new(&gui_binary).spawn();
} else {
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
}
} else {
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
}
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let app_path = exe_dir.join("donutbrowser.exe");
if app_path.exists() {
let _ = Command::new(app_path).spawn();
return;
}
}
}
let paths = [
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
Some(PathBuf::from(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
fn read_gui_pid() -> Option<u32> {
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
let content = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
val.get("gui_pid")?.as_u64().map(|p| p as u32)
}
fn kill_gui_by_pid() -> bool {
let Some(pid) = read_gui_pid() else {
return false;
};
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
ret == 0
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
if kill_gui_by_pid() {
log::info!("[daemon] GUI killed by PID");
return;
}
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
#[cfg(target_os = "macos")]
{
// Use spawn() instead of output() to avoid blocking the event loop.
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut\" to quit"])
.spawn();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
-152
View File
@@ -1,152 +0,0 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
pub struct DaemonClient {
app_handle: tauri::AppHandle,
connected: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
daemon_port: Arc<Mutex<Option<u16>>>,
}
impl DaemonClient {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self {
app_handle,
connected: Arc::new(AtomicBool::new(false)),
shutdown: Arc::new(AtomicBool::new(false)),
daemon_port: Arc::new(Mutex::new(None)),
}
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::SeqCst)
}
pub async fn connect(&self, port: u16) -> Result<(), String> {
*self.daemon_port.lock().await = Some(port);
let url = format!("ws://127.0.0.1:{}/ws/events", port);
log::info!("[daemon-client] Connecting to daemon at {}", url);
let (ws_stream, _) = connect_async(&url)
.await
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
self.connected.store(true, Ordering::SeqCst);
log::info!("[daemon-client] Connected to daemon");
let (mut write, mut read) = ws_stream.split();
let app_handle = self.app_handle.clone();
let connected = self.connected.clone();
let shutdown = self.shutdown.clone();
// Spawn task to handle incoming messages
tokio::spawn(async move {
while !shutdown.load(Ordering::SeqCst) {
match read.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"event" => {
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
// Forward event to Tauri frontend
if let Err(e) = app_handle.emit(&event, payload) {
log::error!("[daemon-client] Failed to emit event: {}", e);
}
}
}
"connected" => {
log::info!("[daemon-client] Received connection confirmation");
}
"pong" => {
log::debug!("[daemon-client] Received pong");
}
_ => {
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Some(Ok(Message::Ping(data))) => {
log::debug!("[daemon-client] Received ping");
if let Err(e) = write.send(Message::Pong(data)).await {
log::error!("[daemon-client] Failed to send pong: {}", e);
break;
}
}
Some(Ok(Message::Close(_))) => {
log::info!("[daemon-client] Daemon closed connection");
break;
}
Some(Err(e)) => {
log::error!("[daemon-client] WebSocket error: {}", e);
break;
}
None => {
log::info!("[daemon-client] WebSocket stream ended");
break;
}
_ => {}
}
}
connected.store(false, Ordering::SeqCst);
log::info!("[daemon-client] Disconnected from daemon");
});
Ok(())
}
pub fn disconnect(&self) {
self.shutdown.store(true, Ordering::SeqCst);
self.connected.store(false, Ordering::SeqCst);
}
}
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
let client = DaemonClient::new(app_handle);
if let Err(e) = client.connect(port).await {
log::error!("[daemon-client] Failed to connect: {}", e);
}
client
}
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
// Try default port first
let default_port = 10108;
log::info!(
"[daemon-client] Looking for daemon on port {}",
default_port
);
let client = DaemonClient::new(app_handle);
match client.connect(default_port).await {
Ok(()) => Some(client),
Err(e) => {
log::warn!(
"[daemon-client] Could not connect to daemon on default port: {}",
e
);
None
}
}
}
-360
View File
@@ -1,360 +0,0 @@
// Daemon Spawn - Start the daemon from the GUI
// Currently disabled; will be re-enabled in the future
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
{
false
}
} else {
false
}
}
#[cfg(target_os = "macos")]
fn is_dev_mode() -> bool {
if let Ok(current_exe) = std::env::current_exe() {
let path_str = current_exe.to_string_lossy();
path_str.contains("target/debug") || path_str.contains("target/release")
} else {
false
}
}
#[cfg(target_os = "macos")]
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let daemon_path = exe_dir.join("donut-daemon");
if daemon_path.exists() {
return Some(daemon_path);
}
}
}
// Try common installation paths
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
.unwrap_or_default(),
];
paths.into_iter().find(|path| path.exists())
}
#[cfg(any(target_os = "linux", windows))]
fn get_daemon_path() -> Option<PathBuf> {
// First, try to find it next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
let exe_dir = current_exe.parent()?;
// Check for daemon binary in same directory
#[cfg(target_os = "windows")]
let daemon_name = "donut-daemon.exe";
#[cfg(target_os = "linux")]
let daemon_name = "donut-daemon";
let daemon_path = exe_dir.join(daemon_name);
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
return Some(PathBuf::from(path));
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
None
}
pub fn spawn_daemon() -> Result<(), String> {
// Log the daemon state for debugging
let state = read_state();
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
// Check if already running
if is_daemon_running() {
log::info!("Daemon is already running (verified by PID check)");
return Ok(());
}
log::info!("Daemon is not running, attempting to start...");
// Log current exe location for debugging
let current_exe = std::env::current_exe().ok();
log::info!("Current exe: {:?}", current_exe);
// On macOS, use launchctl to start the daemon via launchd
// This ensures the daemon runs in the user's Aqua session with WindowServer access
// and survives app termination since it's managed by launchd, not as a child process
#[cfg(target_os = "macos")]
{
spawn_daemon_macos()?;
}
// On Linux, use direct spawn
#[cfg(target_os = "linux")]
{
spawn_daemon_unix()?;
}
#[cfg(windows)]
{
spawn_daemon_windows()?;
}
// Wait for daemon to start (max 3 seconds)
for i in 0..30 {
thread::sleep(Duration::from_millis(100));
if is_daemon_running() {
log::info!("Daemon started successfully after {}ms", (i + 1) * 100);
return Ok(());
}
}
// Check if we got a state file at least
let state = read_state();
if let Some(pid) = state.daemon_pid {
log::info!("Daemon appears to have started (PID {} in state file)", pid);
return Ok(());
}
Err("Daemon did not start within timeout".to_string())
}
#[cfg(target_os = "macos")]
fn spawn_daemon_macos() -> Result<(), String> {
use std::os::unix::process::CommandExt;
// In dev mode, use direct spawn instead of launchctl
// This avoids issues with plist paths pointing to wrong binaries
if is_dev_mode() {
log::info!("Dev mode detected, using direct spawn instead of launchctl");
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
return Ok(());
}
// Production mode: use launchctl for proper daemon management
// First, ensure the LaunchAgent plist is installed
let autostart_enabled = autostart::is_autostart_enabled();
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
if !autostart_enabled {
log::info!("Installing LaunchAgent plist for daemon management");
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
log::info!("LaunchAgent plist installed successfully");
}
// Load the launch agent via launchctl
log::info!("Loading daemon via launchctl...");
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn spawn_daemon_unix() -> Result<(), String> {
use std::os::unix::process::CommandExt;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
#[cfg(windows)]
fn spawn_daemon_windows() -> Result<(), String> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
Command::new(&daemon_path)
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
pub fn ensure_daemon_running() -> Result<(), String> {
if !is_daemon_running() {
spawn_daemon()?;
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
-134
View File
@@ -1,134 +0,0 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::events::{DaemonEmitter, DaemonEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct WsState {
event_emitter: Option<Arc<DaemonEmitter>>,
}
impl WsState {
pub fn new() -> Self {
Self {
event_emitter: None,
}
}
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
Self {
event_emitter: Some(emitter),
}
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WsState) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to daemon events if emitter is available
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
log::info!("[ws] Client connected");
// Send initial ping to confirm connection
let ping_msg = WsMessage {
msg_type: "connected".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
loop {
tokio::select! {
// Handle incoming messages from client
Some(msg) = receiver.next() => {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"ping" => {
let pong = WsMessage {
msg_type: "pong".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&pong) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
}
_ => {
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Ok(Message::Ping(data)) => {
let _ = sender.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
log::info!("[ws] Client disconnected");
break;
}
Err(e) => {
log::error!("[ws] Error receiving message: {}", e);
break;
}
_ => {}
}
}
// Forward daemon events to client
Some(daemon_event) = async {
if let Some(ref mut rx) = event_rx {
rx.recv().await.ok()
} else {
std::future::pending::<Option<DaemonEvent>>().await
}
} => {
let ws_msg = WsMessage {
msg_type: "event".to_string(),
event: Some(daemon_event.event_type),
payload: Some(daemon_event.payload),
};
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
if sender.send(Message::Text(msg_str.into())).await.is_err() {
log::error!("[ws] Failed to send event to client");
break;
}
}
}
else => break,
}
}
log::info!("[ws] WebSocket connection closed");
}
+64 -12
View File
@@ -1296,21 +1296,73 @@ pub async fn ensure_active_browsers_downloaded(
};
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
match crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
)
.await
{
Ok(_) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
// Retry transient failures a few times. Each attempt is wrapped in an overall
// timeout so that a hang anywhere in the download pipeline (version resolution,
// a stalled stream, extraction) cannot block the next browser forever. This is
// the core of the bug fix: Wayfern going first must never starve Camoufox.
const MAX_ATTEMPTS: u32 = 3;
const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
let mut succeeded = false;
for attempt in 1..=MAX_ATTEMPTS {
let result = tokio::time::timeout(
ATTEMPT_TIMEOUT,
crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
),
)
.await;
match result {
Ok(Ok(_)) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
succeeded = true;
break;
}
Ok(Err(e)) => {
log::warn!(
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
);
}
Err(_) => {
// The download future itself hung past the overall timeout and was dropped,
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
// (the future may have re-resolved to a different version, so clear by
// browser prefix) and emit a terminal error event so the UI stops spinning.
log::warn!(
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
ATTEMPT_TIMEOUT.as_secs()
);
crate::downloader::clear_download_state_for_browser(browser);
let progress = crate::downloader::DownloadProgress {
browser: (*browser).to_string(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = crate::events::emit("download-progress", &progress);
}
}
Err(e) => {
log::warn!("Failed to auto-download {browser} {version}: {e}");
if attempt < MAX_ATTEMPTS {
// Short backoff before retrying a transient failure.
let backoff = std::time::Duration::from_secs(2u64.pow(attempt - 1));
tokio::time::sleep(backoff).await;
}
}
if !succeeded {
// Do NOT abort the whole routine: continue so the next browser (Camoufox)
// still gets its chance even though this one failed/timed out.
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
}
}
Ok(downloaded)
+125 -15
View File
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
// Maximum time to wait for the next chunk of a streaming download before treating
// the connection as stalled. Converts an indefinite hang into a terminal error so
// the UI can surface it and the caller can move on / retry.
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
@@ -44,6 +49,11 @@ impl Downloader {
Self {
client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
// Per-read idle timeout: if the connection stalls mid-stream with no bytes
// for this long, the read fails instead of hanging forever. This is the
// transport-level guard; the streaming loop also wraps each read in an
// explicit tokio timeout as defense-in-depth.
.read_timeout(STREAM_IDLE_TIMEOUT)
.build()
.unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(),
@@ -470,7 +480,26 @@ impl Downloader {
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
loop {
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
// surfaces as a terminal error instead of awaiting forever.
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Ok(item) => item,
Err(_) => {
drop(file);
// Keep any partial bytes on disk so a later attempt can resume via Range.
return Err(
format!(
"Download stalled: no data received for {}s",
STREAM_IDLE_TIMEOUT.as_secs()
)
.into(),
);
}
};
let Some(chunk) = next else {
break;
};
if let Some(token) = cancel_token {
if token.is_cancelled() {
drop(file);
@@ -694,20 +723,25 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
// Emit cancelled stage if the download was cancelled by user
if cancel_token.is_cancelled() {
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "cancelled".to_string(),
};
let _ = events::emit("download-progress", &progress);
}
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
// "cancelled"; any other failure (network error, stall timeout, bad status)
// maps to "error" so the frontend can show a concrete error toast.
let stage = if cancel_token.is_cancelled() {
"cancelled"
} else {
"error"
};
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: stage.to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to download browser: {e}").into());
}
@@ -844,6 +878,20 @@ impl Downloader {
// Do not delete files on verification failure; keep archive for manual retry.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Emit a terminal error stage so the UI shows an error instead of spinning.
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
downloading.contains(&download_key)
}
/// Clear all in-progress download bookkeeping for a browser.
///
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
/// by an outer timeout) before its own error path could run. Because
/// `download_browser_full` may re-resolve to a different version than requested, this
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
/// key is left behind regardless of which version was actually in flight.
pub fn clear_download_state_for_browser(browser: &str) {
let prefix = format!("{browser}-");
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.retain(|key| !key.starts_with(&prefix));
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.retain(|key, _| !key.starts_with(&prefix));
}
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -1110,6 +1177,49 @@ mod tests {
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content.len(), test_content.len());
}
#[test]
fn test_clear_download_state_for_browser_removes_stuck_keys() {
// Simulate a download future that was abandoned without running its own cleanup,
// leaving stuck bookkeeping for a version that differs from the requested one.
let key = "wayfern-1.2.3-resolved".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(key.clone());
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.insert(key.clone(), CancellationToken::new());
}
// A different browser's in-progress state must be left untouched.
let other = "camoufox-9.9.9".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(other.clone());
}
clear_download_state_for_browser("wayfern");
assert!(
!is_downloading("wayfern", "1.2.3-resolved"),
"stuck wayfern key should be cleared even when version differs from request"
);
{
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
assert!(
!tokens.contains_key(&key),
"stuck wayfern cancellation token should be cleared"
);
}
assert!(
is_downloading("camoufox", "9.9.9"),
"unrelated browser's download state must be preserved"
);
// Cleanup so we don't leak global state into other tests.
clear_download_state_for_browser("camoufox");
}
}
// Global singleton instance
+1
View File
@@ -281,6 +281,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
+2 -73
View File
@@ -1,10 +1,7 @@
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::broadcast;
/// Trait for emitting events to the frontend or connected clients.
/// This abstraction allows the same code to work in both GUI (Tauri) mode
/// and daemon mode (WebSocket broadcast).
/// Trait for emitting events to the frontend.
///
/// Note: This trait uses `serde_json::Value` to be dyn-compatible.
/// Use the convenience functions `emit()` and `emit_empty()` which accept
@@ -37,49 +34,6 @@ impl EventEmitter for TauriEmitter {
}
}
/// Event message sent through the daemon's broadcast channel.
#[derive(Clone, Debug)]
pub struct DaemonEvent {
pub event_type: String,
pub payload: serde_json::Value,
}
/// Daemon-based event emitter for background daemon mode.
/// Broadcasts events to all connected WebSocket clients.
#[derive(Clone)]
pub struct DaemonEmitter {
tx: broadcast::Sender<DaemonEvent>,
}
impl DaemonEmitter {
pub fn new(tx: broadcast::Sender<DaemonEvent>) -> Self {
Self { tx }
}
/// Create a new DaemonEmitter with a default channel capacity.
pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver<DaemonEvent>) {
let (tx, rx) = broadcast::channel(capacity);
(Self { tx }, rx)
}
/// Subscribe to events from this emitter.
pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
self.tx.subscribe()
}
}
impl EventEmitter for DaemonEmitter {
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
let daemon_event = DaemonEvent {
event_type: event.to_string(),
payload,
};
// Ignore send errors (no receivers connected)
let _ = self.tx.send(daemon_event);
Ok(())
}
}
/// No-op emitter for testing or when events are not needed.
#[derive(Clone, Default)]
pub struct NoopEmitter;
@@ -91,8 +45,7 @@ impl EventEmitter for NoopEmitter {
}
/// Global event emitter that can be set at runtime.
/// This allows managers to emit events without knowing whether they're
/// running in GUI or daemon mode.
/// This allows managers to emit events without holding an AppHandle directly.
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new();
/// Set the global event emitter. This should be called once during app startup.
@@ -136,30 +89,6 @@ mod tests {
.is_ok());
}
#[test]
fn test_daemon_emitter() {
let (emitter, mut rx) = DaemonEmitter::with_capacity(16);
// Emit an event
let _ = emitter.emit_value("test-event", serde_json::json!("hello"));
// Check we received it
let event = rx.try_recv().unwrap();
assert_eq!(event.event_type, "test-event");
assert_eq!(event.payload, serde_json::json!("hello"));
}
#[test]
fn test_daemon_emitter_no_receivers() {
let (tx, _) = broadcast::channel::<DaemonEvent>(16);
let emitter = DaemonEmitter::new(tx);
// Should not error even with no receivers
assert!(emitter
.emit_value("test-event", serde_json::json!("hello"))
.is_ok());
}
#[test]
fn test_emit_convenience_function() {
// Test that emit() works with various types
+99 -37
View File
@@ -27,6 +27,11 @@ pub struct Extension {
pub author: Option<String>,
#[serde(default)]
pub homepage_url: Option<String>,
/// Firefox extension ID from `browser_specific_settings.gecko.id` (or
/// `applications.gecko.id` in old manifests). Firefox refuses to load a
/// sideloaded .xpi unless the filename matches this value.
#[serde(default)]
pub gecko_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
(name, version, description, author, homepage_url)
}
/// Read `browser_specific_settings.gecko.id` (or the legacy
/// `applications.gecko.id`) from the extension's manifest.json. Firefox uses
/// this value as the canonical add-on ID; sideloaded .xpi files must be named
/// `<gecko_id>.xpi` to be picked up.
fn extract_gecko_id(file_data: &[u8], file_type: &str) -> Option<String> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut manifest_content = String::new();
std::io::Read::read_to_string(
&mut archive.by_name("manifest.json").ok()?,
&mut manifest_content,
)
.ok()?;
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
manifest
.pointer("/browser_specific_settings/gecko/id")
.or_else(|| manifest.pointer("/applications/gecko/id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
@@ -285,6 +316,7 @@ impl ExtensionManager {
name
};
let gecko_id = extract_gecko_id(&file_data, &file_type);
let ext = Extension {
id: uuid::Uuid::new_v4().to_string(),
name: final_name,
@@ -299,6 +331,7 @@ impl ExtensionManager {
description,
author,
homepage_url,
gecko_id,
};
let file_dir = self.get_file_dir(&ext.id);
@@ -415,6 +448,7 @@ impl ExtensionManager {
ext.name = mn;
}
}
ext.gecko_id = extract_gecko_id(&data, &new_file_type);
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
@@ -893,24 +927,33 @@ impl ExtensionManager {
continue;
}
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
if src_file.exists() {
// Firefox expects .xpi files in extensions dir
let dest_name = if ext.file_type == "zip" {
format!(
"{}.xpi",
ext
.file_name
.rsplit('.')
.next_back()
.unwrap_or(&ext.file_name)
)
} else {
ext.file_name.clone()
};
let dest = extensions_dir.join(&dest_name);
fs::copy(&src_file, &dest)?;
extension_paths.push(dest.to_string_lossy().to_string());
if !src_file.exists() {
continue;
}
// Firefox/Camoufox only loads sideloaded .xpi files whose filename
// matches `browser_specific_settings.gecko.id` from the manifest.
// Prefer the cached value; fall back to reading the manifest now
// for extensions added before the field existed.
let gecko_id = if let Some(ref id) = ext.gecko_id {
Some(id.clone())
} else if let Ok(data) = fs::read(&src_file) {
extract_gecko_id(&data, &ext.file_type)
} else {
None
};
let Some(gecko_id) = gecko_id else {
log::warn!(
"Skipping Firefox extension '{}': could not determine gecko id from manifest.json",
ext.name
);
continue;
};
let dest = extensions_dir.join(format!("{gecko_id}.xpi"));
fs::copy(&src_file, &dest)?;
extension_paths.push(dest.to_string_lossy().to_string());
}
}
}
@@ -1022,30 +1065,49 @@ impl ExtensionManager {
}
}
if ext.version.is_none() && ext.description.is_none() {
let needs_meta_backfill = ext.version.is_none() && ext.description.is_none();
let needs_gecko_backfill =
ext.gecko_id.is_none() && ext.browser_compatibility.iter().any(|b| b == "firefox");
if needs_meta_backfill || needs_gecko_backfill {
let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) {
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &ext.file_type);
if version.is_some()
|| description.is_some()
|| author.is_some()
|| homepage_url.is_some()
|| manifest_name.is_some()
{
let mut updated_ext = ext.clone();
if let Some(v) = version {
updated_ext.version = Some(v);
let mut updated_ext = ext.clone();
let mut changed = false;
if needs_meta_backfill {
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &ext.file_type);
if version.is_some()
|| description.is_some()
|| author.is_some()
|| homepage_url.is_some()
|| manifest_name.is_some()
{
if let Some(v) = version {
updated_ext.version = Some(v);
}
if let Some(d) = description {
updated_ext.description = Some(d);
}
if let Some(a) = author {
updated_ext.author = Some(a);
}
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
}
changed = true;
}
if let Some(d) = description {
updated_ext.description = Some(d);
}
if let Some(a) = author {
updated_ext.author = Some(a);
}
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
}
if needs_gecko_backfill {
if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) {
updated_ext.gecko_id = Some(gid);
changed = true;
}
}
if changed {
let metadata_path = self.get_metadata_path(&ext.id);
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
let _ = fs::write(metadata_path, json);
+8
View File
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -90,6 +94,7 @@ impl GroupManager {
name,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
groups_data.groups.push(group.clone());
@@ -136,6 +141,7 @@ impl GroupManager {
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
group.name = name;
group.updated_at = Some(crate::proxy_manager::now_secs());
let updated_group = group.clone();
self.save_groups_data(&groups_data)?;
@@ -167,6 +173,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
self.save_groups_data(&groups_data)?;
}
@@ -183,6 +190,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
} else {
groups_data.groups.push(group.clone());
}
+227 -27
View File
@@ -1,13 +1,19 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
// Store pending URLs that need to be handled when the window is ready
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// Set to true once the user has confirmed they want to quit, so the close
// interceptor lets the next CloseRequested through instead of looping back
// to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
mod api_client;
mod api_server;
mod app_auto_updater;
@@ -46,11 +52,6 @@ mod wayfern_terms;
pub mod cloud_auth;
mod commercial_license;
mod cookie_manager;
pub mod daemon;
pub mod daemon_client;
#[allow(dead_code)]
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
@@ -92,14 +93,14 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
save_sync_settings, save_table_sorting_settings,
};
use sync::{
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
@@ -190,7 +191,8 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
}
}
#[tauri::command]
// Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`.
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}");
@@ -927,15 +929,21 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
#[tauri::command]
async fn check_vpn_validity(
vpn_id: String,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
check_vpn_validity_core(&vpn_id).await
}
pub async fn check_vpn_validity_core(
vpn_id: &str,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id).is_some();
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
let vpn_worker = vpn_worker_runner::start_vpn_worker(vpn_id)
.await
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
@@ -1012,6 +1020,53 @@ async fn check_vpn_validity(
Ok(result)
}
/// Validate that a profile's selected proxy or VPN actually works before the
/// profile is created. Shared by the Tauri command, REST API, and MCP create
/// paths so a dead/unreachable proxy or VPN (or a 402 from an expired proxy
/// subscription) fails creation identically everywhere. Returns structured
/// `{ "code": ... }` error strings the frontend translates via backend-errors.ts.
pub async fn validate_profile_network(
proxy_id: Option<&str>,
vpn_id: Option<&str>,
) -> Result<(), String> {
if let Some(vpn_id) = vpn_id.filter(|s| !s.is_empty()) {
let result = check_vpn_validity_core(vpn_id).await?;
if !result.is_valid {
return Err(serde_json::json!({ "code": "VPN_NOT_WORKING" }).to_string());
}
return Ok(());
}
if let Some(proxy_id) = proxy_id.filter(|s| !s.is_empty()) {
// The cloud-included proxy is managed infrastructure; its only failure mode
// is the user hitting their usage limit, which surfaces as a 402 at request
// time. There's nothing to pre-validate here.
if proxy_id == crate::proxy_manager::CLOUD_PROXY_ID {
return Ok(());
}
let settings = crate::proxy_manager::PROXY_MANAGER
.get_proxy_settings_by_id(proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
match crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity(proxy_id, &settings)
.await
{
Ok(result) if result.is_valid => {}
Ok(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
Err(err) if err.contains("402") => {
return Err(serde_json::json!({ "code": "PROXY_PAYMENT_REQUIRED" }).to_string());
}
Err(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
}
}
Ok(())
}
#[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Start VPN worker process (detached, survives GUI shutdown)
@@ -1120,6 +1175,7 @@ async fn generate_sample_fingerprint(
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
if browser == "camoufox" {
@@ -1145,6 +1201,120 @@ async fn generate_sample_fingerprint(
}
}
/// Confirm a quit chosen from the close-confirmation dialog and exit the app.
#[tauri::command]
fn confirm_quit(app_handle: tauri::AppHandle) {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
app_handle.exit(0);
}
/// Hide the main window so the app keeps running behind its tray icon.
#[tauri::command]
fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app_handle.get_webview_window("main") {
window.hide().map_err(|e| e.to_string())?;
}
Ok(())
}
fn show_main_window(app_handle: &tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
/// Update the tray menu labels with localized strings pushed from the frontend
/// (which owns the active language). The item ids are unchanged so the existing
/// menu-event handler keeps matching.
#[tauri::command]
fn update_tray_menu(
app_handle: tauri::AppHandle,
show_label: String,
quit_label: String,
) -> Result<(), String> {
use tauri::menu::{MenuBuilder, MenuItemBuilder};
if let Some(tray) = app_handle.tray_by_id("main") {
let show_item = MenuItemBuilder::with_id("tray_show", show_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let quit_item = MenuItemBuilder::with_id("tray_quit", quit_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let menu = MenuBuilder::new(&app_handle)
.item(&show_item)
.separator()
.item(&quit_item)
.build()
.map_err(|e| e.to_string())?;
tray.set_menu(Some(menu)).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Build the system tray. Best-effort: on Linux the tray depends on
/// libayatana-appindicator at runtime, so any failure here must not abort app
/// startup — the caller logs and continues without a tray.
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
// Bootstrap labels only — the frontend pushes localized labels via
// `update_tray_menu` on mount and on language change, and the menu is only
// opened after a minimize-to-tray (post-mount), so these are never shown.
let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser").build(app)?;
let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit").build(app)?;
let tray_menu = MenuBuilder::new(app)
.item(&show_item)
.separator()
.item(&quit_item)
.build()?;
// macOS uses a black template icon (the OS tints it for light/dark menu
// bars). Windows and Linux use the full-color icon, because neither tints a
// template — a black template would be invisible on dark Linux panels.
#[cfg(target_os = "macos")]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
#[cfg(not(target_os = "macos"))]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8();
let (tray_w, tray_h) = tray_rgba.dimensions();
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
TrayIconBuilder::with_id("main")
.icon(tray_image)
.icon_as_template(cfg!(target_os = "macos"))
.tooltip("Donut Browser")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(|app_handle, event| match event.id().as_ref() {
"tray_show" => show_main_window(app_handle),
"tray_quit" => {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
app_handle.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Click events are not delivered on Linux (AppIndicator/SNI only drives
// the menu), so left-click-to-restore is macOS/Windows only — Linux users
// restore via the "Show Donut Browser" menu item.
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -1158,15 +1328,25 @@ pub fn run() {
let log_file_name = app_dirs::app_name();
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
// the platform default app log dir, so all on-disk state lives under one root.
let file_log_target = match app_dirs::log_dir_override() {
Some(path) => Target::new(TargetKind::Folder {
path,
file_name: Some(log_file_name.to_string()),
}),
None => Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}),
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
@@ -1218,14 +1398,6 @@ pub fn run() {
mgr.ensure_icons_extracted();
}
// Daemon (tray icon) is currently disabled — clean up any existing autostart
if daemon::autostart::is_autostart_enabled() {
log::info!("Removing daemon autostart (daemon is disabled)");
if let Err(e) = daemon::autostart::disable_autostart() {
log::warn!("Failed to remove daemon autostart: {e}");
}
}
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
@@ -1243,6 +1415,32 @@ pub fn run() {
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
// System tray so the user can keep the app running after the close
// dialog's "Minimize" action hides the window. Best-effort: a tray
// failure (e.g. missing libayatana-appindicator on Linux) must never
// prevent the app from launching, so we log and continue without it.
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
}
// Intercept the window close so the frontend can ask the user whether
// to minimize or quit. The app exits when `confirm_quit` flips
// QUIT_CONFIRMED — until then, every CloseRequested is held back.
{
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if QUIT_CONFIRMED.load(Ordering::SeqCst) {
return;
}
api.prevent_close();
if let Err(e) = app_handle.emit("close-confirm-requested", ()) {
log::warn!("Failed to emit close-confirm-requested: {e}");
}
}
});
}
// Set transparent titlebar for macOS
#[cfg(target_os = "macos")]
{
@@ -1954,6 +2152,9 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
confirm_quit,
hide_to_tray,
update_tray_menu,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
@@ -1984,15 +2185,14 @@ pub fn run() {
save_app_settings,
read_log_files,
open_log_directory,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
get_table_sorting_settings,
save_table_sorting_settings,
get_system_language,
get_system_info,
dismiss_window_resize_warning,
get_window_resize_warning_dismissed,
get_onboarding_completed,
complete_onboarding,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
@@ -2057,6 +2257,7 @@ pub fn run() {
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync,
set_proxy_sync_enabled,
set_group_sync_enabled,
@@ -2103,7 +2304,6 @@ pub fn run() {
disconnect_vpn,
get_vpn_status,
list_active_vpn_connections,
handle_url_open,
// Cloud auth commands
cloud_auth::cloud_exchange_device_code,
cloud_auth::cloud_get_user,
+525 -18
View File
@@ -33,6 +33,48 @@ pub struct McpTool {
pub input_schema: serde_json::Value,
}
/// JavaScript executed in the target page to enumerate visible interactive
/// elements. Returns a JSON string `{elements, count, truncated}` where
/// `elements` is the newline-joined labeled list. Live references are stashed
/// on `window.__donut_interactive` so subsequent `click_by_index` /
/// `type_by_index` calls can resolve `index → Element` without round-tripping
/// a selector. `__MAX_CHARS__` is substituted at call time.
const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => {
const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt'];
const MAX_CHARS = __MAX_CHARS__;
const interactive = [];
const lines = [];
let truncated = false;
let total = 0;
const nodes = document.querySelectorAll(SELECTORS);
for (const el of nodes) {
if (el.disabled) continue;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) continue;
const style = window.getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
const tag = el.tagName.toLowerCase();
const parts = [];
for (const a of ATTRS) {
const v = el.getAttribute(a);
if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"');
}
let text = '';
if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100);
}
const idx = interactive.length;
const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + '</' + tag + '>';
if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; }
total += line.length + 1;
interactive.push(el);
lines.push(line);
}
window.__donut_interactive = interactive;
return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated });
})()"#;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct McpRequest {
@@ -1103,6 +1145,25 @@ impl McpServer {
"required": ["profile_id"]
}),
},
// Cookie management tools
McpTool {
name: "import_profile_cookies".to_string(),
description: "Import cookies into a Wayfern or Camoufox profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the target profile"
},
"content": {
"type": "string",
"description": "Raw cookie file content (JSON array or Netscape cookies.txt)"
}
},
"required": ["profile_id", "content"]
}),
},
// Team lock tools
McpTool {
name: "get_team_locks".to_string(),
@@ -1354,6 +1415,76 @@ impl McpServer {
"required": ["profile_id"]
}),
},
McpTool {
name: "get_interactive_elements".to_string(),
description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"max_chars": {
"type": "integer",
"description": "Cap on the serialized output length (default: 40000). The response carries a `truncated` flag if the list was cut off — narrow the viewport or scroll if you need elements past the cutoff."
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "click_by_index".to_string(),
description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
}
},
"required": ["profile_id", "index"]
}),
},
McpTool {
name: "type_by_index".to_string(),
description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
},
"text": {
"type": "string",
"description": "Text to type into the element"
},
"clear_first": {
"type": "boolean",
"description": "Clear the input before typing (default: true)"
},
"instant": {
"type": "boolean",
"description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection."
},
"wpm": {
"type": "number",
"description": "Target words per minute for human typing (default: 80)"
}
},
"required": ["profile_id", "index", "text"]
}),
},
]
}
@@ -1540,9 +1671,15 @@ impl McpServer {
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
// Fingerprint management — viewing and editing both require a paid plan.
"get_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_get_profile_fingerprint(arguments).await
}
"update_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_update_profile_fingerprint(arguments).await
}
"update_profile_proxy_bypass_rules" => {
self
.handle_update_profile_proxy_bypass_rules(arguments)
@@ -1562,6 +1699,8 @@ impl McpServer {
.handle_assign_extension_group_to_profile(arguments)
.await
}
// Cookie management
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
// Team lock tools
"get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
@@ -1602,6 +1741,18 @@ impl McpServer {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_info(arguments).await
}
"get_interactive_elements" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_interactive_elements(arguments).await
}
"click_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_click_by_index(arguments).await
}
"type_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_type_by_index(arguments).await
}
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
@@ -1687,7 +1838,7 @@ impl McpServer {
})?;
let url = arguments.get("url").and_then(|v| v.as_str());
let _headless = arguments
let headless = arguments
.get("headless")
.and_then(|v| v.as_bool())
.unwrap_or(false);
@@ -1731,19 +1882,21 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(),
})?;
// Launch the browser
crate::browser_runner::BrowserRunner::instance()
.launch_browser(
app_handle.clone(),
profile,
url.map(|s| s.to_string()),
None,
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to launch browser: {e}"),
})?;
// Launch a fresh instance, honoring the requested headless mode. The CDP
// port is self-allocated and discovered later via get_cdp_port_for_profile.
crate::browser_runner::launch_browser_profile_impl(
app_handle.clone(),
profile.clone(),
url.map(|s| s.to_string()),
None,
headless,
true,
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to launch browser: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
@@ -2731,6 +2884,74 @@ impl McpServer {
}))
}
// Cookie management handlers
async fn handle_import_profile_cookies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
})?;
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?
.clone()
};
let result =
crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import cookies: {e}"),
})?;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = crate::profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.is_sync_enabled() {
let pid = profile_id.to_string();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} replaced, {} parse error(s)",
result.cookies_imported,
result.cookies_replaced,
result.errors.len()
)
}]
}))
}
// VPN management handlers
async fn handle_import_vpn(
&self,
@@ -4263,6 +4484,11 @@ impl McpServer {
.and_then(|v| v.as_str())
.unwrap_or("text");
let selector = arguments.get("selector").and_then(|v| v.as_str());
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(40_000);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
@@ -4310,10 +4536,28 @@ impl McpServer {
.and_then(|v| v.as_str())
.unwrap_or("");
// Cap output so a 500 KB DOM dump doesn't blow out the agent's context.
// Slice on character boundaries (chars().take().collect()) rather than
// byte indices, since the latter would panic on multi-byte boundaries.
let total_chars = content.chars().count();
let (text, truncated) = if total_chars > max_chars {
(content.chars().take(max_chars).collect::<String>(), true)
} else {
(content.to_string(), false)
};
let payload = if truncated {
format!(
"{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]"
)
} else {
text
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": content
"text": payload
}]
}))
}
@@ -4361,6 +4605,267 @@ impl McpServer {
}))
}
async fn handle_get_interactive_elements(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(40_000);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
// Walk the DOM for visible, non-disabled interactive elements, label them
// with a zero-based index, and cache the live references on
// `window.__donut_interactive` so click_by_index / type_by_index can
// resolve the index → Element without round-tripping a selector.
let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string());
let result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Enumeration failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
let payload_str = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{}");
let payload: serde_json::Value =
serde_json::from_str(payload_str).unwrap_or(serde_json::json!({}));
let elements = payload
.get("elements")
.and_then(|v| v.as_str())
.unwrap_or("");
let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
let truncated = payload
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let header = if truncated {
format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):")
} else {
format!("{count} interactive elements:")
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("{header}\n{elements}")
}]
}))
}
async fn handle_click_by_index(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let index = arguments
.get("index")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing index".to_string(),
})?;
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let js = format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.click();
return true;
}})()"#
);
let result = self
.send_cdp_and_wait_for_load(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
10,
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Click failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Clicked element at index {index}")
}]
}))
}
async fn handle_type_by_index(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let index = arguments
.get("index")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing index".to_string(),
})?;
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing text".to_string(),
})?;
let clear_first = arguments
.get("clear_first")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let instant = arguments
.get("instant")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let wpm = arguments.get("wpm").and_then(|v| v.as_f64());
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
// Mirrors handle_type_text's focus step but resolves the element via the
// cached index instead of a CSS selector.
let focus_js = if clear_first {
format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
el.value = '';
el.dispatchEvent(new Event('input', {{bubbles: true}}));
return true;
}})()"#
)
} else {
format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
return true;
}})()"#
)
};
let focus_result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": focus_js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = focus_result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Focus failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
if instant {
self
.send_cdp(
&ws_url,
"Input.insertText",
serde_json::json!({ "text": text }),
)
.await?;
} else {
self.send_human_keystrokes(&ws_url, text, wpm).await?;
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Typed text into element at index {index}")
}]
}))
}
// --- Synchronizer handlers ---
async fn handle_start_sync_session(
@@ -4560,6 +5065,8 @@ mod tests {
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
// Cookie tools
assert!(tool_names.contains(&"import_profile_cookies"));
// Team lock tools
assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status"));
+67 -26
View File
@@ -200,6 +200,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -303,6 +304,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -365,6 +367,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Save profile info
@@ -377,9 +380,18 @@ impl ProfileManager {
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
// Create user.js with common Firefox preferences and apply proxy settings if provided
// Skip for ephemeral profiles since the data dir is created at launch time
if !ephemeral {
// `apply_proxy_settings_to_profile` writes a Firefox-style user.js
// with the upstream proxy host. That is wrong for both supported
// browser types:
// - Camoufox: camoufox_manager rewrites user.js at every launch with
// the local donut-proxy host; writing the upstream here leaves a
// stale, wrong proxy in user.js until the next launch.
// - Wayfern: Chromium gets its proxy via `--proxy-pac-url=` at launch
// (see wayfern_manager.rs) and never reads user.js.
// So we only call it for any unrecognized browser type that might be
// a true Firefox-family target (none currently). Ephemeral profiles
// skip regardless because their data dir is created at launch time.
if !ephemeral && !matches!(browser, "camoufox" | "wayfern") {
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
@@ -501,6 +513,7 @@ impl ProfileManager {
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile with new name
self.save_profile(&profile)?;
@@ -710,6 +723,7 @@ impl ProfileManager {
}
profile.group_id = group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -764,6 +778,7 @@ impl ProfileManager {
}
}
profile.tags = deduped;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -800,6 +815,7 @@ impl ProfileManager {
// Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -829,6 +845,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -860,6 +877,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.proxy_bypass_rules = rules;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -886,6 +904,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.dns_blocklist = dns_blocklist;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -1049,6 +1068,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_profile(&new_profile)?;
@@ -1216,6 +1236,7 @@ impl ProfileManager {
// Update proxy settings and clear VPN (mutual exclusion)
profile.proxy_id = proxy_id.clone();
profile.vpn_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save the updated profile
self
@@ -1236,18 +1257,34 @@ impl ProfileManager {
}
}
// Update on-disk browser profile config immediately
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
// Update on-disk browser profile config immediately.
// Both supported browser types ignore this write (Camoufox rewrites
// user.js at launch with the local donut-proxy host, Wayfern takes its
// proxy via `--proxy-pac-url=` and never reads user.js), and for
// Camoufox specifically writing the upstream host here would leave a
// stale, wrong proxy in user.js until the next launch.
if !matches!(profile.browser.as_str(), "camoufox" | "wayfern") {
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
} else {
// Proxy ID provided but proxy not found, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
} else {
// Proxy ID provided but proxy not found, disable proxy
// No proxy ID provided, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
@@ -1256,15 +1293,6 @@ impl ProfileManager {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
} else {
// No proxy ID provided, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
@@ -1308,6 +1336,7 @@ impl ProfileManager {
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id.clone();
profile.proxy_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self
.save_profile(&profile)
@@ -1352,6 +1381,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -1799,10 +1829,17 @@ impl ProfileManager {
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
// Keep extension updates enabled and allow sideloaded extensions
// Keep extension updates enabled and allow sideloaded extensions.
// - autoDisableScopes=0: profile-installed extensions are enabled by default.
// - startupScanScopes=1: rescan SCOPE_PROFILE on each launch so freshly
// dropped .xpi files in <profile>/extensions/ get registered.
// - signatures.required=false: accept unsigned/dev .xpi files. Camoufox
// is built without MOZ_REQUIRE_SIGNING so this is honored.
"user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
"user_pref(\"extensions.startupScanScopes\", 1);".to_string(),
"user_pref(\"xpinstall.signatures.required\", false);".to_string(),
// Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(),
@@ -2432,6 +2469,10 @@ pub async fn create_browser_profile_new(
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
// subscription) cancels creation with a translatable error.
crate::validate_profile_network(proxy_id.as_deref(), vpn_id.as_deref()).await?;
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group(
@@ -2463,7 +2504,7 @@ pub async fn update_camoufox_config(
.has_active_paid_subscription()
.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
@@ -2491,7 +2532,7 @@ pub async fn update_wayfern_config(
.has_active_paid_subscription()
.await
{
return Err("Fingerprint editing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
+6
View File
@@ -78,6 +78,12 @@ pub struct BrowserProfile {
/// any staleness check.
#[serde(default)]
pub created_at: Option<u64>,
/// Unix seconds of the last meaningful metadata edit (name, tags, note,
/// proxy/vpn/group/extension assignment, launch hook, bypass rules, dns).
/// Source of truth for metadata sync conflict resolution (last-write-wins);
/// NOT bumped by browser-file changes, which sync via the file manifest.
#[serde(default)]
pub updated_at: Option<u64>,
}
pub fn default_release_type() -> String {
+3
View File
@@ -586,6 +586,7 @@ impl ProfileImporter {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -668,6 +669,7 @@ impl ProfileImporter {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -726,6 +728,7 @@ impl ProfileImporter {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.profile_manager.save_profile(&profile)?;
+20
View File
@@ -103,6 +103,11 @@ pub struct StoredProxy {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins) — bumped on config edits only, never
/// by sync bookkeeping. `None` on legacy files is treated as 0.
#[serde(default)]
pub updated_at: Option<u64>,
#[serde(default)]
pub is_cloud_managed: bool,
#[serde(default)]
@@ -124,6 +129,14 @@ pub struct StoredProxy {
pub dynamic_proxy_format: Option<String>,
}
/// Current unix time in whole seconds. Used to stamp `updated_at` on edits.
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured();
@@ -133,6 +146,7 @@ impl StoredProxy {
proxy_settings,
sync_enabled,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: None,
@@ -159,10 +173,12 @@ impl StoredProxy {
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
self.proxy_settings = proxy_settings;
self.updated_at = Some(now_secs());
}
pub fn update_name(&mut self, name: String) {
self.name = name;
self.updated_at = Some(now_secs());
}
}
@@ -455,6 +471,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: true,
is_cloud_derived: false,
geo_country: None,
@@ -646,6 +663,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: true,
geo_country: Some(country),
@@ -710,6 +728,7 @@ impl ProxyManager {
&proxy.geo_isp,
);
proxy.updated_at = Some(now_secs());
proxy.proxy_settings.username = Some(geo_username);
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
@@ -3154,6 +3173,7 @@ mod tests {
},
sync_enabled: false,
last_sync: None,
updated_at: None,
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: Some("US".to_string()),
-1
View File
@@ -28,7 +28,6 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
{
match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(),
"donut-daemon" => "donut-daemon.exe".to_string(),
_ => String::new(),
}
}
+49 -5
View File
@@ -1147,14 +1147,17 @@ pub async fn handle_proxy_connection(
}
}
let _ = handle_connect_from_buffer(
if let Err(e) = handle_connect_from_buffer(
stream,
full_request,
upstream_url,
bypass_matcher,
blocklist_matcher,
)
.await;
.await
{
log::warn!("CONNECT tunnel ended with error: {e}");
}
return;
}
@@ -1449,6 +1452,13 @@ async fn handle_connect_from_buffer(
tracker.record_request(&domain, 0, 0);
}
log::info!(
"CONNECT {}:{} (upstream={})",
target_host,
target_port,
upstream_url.as_deref().unwrap_or("DIRECT")
);
// Connect to target (directly or via upstream proxy).
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
// Shadowsocks) share the same bidirectional-copy tunnel code below.
@@ -1503,12 +1513,46 @@ async fn handle_connect_from_buffer(
let mut buffer = [0u8; 4096];
let n = proxy_stream.read(&mut buffer).await?;
let response = String::from_utf8_lossy(&buffer[..n]);
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
let status_line = response_full.lines().next().unwrap_or("").to_string();
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
if !response_full.starts_with("HTTP/1.1 200")
&& !response_full.starts_with("HTTP/1.0 200")
{
log::warn!(
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
return Err(format!("Upstream proxy CONNECT failed: {response_full}").into());
}
// Detect the buffer-drop race where the upstream returned the
// 200 response coalesced with destination bytes — those bytes
// would otherwise be silently discarded and the browser would
// see a TLS stream missing its first record.
let header_end_in_buffer = response_full.find("\r\n\r\n").map(|i| i + 4);
if let Some(end) = header_end_in_buffer {
if end < n {
log::warn!(
"Upstream CONNECT response coalesced {} byte(s) of payload — these would be dropped without forwarding",
n - end
);
}
}
log::info!(
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
Box::new(proxy_stream)
}
"socks4" | "socks5" => {
+26 -62
View File
@@ -50,12 +50,12 @@ pub struct AppSettings {
#[serde(default)]
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
#[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
#[serde(default)]
pub disable_auto_updates: bool,
/// When true, the decrypted in-RAM copy of a password-protected profile is
/// preserved between launches for faster subsequent startups. The on-disk
@@ -93,9 +93,9 @@ impl Default for AppSettings {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
}
@@ -183,17 +183,6 @@ impl SettingsManager {
Ok(())
}
pub fn should_show_launch_on_login_prompt(&self) -> Result<bool, Box<dyn std::error::Error>> {
// Daemon is currently disabled, never show this prompt
Ok(false)
}
pub fn decline_launch_on_login(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.launch_on_login_declined = true;
self.save_settings(&settings)
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
@@ -795,7 +784,6 @@ pub async fn save_app_settings(
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.launch_on_login_declined = current.launch_on_login_declined;
}
}
@@ -919,28 +907,6 @@ pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), Stri
Ok(())
}
#[tauri::command]
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
let manager = SettingsManager::instance();
manager
.should_show_launch_on_login_prompt()
.map_err(|e| format!("Failed to check launch on login prompt setting: {e}"))
}
#[tauri::command]
pub async fn enable_launch_on_login() -> Result<(), String> {
crate::daemon::autostart::enable_autostart()
.map_err(|e| format!("Failed to enable autostart: {e}"))
}
#[tauri::command]
pub async fn decline_launch_on_login() -> Result<(), String> {
let manager = SettingsManager::instance();
manager
.decline_launch_on_login()
.map_err(|e| format!("Failed to decline launch on login: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::instance();
@@ -1047,6 +1013,27 @@ pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
Ok(settings.window_resize_warning_dismissed)
}
#[tauri::command]
pub async fn get_onboarding_completed() -> Result<bool, String> {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
Ok(settings.onboarding_completed)
}
#[tauri::command]
pub async fn complete_onboarding() -> Result<(), String> {
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
settings.onboarding_completed = true;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
@@ -1182,9 +1169,9 @@ mod tests {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
};
@@ -1247,29 +1234,6 @@ mod tests {
);
}
#[test]
fn test_should_show_launch_on_login_prompt() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.should_show_launch_on_login_prompt();
assert!(result.is_ok(), "Should not fail");
let _should_show = result.unwrap();
}
#[test]
fn test_decline_launch_on_login() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings = manager.load_settings().unwrap();
assert!(!settings.launch_on_login_declined);
manager.decline_launch_on_login().unwrap();
let settings = manager.load_settings().unwrap();
assert!(settings.launch_on_login_declined);
}
#[test]
fn test_load_corrupted_settings_file() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
+37
View File
@@ -49,6 +49,21 @@ impl SyncClient {
&self,
key: &str,
content_type: Option<&str>,
) -> SyncResult<PresignUploadResponse> {
self
.presign_upload_with_metadata(key, content_type, None)
.await
}
/// Presign an upload, asking the server to sign `metadata` into the object as
/// `x-amz-meta-*`. The response echoes the metadata the server actually signed
/// (empty/None on older servers); the caller must send exactly that back on
/// the PUT via `upload_bytes_with_metadata`.
pub async fn presign_upload_with_metadata(
&self,
key: &str,
content_type: Option<&str>,
metadata: Option<std::collections::HashMap<String, String>>,
) -> SyncResult<PresignUploadResponse> {
let response = self
.client
@@ -58,6 +73,7 @@ impl SyncClient {
key: key.to_string(),
content_type: content_type.map(|s| s.to_string()),
expires_in: Some(3600),
metadata,
})
.send()
.await
@@ -186,6 +202,21 @@ impl SyncClient {
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
) -> SyncResult<()> {
self
.upload_bytes_with_metadata(presigned_url, data, content_type, None)
.await
}
/// PUT to a presigned URL, sending `metadata` as `x-amz-meta-*` headers. These
/// MUST be exactly the metadata the presign signed (from
/// `PresignUploadResponse::metadata`) or S3 rejects the request.
pub async fn upload_bytes_with_metadata(
&self,
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
metadata: Option<&std::collections::HashMap<String, String>>,
) -> SyncResult<()> {
let mut req = self
.client
@@ -197,6 +228,12 @@ impl SyncClient {
req = req.header("Content-Type", ct);
}
if let Some(meta) = metadata {
for (k, v) in meta {
req = req.header(format!("x-amz-meta-{k}"), v);
}
}
let response = req
.send()
.await
+337 -270
View File
@@ -10,11 +10,53 @@ use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore};
/// S3 object-metadata key (stored as `x-amz-meta-updated-at`) holding an
/// entity's user-edit timestamp in unix seconds. Used to resolve sync conflicts
/// (last-write-wins) from a HEAD request without downloading the object body.
const UPDATED_AT_META_KEY: &str = "updated-at";
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
}
fn register_sync_cancel(profile_id: &str) -> Arc<AtomicBool> {
let mut map = SYNC_CANCEL_FLAGS.lock().unwrap();
let flag = Arc::new(AtomicBool::new(false));
map.insert(profile_id.to_string(), flag.clone());
flag
}
fn clear_sync_cancel(profile_id: &str) {
SYNC_CANCEL_FLAGS.lock().unwrap().remove(profile_id);
}
pub fn request_sync_cancel(profile_id: &str) -> bool {
if let Some(flag) = SYNC_CANCEL_FLAGS.lock().unwrap().get(profile_id) {
flag.store(true, Ordering::SeqCst);
true
} else {
false
}
}
struct SyncCancelGuard(String);
impl Drop for SyncCancelGuard {
fn drop(&mut self) {
clear_sync_cancel(&self.0);
}
}
#[tauri::command]
pub async fn cancel_profile_sync(profile_id: String) -> Result<bool, String> {
Ok(request_sync_cancel(&profile_id))
}
/// Upload/download concurrency limit
const SYNC_CONCURRENCY: usize = 32;
@@ -321,6 +363,67 @@ impl SyncEngine {
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
}
/// Resolve a remote config object's user-edit timestamp (`updated_at`) for
/// conflict resolution. Prefers the value from S3 object metadata returned by
/// the HEAD (`stat`) — no body transfer. Falls back to downloading and
/// decrypting the small JSON body and reading its embedded `updated_at` (for
/// older self-hosted servers that don't surface metadata). Legacy objects with
/// neither resolve to 0, so any real local edit (`updated_at` > 0) wins.
async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
if let Some(meta) = &stat.metadata {
if let Some(v) = meta
.get(UPDATED_AT_META_KEY)
.and_then(|s| s.parse::<u64>().ok())
{
return v;
}
}
// Fallback: read updated_at from the (small) JSON body.
if let Ok(presign) = self.client.presign_download(remote_key).await {
if let Ok(raw) = self.client.download_bytes(&presign.url).await {
if let Ok(data) = encryption::maybe_unseal_after_download(&raw) {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(u) = val.get("updated_at").and_then(|x| x.as_u64()) {
return u;
}
}
}
}
}
0
}
/// Upload a small config JSON blob (proxy/vpn/group/extension/extension-group/
/// profile metadata), signing its `updated_at` into S3 object metadata so
/// future reconciles can compare via HEAD without downloading the body. The
/// body is sealed (E2E) exactly as before; only a plaintext unix timestamp
/// lives in the object metadata.
async fn upload_config_json(
&self,
remote_key: &str,
json: &str,
updated_at: u64,
) -> SyncResult<()> {
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal config: {e}")))?;
let mut meta = HashMap::new();
meta.insert(UPDATED_AT_META_KEY.to_string(), updated_at.to_string());
let presign = self
.client
.presign_upload_with_metadata(remote_key, Some(content_type), Some(meta))
.await?;
self
.client
.upload_bytes_with_metadata(
&presign.url,
&payload,
Some(content_type),
presign.metadata.as_ref(),
)
.await?;
Ok(())
}
pub async fn sync_profile(
&self,
app_handle: &tauri::AppHandle,
@@ -391,6 +494,9 @@ impl SyncEngine {
let profile_dir = profiles_dir.join(profile.id.to_string());
let profile_id = profile.id.to_string();
let cancel_flag = register_sync_cancel(&profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.clone());
// Determine team key prefix for team profiles
let key_prefix = Self::get_team_key_prefix(profile).await;
@@ -514,10 +620,16 @@ impl SyncEngine {
&diff.files_to_upload,
encryption_key.as_ref(),
&key_prefix,
&cancel_flag,
)
.await?;
}
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after uploads", profile_id);
return Err(SyncError::Cancelled);
}
// Perform downloads
if !diff.files_to_download.is_empty() {
self
@@ -529,10 +641,16 @@ impl SyncEngine {
&diff.files_to_download,
encryption_key.as_ref(),
&key_prefix,
&cancel_flag,
)
.await?;
}
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after downloads", profile_id);
return Err(SyncError::Cancelled);
}
// Delete local files that don't exist remotely (when remote is newer)
for path in &diff.files_to_delete_local {
let file_path = profile_dir.join(path);
@@ -823,6 +941,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -930,6 +1049,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
if cancel_flag.load(Ordering::Relaxed) {
log::info!(
"Upload cancelled for profile {} before scheduling more files",
profile_id_owned
);
break;
}
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone();
@@ -958,6 +1084,7 @@ impl SyncEngine {
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
let content_type = mime_guess::from_path(&file.path)
.first()
.map(|m| m.to_string());
@@ -965,6 +1092,10 @@ impl SyncEngine {
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
let data = match fs::read(&file_path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
@@ -1095,6 +1226,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -1194,6 +1326,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
if cancel_flag.load(Ordering::Relaxed) {
log::info!(
"Download cancelled for profile {} before scheduling more files",
profile_id_owned
);
break;
}
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone();
@@ -1222,13 +1361,21 @@ impl SyncEngine {
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
// Retry loop for network downloads
let mut last_err = String::new();
for attempt in 0..MAX_FILE_RETRIES {
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
match client.download_bytes(&url).await {
Ok(data) => {
let write_data = if let Some(ref key) = enc_key {
@@ -1350,21 +1497,13 @@ impl SyncEngine {
match (local_proxy, stat.exists) {
(Some(proxy), true) => {
// Both exist - compare timestamps
let local_updated = proxy.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = proxy.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_proxy(proxy_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_proxy(&proxy).await?;
}
}
@@ -1397,17 +1536,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
let remote_key = format!("proxies/{}.json", proxy.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_proxy.updated_at.unwrap_or(0))
.await?;
// Update local proxy with new last_sync (always write plaintext locally)
@@ -1498,21 +1629,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
// Both exist - compare timestamps
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_group(&group).await?;
}
}
@@ -1545,17 +1668,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_group)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
let remote_key = format!("groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at.unwrap_or(0))
.await?;
// Update local group with new last_sync
@@ -1714,18 +1829,13 @@ impl SyncEngine {
match (local_vpn, stat.exists) {
(Some(vpn), true) => {
let local_updated = vpn.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = vpn.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_vpn(vpn_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_vpn(&vpn).await?;
}
}
@@ -1755,17 +1865,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
let remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_vpn.updated_at.unwrap_or(0))
.await?;
// Update local VPN with new last_sync
@@ -1865,18 +1967,13 @@ impl SyncEngine {
match (local_ext, stat.exists) {
(Some(ext), true) => {
let local_updated = ext.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = ext.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension(ext_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension(&ext).await?;
}
}
@@ -1906,17 +2003,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_ext)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
let remote_key = format!("extensions/{}.json", ext.id);
let presign = self
.client
.presign_upload(&remote_key, Some(meta_content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
.upload_config_json(&remote_key, &json, updated_ext.updated_at)
.await?;
// Also upload the extension file data — encrypted as a sealed envelope
@@ -2070,18 +2159,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension_group(&group).await?;
}
}
@@ -2115,17 +2199,9 @@ impl SyncEngine {
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
})?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
let remote_key = format!("extension_groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at)
.await?;
// Update local group with new last_sync
@@ -2361,6 +2437,8 @@ impl SyncEngine {
);
}
if !manifest.files.is_empty() {
let cancel_flag = register_sync_cancel(profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
self
.download_profile_files(
app_handle,
@@ -2370,6 +2448,7 @@ impl SyncEngine {
&manifest.files,
encryption_key.as_ref(),
key_prefix,
&cancel_flag,
)
.await?;
}
@@ -2506,8 +2585,46 @@ impl SyncEngine {
profiles_to_check.len()
);
// For each remote profile, check if it exists locally and download if missing
// For each remote profile, check if it exists locally and download if missing.
// Skip any profile that has a tombstone — a leftover manifest under a
// tombstoned id means delete_prefix raced or partially failed, and
// re-downloading it here is what surfaced the "Browsing keeps re-syncing"
// bug after a delete.
for (profile_id, key_prefix) in &profiles_to_check {
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
let has_personal_tombstone = matches!(
self.client.stat(&personal_tombstone).await,
Ok(stat) if stat.exists
);
let team_tombstone_key = if key_prefix.is_empty() {
None
} else {
Some(format!(
"{}tombstones/profiles/{}.json",
key_prefix, profile_id
))
};
let has_team_tombstone = if let Some(ref tk) = team_tombstone_key {
matches!(self.client.stat(tk).await, Ok(stat) if stat.exists)
} else {
false
};
if has_personal_tombstone || has_team_tombstone {
log::info!(
"Skipping download of tombstoned profile {} (clearing leftover remote files)",
profile_id
);
let prefix = format!("{}profiles/{}/", key_prefix, profile_id);
if let Err(e) = self.client.delete_prefix(&prefix, None).await {
log::warn!(
"Failed to clear stale remote files for tombstoned profile {}: {}",
profile_id,
e
);
}
continue;
}
match self
.download_profile_if_missing(app_handle, profile_id, key_prefix)
.await
@@ -2571,6 +2688,24 @@ impl SyncEngine {
};
if has_personal_tombstone || has_team_tombstone {
// Originator guard: re-read the profile right before deleting. If the
// local user disabled sync between the snapshot above and this stat
// call, they're the one who wrote this tombstone — keep their local
// copy. Tombstones must delete remote-originated changes, never the
// sender's own data. (Caused mass local deletion in v0.24.x.)
let still_sync_enabled = profile_manager
.list_profiles()
.unwrap_or_default()
.iter()
.find(|p| p.id.to_string() == *pid)
.is_some_and(|p| p.is_sync_enabled());
if !still_sync_enabled {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy (originating device)",
pid
);
continue;
}
log::info!(
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
pid
@@ -2948,6 +3083,11 @@ pub async fn set_profile_sync_mode(
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
}
let enabling_now = new_mode != SyncMode::Disabled;
if enabling_now && profile.process_id.is_some() {
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
}
if profile.ephemeral {
return Err("Cannot enable sync for an ephemeral profile".to_string());
}
@@ -3029,6 +3169,22 @@ pub async fn set_profile_sync_mode(
let _ = events::emit("profiles-changed", ());
// When (re-)enabling sync, clear any stale tombstone from a previous
// disable on this device. Otherwise the next reconcile on another
// device — or even a race on this one — would see the tombstone and
// delete the freshly re-uploaded data.
if enabling {
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
let key_prefix = SyncEngine::get_team_key_prefix(&profile).await;
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
let _ = engine.client.delete(&personal_tombstone, None).await;
if !key_prefix.is_empty() {
let team_tombstone = format!("{}tombstones/profiles/{}.json", key_prefix, profile_id);
let _ = engine.client.delete(&team_tombstone, None).await;
}
}
}
if enabling {
let is_running = profile.process_id.is_some();
@@ -3084,28 +3240,25 @@ pub async fn set_profile_sync_mode(
log::warn!("Scheduler not initialized, sync will not start");
}
} else {
// Delete remote data when disabling sync
// Delete remote data when disabling sync. Awaited (not spawned) so the
// tombstone write completes before this command returns. A previous
// tokio::spawn here allowed the tombstone-write to land *after* a fast
// user-triggered re-enable's tombstone-clear, re-introducing the
// tombstone and tripping the reconcile-pass deletion of a profile the
// user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20).
if old_mode != SyncMode::Disabled {
let profile_id_clone = profile_id.clone();
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
match SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
log::warn!(
"Failed to delete profile {} from sync: {}",
profile_id_clone,
e
);
} else {
log::info!("Profile {} deleted from sync service", profile_id_clone);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id).await {
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
} else {
log::info!("Profile {} deleted from sync service", profile_id);
}
}
});
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
}
}
}
let _ = events::emit(
@@ -3183,6 +3336,28 @@ pub async fn sync_profile(app_handle: tauri::AppHandle, profile_id: String) -> R
trigger_sync_for_profile(app_handle, profile_id).await
}
/// Ensure the device has either a cloud login or a self-hosted server URL + token.
/// Returns a JSON error code string consumable by the frontend translator.
async fn ensure_sync_configured(app_handle: &tauri::AppHandle) -> Result<(), String> {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if cloud_logged_in {
return Ok(());
}
let manager = SettingsManager::instance();
let settings = manager.load_settings().map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
if settings.sync_server_url.is_none() {
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
}
let token = manager.get_sync_token(app_handle).await.ok().flatten();
if token.is_none() {
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
}
Ok(())
}
pub async fn trigger_sync_for_profile(
app_handle: tauri::AppHandle,
profile_id: String,
@@ -3222,43 +3397,29 @@ pub async fn set_proxy_sync_enabled(
let proxy = proxies
.iter()
.find(|p| p.id == proxy_id)
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
.ok_or_else(|| serde_json::json!({ "code": "PROXY_NOT_FOUND" }).to_string())?;
// Block modifying sync for cloud-managed proxies
if proxy.is_cloud_managed {
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
return Err(serde_json::json!({ "code": "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" }).to_string());
}
// If disabling, check if proxy is used by any synced profile
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let new_last_sync = if enabled { proxy.last_sync } else { None };
proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?;
proxy_manager
.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } }).to_string()
})?;
let _ = events::emit("stored-proxies-changed", ());
@@ -3299,36 +3460,18 @@ pub async fn set_group_sync_enabled(
groups
.iter()
.find(|g| g.id == group_id)
.ok_or_else(|| format!("Group with ID '{group_id}' not found"))?
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?
.clone()
};
// If disabling, check if group is used by any synced profile
if !enabled && is_group_used_by_synced_profile(&group_id) {
return Err("Sync cannot be disabled while this group is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_group = group.clone();
@@ -3341,7 +3484,10 @@ pub async fn set_group_sync_enabled(
{
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
if let Err(e) = group_manager.update_group_internal(&updated_group) {
return Err(format!("Failed to update group: {e}"));
return Err(
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string(),
);
}
}
@@ -3392,35 +3538,17 @@ pub async fn set_vpn_sync_enabled(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.load_config(&vpn_id)
.map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "VPN_NOT_FOUND" }).to_string())?
};
// If disabling, check if VPN is used by any synced profile
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let last_sync = if enabled { vpn.last_sync } else { None };
@@ -3429,7 +3557,10 @@ pub async fn set_vpn_sync_enabled(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.update_sync_fields(&vpn_id, enabled, last_sync)
.map_err(|e| format!("Failed to update VPN sync: {e}"))?;
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("vpn-configs-changed", ());
@@ -3526,48 +3657,10 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all eligible profiles. Without this the user would see
// groups/proxies/vpns syncing while their profiles stay local-only — the
// long-standing source of issue #352. Encrypted mode wins when an E2E
// password is already configured; otherwise we fall back to plain Regular.
{
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
);
}
}
}
// Intentionally excludes profiles: enabling profile sync uploads the entire
// browser data dir per profile, which is destructive if the user expected
// an opt-in. Profile sync stays under explicit per-profile control via
// set_profile_sync_mode. This command only touches metadata-sized entities.
// Enable sync for all unsynced proxies
{
@@ -3664,26 +3757,11 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_extension(&extension_id)
.map_err(|e| format!("Extension with ID '{extension_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "EXTENSION_NOT_FOUND" }).to_string())?
};
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_ext = ext;
@@ -3696,7 +3774,10 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.update_extension_internal(&updated_ext)
.map_err(|e| format!("Failed to update extension sync: {e}"))?;
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("extensions-changed", ());
@@ -3720,26 +3801,11 @@ pub async fn set_extension_group_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_group(&extension_group_id)
.map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "EXTENSION_GROUP_NOT_FOUND" }).to_string())?
};
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_group = group;
@@ -3750,9 +3816,10 @@ pub async fn set_extension_group_sync_enabled(
{
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.update_group_internal(&updated_group)
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
manager.update_group_internal(&updated_group).map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("extensions-changed", ());
+13 -3
View File
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/startupCache/**",
"**/safebrowsing/**",
"**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/sessionstore-backups/**",
"**/sessions/**",
"**/serviceworker.txt",
"**/AlternateServices.bin",
"**/SiteSecurityServiceState.bin",
"**/favicons.sqlite",
"**/favicons.sqlite-*",
"**/crashes/**",
"**/minidumps/**",
"*.tmp",
@@ -52,9 +62,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*",
"**/.DS_Store",
".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's
// fingerprint. Each device decides its own refresh cadence, so syncing
// this would cause one device's refresh to silence others.
// Orphaned local-only marker from earlier rollover-based fingerprint
// regeneration. Keep excluding it so any markers left on disk from
// prior builds never get uploaded.
".last-fp-refresh",
];
+3 -3
View File
@@ -11,9 +11,9 @@ pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
+10 -3
View File
@@ -716,16 +716,18 @@ impl SyncScheduler {
match entity_type.as_str() {
"profile" => {
let profile_manager = ProfileManager::instance();
let has_profile = {
let local_sync_enabled = {
if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
profile_uuid
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
.is_some_and(|p| p.is_sync_enabled())
} else {
false
}
};
if has_profile {
if local_sync_enabled {
log::info!(
"Profile {} was deleted remotely, deleting locally",
entity_id
@@ -733,6 +735,11 @@ impl SyncScheduler {
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
}
} else {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
entity_id
);
}
}
"proxy" => {
+16
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatRequest {
@@ -11,6 +12,11 @@ pub struct StatResponse {
#[serde(rename = "lastModified")]
pub last_modified: Option<String>,
pub size: Option<u64>,
/// User-defined S3 object metadata (`x-amz-meta-*`), lowercased keys without
/// the prefix. `None` from older servers that don't return it. Used to read
/// `updated-at` for sync conflict resolution without downloading the body.
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
pub content_type: Option<String>,
#[serde(rename = "expiresIn")]
pub expires_in: Option<u64>,
/// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`).
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
pub url: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
/// The metadata the server actually signed into the URL. The client must send
/// exactly these as `x-amz-meta-*` headers on the PUT or S3 rejects it. `None`
/// from older servers → client sends no metadata headers (body-GET fallback).
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -166,6 +180,7 @@ pub enum SyncError {
SerializationError(String),
ConflictError(String),
InvalidData(String),
Cancelled,
}
impl std::fmt::Display for SyncError {
@@ -178,6 +193,7 @@ impl std::fmt::Display for SyncError {
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
}
}
}
+4
View File
@@ -52,6 +52,10 @@ pub struct VpnConfig {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on config edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
/// Parsed WireGuard configuration
+12
View File
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
sync_enabled: bool,
#[serde(default)]
last_sync: Option<u64>,
#[serde(default)]
updated_at: Option<u64>,
}
/// VPN storage manager with encryption
@@ -247,6 +249,7 @@ impl VpnStorage {
last_used: config.last_used,
sync_enabled: config.sync_enabled,
last_sync: config.last_sync,
updated_at: config.updated_at,
};
// Update existing or add new
@@ -280,6 +283,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
}
@@ -300,6 +304,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
.collect(),
)
@@ -356,6 +361,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -367,6 +373,7 @@ impl VpnStorage {
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
let mut config = self.load_config(id)?;
config.name = new_name.to_string();
config.updated_at = Some(crate::proxy_manager::now_secs());
self.save_config(&config)?;
Ok(config)
}
@@ -420,6 +427,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -463,6 +471,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -487,6 +496,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let config2 = VpnConfig {
@@ -498,6 +508,7 @@ mod tests {
last_used: Some(3000),
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config1).unwrap();
@@ -524,6 +535,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
+34 -4
View File
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
/// The fingerprint Wayfern actually applied, echoed back by
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
/// (e.g. when the stored one targets an older browser version). Internal
/// only — the caller persists it to the profile; never sent to the frontend.
#[serde(default, skip_serializing)]
pub used_fingerprint: Option<String>,
}
struct WayfernInstance {
@@ -703,6 +709,7 @@ impl WayfernManager {
log::info!("Found {} page targets", page_targets.len());
// Apply fingerprint if configured
let mut used_fingerprint: Option<String> = None;
if let Some(fingerprint_json) = &config.fingerprint {
log::info!(
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
@@ -781,10 +788,30 @@ impl WayfernManager {
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
),
Ok(result) => {
log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
);
// Wayfern.setFingerprint echoes back the fingerprint it actually
// used, which may be UPGRADED from what we sent (e.g. when the
// stored fingerprint targets an older browser version). Capture
// it once, from the first target that succeeds, so the caller can
// persist the upgraded value to the profile.
if used_fingerprint.is_none() {
// getFingerprint/setFingerprint wrap the object as
// { fingerprint: {...} }; tolerate a bare object too.
let fp = result.get("fingerprint").cloned().unwrap_or(result);
if fp.is_object() {
match serde_json::to_string(&Self::normalize_fingerprint(fp)) {
Ok(s) => used_fingerprint = Some(s),
Err(e) => {
log::warn!("Failed to serialize used fingerprint: {e}")
}
}
}
}
}
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
}
}
@@ -849,6 +876,7 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
used_fingerprint,
})
}
@@ -990,6 +1018,7 @@ impl WayfernManager {
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
used_fingerprint: None,
});
} else {
log::info!(
@@ -1032,6 +1061,7 @@ impl WayfernManager {
profilePath: Some(found_profile_path),
url: None,
cdp_port,
used_fingerprint: None,
});
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.2",
"version": "0.24.4",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -19,7 +19,7 @@
"active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"],
"externalBin": ["binaries/donut-proxy"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -42,11 +42,11 @@
"linux": {
"deb": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo3"]
"depends": ["xdg-utils", "libxdo3", "libayatana-appindicator3-1"]
},
"rpm": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo"]
"depends": ["xdg-utils", "libxdo", "libayatana-appindicator-gtk3"]
},
"appimage": {
"files": {
+4
View File
@@ -135,6 +135,7 @@ fn test_vpn_storage_save_and_load() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let save_result = storage.save_config(&config);
@@ -174,6 +175,7 @@ fn test_vpn_storage_list() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
}
@@ -201,6 +203,7 @@ fn test_vpn_storage_delete() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -489,6 +492,7 @@ fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> Vp
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
}
}
+299 -42
View File
@@ -3,11 +3,14 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useOnborda } from "onborda";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
import { CommandPalette } from "@/components/command-palette";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
@@ -21,7 +24,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
@@ -34,10 +37,13 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { type AppPage, RailNav } from "@/components/rail-nav";
import { SettingsDialog } from "@/components/settings-dialog";
import { ShortcutsPage } from "@/components/shortcuts-page";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { ThankYouDialog } from "@/components/thank-you-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WelcomeDialog } from "@/components/welcome-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
@@ -53,6 +59,16 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import {
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
} from "@/lib/onboarding-signal";
import {
matchesGroupDigit,
matchesShortcut,
SHORTCUTS,
type ShortcutId,
} from "@/lib/shortcuts";
import {
dismissToast,
showErrorToast,
@@ -87,6 +103,95 @@ export default function Home() {
error: profilesError,
} = useProfileEvents();
// First-run onboarding tour (Onborda).
const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } =
useOnborda();
const onboardingHandledRef = useRef(false);
const [welcomeOpen, setWelcomeOpen] = useState(false);
const [thankYouOpen, setThankYouOpen] = useState(false);
// null = onboarding decision pending; false = not a first-run onboarding (run
// the normal permission checks); true = first-run onboarding, so the welcome
// flow drives permissions and the standalone permission dialog is suppressed.
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
null,
);
// Welcome flow finished. Existing-profile users are done after the welcome +
// commercial-use steps; users with no profile yet continue into the in-app
// product tour that walks them through creating their first profile.
const handleWelcomeComplete = useCallback(() => {
setWelcomeOpen(false);
setFirstRunOnboarding(false);
if (profiles.length === 0) {
startOnborda(ONBOARDING_TOUR);
}
}, [startOnborda, profiles.length]);
// The product tour finished (user clicked "Finish", not "Skip") → celebrate.
useEffect(() => {
const handler = () => setThankYouOpen(true);
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
return () =>
window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
}, []);
// Suppress the global browser-download toasts while onboarding (welcome or
// tour) is active — the welcome dialog shows setup progress itself.
useEffect(() => {
setOnboardingActive(welcomeOpen || isOnbordaVisible);
}, [welcomeOpen, isOnbordaVisible]);
// While the tour is visible, keep the body pinned to the left. Onborda calls
// scrollIntoView({ inline: "center" }) on the highlighted element; because the
// body is overflow-hidden it can still be scrolled programmatically, which
// would shove the whole app (rail and all) sideways with no way to scroll
// back. The profile table keeps its own scroll container, untouched here.
useEffect(() => {
if (!isOnbordaVisible) return;
const pin = () => {
if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0;
if (document.documentElement.scrollLeft !== 0)
document.documentElement.scrollLeft = 0;
};
pin();
window.addEventListener("scroll", pin, true);
return () => window.removeEventListener("scroll", pin, true);
}, [isOnbordaVisible]);
// On the very first launch, always show the welcome + commercial-use steps
// (one-shot: the backend flag is set immediately so it can't trigger again).
// The welcome dialog itself decides whether to continue into the browser
// download + profile-creation flow — only when the user has no profile yet.
useEffect(() => {
if (profilesLoading || onboardingHandledRef.current) return;
onboardingHandledRef.current = true;
void (async () => {
try {
const completed = await invoke<boolean>("get_onboarding_completed");
if (completed) {
setFirstRunOnboarding(false);
return;
}
await invoke("complete_onboarding");
setFirstRunOnboarding(true);
setWelcomeOpen(true);
} catch (err) {
console.error("Onboarding init failed:", err);
setFirstRunOnboarding(false);
}
})();
}, [profilesLoading]);
// Advance from the "create a profile" step to the "DNS blocking" step as soon
// as the user's first profile exists (its DNS dropdown is now in the DOM).
useEffect(() => {
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
// Small delay so the new profile row (and its DNS dropdown target) has
// mounted before Onborda re-points at it.
setCurrentStep(1, 300);
}
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
const {
groups: groupsData,
isLoading: groupsLoading,
@@ -149,6 +254,11 @@ export default function Home() {
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
"proxies" | "vpns"
>("proxies");
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
useState<"extensions" | "groups">("extensions");
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
"api" | "mcp"
>("api");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -201,8 +311,6 @@ export default function Home() {
const [passwordDialogMode, setPasswordDialogMode] =
useState<PasswordDialogMode>("set");
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
useState<string | undefined>(undefined);
@@ -221,6 +329,11 @@ export default function Home() {
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] =
useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
useState<BrowserProfile | null>(null);
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions();
@@ -273,9 +386,134 @@ export default function Home() {
case "account":
setAccountDialogOpen(true);
break;
case "shortcuts":
// Plain page render — nothing else to open.
break;
}
}, []);
const runShortcut = useCallback(
(id: ShortcutId) => {
switch (id) {
case "openPalette":
setCommandPaletteOpen(true);
break;
case "openShortcuts":
handleRailNavigate("shortcuts");
break;
case "importProfile":
handleRailNavigate("import");
break;
case "goProfiles":
handleRailNavigate("profiles");
break;
case "goProxies": {
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
// initialTab, so we just pick the right destination.
if (currentPage === "proxies") {
handleRailNavigate("vpns");
} else if (currentPage === "vpns") {
handleRailNavigate("proxies");
} else {
handleRailNavigate(
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
);
}
break;
}
case "goExtensions": {
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
if (currentPage === "extensions") {
setExtensionManagementInitialTab((cur) =>
cur === "extensions" ? "groups" : "extensions",
);
} else {
handleRailNavigate("extensions");
}
break;
}
case "goGroups":
handleRailNavigate("groups");
break;
case "goIntegrations": {
// Mod+I: flip api↔mcp tab when already on integrations.
if (currentPage === "integrations") {
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
} else {
handleRailNavigate("integrations");
}
break;
}
case "goAccount":
handleRailNavigate("account");
break;
case "goSettings":
handleRailNavigate("settings");
break;
}
},
[handleRailNavigate, currentPage, proxyManagementInitialTab],
);
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
const orderedGroupTargets = useMemo(
() => [
{ id: "__all__", name: t("rail.profiles") },
...groupsData.map((g) => ({ id: g.id, name: g.name })),
],
[groupsData, t],
);
const selectGroupByDigit = useCallback(
(digit: number) => {
const target = orderedGroupTargets[digit - 1];
if (!target) return;
handleRailNavigate("profiles");
handleSelectGroup(target.id);
},
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
);
useEffect(() => {
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const isTyping =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target?.isContentEditable === true;
const digit = matchesGroupDigit(e);
if (digit !== null) {
if (isTyping) return;
if (digit - 1 >= orderedGroupTargets.length) return;
e.preventDefault();
selectGroupByDigit(digit);
return;
}
for (const s of SHORTCUTS) {
if (!matchesShortcut(s, e)) continue;
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
return;
}
e.preventDefault();
runShortcut(s.id);
return;
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
// Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => {
try {
@@ -402,24 +640,6 @@ export default function Home() {
}
}, [handleUrlOpen, hasCheckedStartupUrl]);
const checkStartupPrompt = useCallback(async () => {
// Only check once during app startup to prevent reopening after dismissing notifications
if (hasCheckedStartupPrompt) return;
try {
const shouldShow = await invoke<boolean>(
"should_show_launch_on_login_prompt",
);
if (shouldShow) {
setLaunchOnLoginDialogOpen(true);
}
} catch (error) {
console.error("Failed to check startup prompt:", error);
} finally {
setHasCheckedStartupPrompt(true);
}
}, [hasCheckedStartupPrompt]);
// Handle profile errors from useProfileEvents hook
useEffect(() => {
if (profilesError) {
@@ -652,9 +872,12 @@ export default function Home() {
} catch (error) {
showErrorToast(
t("errors.createProfileFailed", {
error: error instanceof Error ? error.message : String(error),
error: translateBackendError(t, error),
}),
);
// Rethrow so the create dialog keeps itself open (its own handler
// skips closing on error), letting the user fix the proxy/VPN and retry.
throw error;
}
},
[selectedGroupId, t],
@@ -1031,7 +1254,7 @@ export default function Home() {
failed_count: payload.failed_count ?? 0,
phase: payload.phase,
},
{ id: toastId },
{ id: toastId, profileId: payload.profile_id },
);
}
});
@@ -1046,9 +1269,6 @@ export default function Home() {
}, [profiles, t]);
useEffect(() => {
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events and get cleanup function
const setupListeners = async () => {
const cleanup = await listenForUrlEvents();
@@ -1091,7 +1311,6 @@ export default function Home() {
};
}, [
checkForUpdates,
checkStartupPrompt,
listenForUrlEvents,
checkCurrentUrl,
checkMissingBinaries,
@@ -1193,11 +1412,13 @@ export default function Home() {
showToast({
id: "browser-support-ending-warning",
type: "error",
title: "Browser support ending soon",
description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`,
title: t("browserSupport.endingSoonTitle"),
description: t("browserSupport.endingSoonDescription", {
profiles: unsupportedNames,
}),
duration: 15000,
action: {
label: "Learn more",
label: t("common.buttons.learnMore"),
onClick: () => {
const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions",
@@ -1207,7 +1428,7 @@ export default function Home() {
},
});
}
}, [profiles]);
}, [profiles, t]);
// Re-check Wayfern terms when a browser download completes
useEffect(() => {
@@ -1228,12 +1449,14 @@ export default function Home() {
};
}, [checkTerms]);
// Check permissions when they are initialized
// Check permissions when they are initialized. During first-run onboarding
// the welcome flow requests permissions, so the standalone dialog is deferred
// until we know this isn't a first-run onboarding.
useEffect(() => {
if (isInitialized) {
if (isInitialized && firstRunOnboarding === false) {
checkAllPermissions();
}
}, [isInitialized, checkAllPermissions]);
}, [isInitialized, firstRunOnboarding, checkAllPermissions]);
// Check self-hosted sync config on mount and when cloud user changes
useEffect(() => {
@@ -1288,6 +1511,7 @@ export default function Home() {
return (
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<CloseConfirmDialog />
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
searchQuery={searchQuery}
@@ -1306,6 +1530,8 @@ export default function Home() {
{isLoading && groupsData.length === 0 ? null : null}
<ProfilesDataTable
profiles={filteredProfiles}
infoDialogProfile={profileInfoDialog}
onInfoDialogProfileChange={setProfileInfoDialog}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
@@ -1344,6 +1570,10 @@ export default function Home() {
</div>
)}
{currentPage === "shortcuts" && (
<ShortcutsPage groupTargets={orderedGroupTargets} />
)}
{settingsDialogOpen && (
<SettingsDialog
isOpen={settingsDialogOpen}
@@ -1368,6 +1598,7 @@ export default function Home() {
setCurrentPage("profiles");
}}
subPage={currentPage === "integrations"}
initialTab={integrationsInitialTab}
/>
)}
@@ -1404,6 +1635,7 @@ export default function Home() {
}}
limitedMode={false}
subPage={currentPage === "extensions"}
initialTab={extensionManagementInitialTab}
/>
)}
@@ -1447,6 +1679,29 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
/>
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
onAction={runShortcut}
groupTargets={orderedGroupTargets}
onSelectGroup={(id) => {
handleRailNavigate("profiles");
handleSelectGroup(id);
}}
profiles={profiles}
runningProfileIds={runningProfiles}
onLaunchProfile={(profile) => {
void launchProfile(profile);
}}
onKillProfile={(profile) => {
void handleKillProfile(profile);
}}
onShowProfileInfo={(profile) => {
handleRailNavigate("profiles");
setProfileInfoDialog(profile);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
@@ -1471,6 +1726,16 @@ export default function Home() {
onPermissionGranted={checkNextPermission}
/>
<WelcomeDialog
isOpen={welcomeOpen}
needsSetup={profiles.length === 0}
onComplete={handleWelcomeComplete}
/>
<ThankYouDialog
isOpen={thankYouOpen}
onClose={() => setThankYouOpen(false)}
/>
<CloneProfileDialog
isOpen={!!cloneProfile}
onClose={() => {
@@ -1675,14 +1940,6 @@ export default function Home() {
onClose={checkTrialStatus}
/>
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
<LaunchOnLoginDialog
isOpen={launchOnLoginDialogOpen}
onClose={() => {
setLaunchOnLoginDialogOpen(false);
}}
/>
<WindowResizeWarningDialog
isOpen={windowResizeWarningOpen}
browserType={windowResizeWarningBrowserType}
+31
View File
@@ -280,9 +280,40 @@ export function AccountPage({
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
{typeof user.deviceOrdinal === "number" && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.device")}
</p>
<p className="mt-0.5">
{t("account.deviceOrdinal", {
ordinal: user.deviceOrdinal,
count: user.deviceCount ?? user.deviceOrdinal,
})}
</p>
</div>
)}
</div>
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
</p>
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
{t("account.automationActiveHere")}
</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
+1 -1
View File
@@ -37,7 +37,7 @@ export function AppUpdateToast({
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 size-5" />
<LuCheckCheck className="shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
+4 -1
View File
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { OnboardingProvider } from "@/components/onboarding-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -17,7 +18,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RippleButton } from "./ui/ripple";
export function CloseConfirmDialog() {
const { t, i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const unlistenPromise = listen("close-confirm-requested", () => {
setIsOpen(true);
});
return () => {
void unlistenPromise.then((u) => {
u();
});
};
}, []);
// The native tray menu is built in Rust and cannot read the active language,
// so push localized labels to it on mount and whenever the language changes.
useEffect(() => {
const syncTrayMenu = () => {
void invoke("update_tray_menu", {
showLabel: t("tray.show"),
quitLabel: t("tray.quit"),
}).catch(() => {
// Tray is desktop-only; ignore on platforms without one.
});
};
syncTrayMenu();
i18n.on("languageChanged", syncTrayMenu);
return () => {
i18n.off("languageChanged", syncTrayMenu);
};
}, [t, i18n]);
const handleMinimize = async () => {
setIsOpen(false);
try {
await invoke("hide_to_tray");
} catch (error) {
console.error("Failed to hide to tray:", error);
}
};
const handleQuit = async () => {
setIsOpen(false);
try {
await invoke("confirm_quit");
} catch (error) {
console.error("Failed to quit app:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("closeConfirm.title")}</DialogTitle>
<DialogDescription>{t("closeConfirm.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => {
void handleMinimize();
}}
>
{t("closeConfirm.minimize")}
</RippleButton>
<RippleButton
variant="destructive"
onClick={() => {
void handleQuit();
}}
>
{t("closeConfirm.quit")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+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>
);
}
+137 -41
View File
@@ -11,7 +11,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
@@ -307,6 +307,10 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
// Load downloaded versions for both anti-detect browsers up front so the
// selection-screen availability gate is accurate before either is picked.
void loadDownloadedVersions("wayfern");
void loadDownloadedVersions("camoufox");
// Load release types when a browser is selected
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
@@ -320,6 +324,7 @@ export function CreateProfileDialog({
isOpen,
loadSupportedBrowsers,
loadReleaseTypes,
loadDownloadedVersions,
checkAndDownloadGeoIPDatabase,
selectedBrowser,
]);
@@ -405,6 +410,7 @@ export function CreateProfileDialog({
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
@@ -585,7 +591,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
? t("createProfile.title")
@@ -618,23 +624,30 @@ export function CreateProfileDialog({
onClick={() => {
handleBrowserSelect("wayfern");
}}
disabled={!getCreatableVersion("wayfern")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
{isBrowserCurrentlyDownloading("wayfern") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.chromiumLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.chromiumSubtitle")}
{isBrowserCurrentlyDownloading("wayfern")
? t("createProfile.downloadingSubtitle")
: t("createProfile.chromiumSubtitle")}
</div>
</div>
</Button>
@@ -644,26 +657,41 @@ export function CreateProfileDialog({
onClick={() => {
handleBrowserSelect("camoufox");
}}
disabled={!getCreatableVersion("camoufox")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
{isBrowserCurrentlyDownloading("camoufox") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent =
getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.firefoxSubtitle")}
{isBrowserCurrentlyDownloading("camoufox")
? t("createProfile.downloadingSubtitle")
: t("createProfile.firefoxSubtitle")}
</div>
</div>
</Button>
{!getCreatableVersion("wayfern") &&
!getCreatableVersion("camoufox") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
</div>
</TabsContent>
@@ -867,7 +895,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
!getCreatableVersion("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
@@ -899,17 +927,53 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
isBrowserVersionAvailable("wayfern") && (
getCreatableVersion("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
getCreatableVersion("wayfern")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
getCreatableVersion("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("wayfern");
}}
isLoading={isBrowserCurrentlyDownloading(
"wayfern",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"wayfern",
)}
>
{isBrowserCurrentlyDownloading("wayfern")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
@@ -927,7 +991,7 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("wayfern")?.version
getCreatableVersion("wayfern")?.version
}
profileBrowser="wayfern"
/>
@@ -975,7 +1039,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
!getCreatableVersion("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
@@ -1007,17 +1071,53 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
getCreatableVersion("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
getCreatableVersion("camoufox")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
getCreatableVersion("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
@@ -1045,7 +1145,7 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("camoufox")?.version
getCreatableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
@@ -1077,7 +1177,7 @@ export function CreateProfileDialog({
size="sm"
variant="outline"
>
Retry
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
@@ -1086,7 +1186,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1122,18 +1222,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(
selectedBrowser,
) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1432,7 +1529,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
Fetching available versions...
{t("createProfile.version.fetching")}
</p>
</div>
)}
@@ -1458,7 +1555,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1494,16 +1591,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(selectedBrowser) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1701,7 +1797,7 @@ export function CreateProfileDialog({
</ScrollArea>
</Tabs>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<DialogFooter className="shrink-0 pt-4 border-t">
{currentStep === "browser-config" ? (
<>
<RippleButton variant="outline" onClick={handleBack}>
+11 -15
View File
@@ -174,42 +174,38 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
);
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
);
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
}
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
return <LuDownload className="shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -232,7 +228,7 @@ export function UnifiedToast(props: ToastProps) {
<button
type="button"
onClick={onCancel}
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={t("common.buttons.cancel")}
>
<LuX className="size-3" />
@@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
@@ -45,7 +46,7 @@ export function DeviceCodeVerifyDialog({
const handleOpenLogin = async () => {
setIsOpeningLogin(true);
try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
await openUrl(DEVICE_LINK_URL);
} catch (error) {
console.error("Failed to open login link:", error);
showErrorToast(String(error));
+40 -14
View File
@@ -73,6 +73,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { Extension, ExtensionGroup } from "@/types";
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
@@ -130,6 +131,8 @@ interface ExtensionManagementDialogProps {
onClose: () => void;
limitedMode: boolean;
subPage?: boolean;
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
initialTab?: "extensions" | "groups";
}
export function ExtensionManagementDialog({
@@ -137,6 +140,7 @@ export function ExtensionManagementDialog({
onClose,
limitedMode,
subPage,
initialTab = "extensions",
}: ExtensionManagementDialogProps) {
const { t } = useTranslation();
const [extensions, setExtensions] = useState<Extension[]>([]);
@@ -208,9 +212,10 @@ export function ExtensionManagementDialog({
Record<string, boolean>
>({});
// Tab
// Tab — keyed off `initialTab` so remounting the dialog with a new initial
// tab (e.g. via the Mod+E shortcut toggle) jumps to that tab.
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
"extensions",
initialTab,
);
const loadData = useCallback(async () => {
@@ -304,7 +309,11 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
} finally {
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
}
@@ -327,7 +336,11 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
} finally {
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
}
@@ -585,9 +598,15 @@ export function ExtensionManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
@@ -610,9 +629,15 @@ export function ExtensionManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
@@ -1104,10 +1129,10 @@ export function ExtensionManagementDialog({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
@@ -1120,6 +1145,7 @@ export function ExtensionManagementDialog({
)}
<AnimatedTabs
key={initialTab}
value={activeTab}
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
className="flex-1 min-h-0 flex flex-col"
+3 -3
View File
@@ -148,10 +148,10 @@ export function GroupBadges({
return (
<div className="relative mb-4">
{showLeftFade && (
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
)}
{showRightFade && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
)}
<div
ref={scrollContainerRef}
@@ -165,7 +165,7 @@ export function GroupBadges({
<Badge
key={group.id}
variant={selectedGroupId === group.id ? "default" : "secondary"}
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 flex-shrink-0"
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
onClick={(e) => {
if (hasMovedRef.current || clickBlockedRef.current) {
e.preventDefault();
+12 -5
View File
@@ -57,6 +57,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { GroupWithCount, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -262,8 +263,8 @@ export function GroupManagementDialog({
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast(
error instanceof Error
? error.message
parseBackendError(error)
? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"),
);
} finally {
@@ -529,9 +530,15 @@ export function GroupManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
+1
View File
@@ -321,6 +321,7 @@ const HomeHeader = ({
<span className="shrink-0">
<Button
size="sm"
data-onborda="create-profile"
onClick={() => {
onCreateProfileDialogOpen(true);
}}
+2 -2
View File
@@ -303,7 +303,7 @@ export function ImportProfileDialog({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
{!subPage && (
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
@@ -604,7 +604,7 @@ export function ImportProfileDialog({
<div
className={cn(
"flex-shrink-0 flex gap-2 items-center justify-end",
"shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
)}
>
+19 -3
View File
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
/** Which tab is displayed when the dialog mounts; defaults to "api". */
initialTab?: "api" | "mcp";
}
function AgentIcon({ category }: { category: AgentCategory }) {
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
isOpen,
onClose,
subPage,
initialTab = "api",
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
@@ -117,6 +120,7 @@ export function IntegrationsDialog({
const [isMcpStarting, setIsMcpStarting] = useState(false);
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
const { termsAccepted } = useWayfernTerms();
@@ -124,6 +128,7 @@ export function IntegrationsDialog({
try {
const loaded = await invoke<AppSettings>("get_app_settings");
setSettings(loaded);
setApiPortDraft(String(loaded.api_port ?? ""));
} catch (e) {
console.error("Failed to load settings:", e);
}
@@ -310,7 +315,7 @@ export function IntegrationsDialog({
)}
<div className="overflow-y-auto flex-1 min-h-0">
<AnimatedTabs defaultValue="api">
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
@@ -367,13 +372,24 @@ export function IntegrationsDialog({
<div className="flex items-center gap-2">
<Input
type="number"
value={settings.api_port}
value={apiPortDraft}
onChange={(e) => {
setApiPortDraft(e.target.value);
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
if (
!Number.isNaN(val) &&
val >= 1 &&
val <= 65535
) {
setSettings({ ...settings, api_port: val });
}
}}
onBlur={() => {
const val = Number.parseInt(apiPortDraft, 10);
if (Number.isNaN(val) || val < 1 || val > 65535) {
setApiPortDraft(String(settings.api_port));
}
}}
className="w-24 font-mono"
min={1}
max={65535}
-106
View File
@@ -1,106 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface LaunchOnLoginDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function LaunchOnLoginDialog({
isOpen,
onClose,
}: LaunchOnLoginDialogProps) {
const { t } = useTranslation();
const [isEnabling, setIsEnabling] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const handleEnable = useCallback(async () => {
setIsEnabling(true);
try {
await invoke("enable_launch_on_login");
showSuccessToast(t("launchOnLogin.enableSuccess"));
onClose();
} catch (error) {
console.error("Failed to enable launch on login:", error);
showErrorToast(t("launchOnLogin.enableFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsEnabling(false);
}
}, [onClose, t]);
const handleDecline = useCallback(async () => {
setIsDeclining(true);
try {
await invoke("decline_launch_on_login");
onClose();
} catch (error) {
console.error("Failed to decline launch on login:", error);
showErrorToast(t("launchOnLogin.declineFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsDeclining(false);
}
}, [onClose, t]);
return (
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-sm"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onPointerDownOutside={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("launchOnLogin.description")}
</p>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button
variant="ghost"
onClick={handleDecline}
disabled={isEnabling || isDeclining}
>
{isDeclining
? t("launchOnLogin.declining")
: t("launchOnLogin.declineButton")}
</Button>
<LoadingButton
onClick={handleEnable}
isLoading={isEnabling}
disabled={isDeclining}
>
{t("launchOnLogin.enableButton")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = ButtonProps & {
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
return (
<UIButton
className={cn("grid place-items-center", className)}
className={cn("inline-flex items-center justify-center", className)}
{...props}
disabled={props.disabled || isLoading}
>
+100
View File
@@ -0,0 +1,100 @@
"use client";
import type { CardComponentProps } from "onborda";
import { useOnborda } from "onborda";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal";
// Custom Onborda card, themed with the app's CSS variables. Finishing the last
// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory
// thank-you dialog (skipping early does not emit it).
export function OnboardingCard({
step,
currentStep,
totalSteps,
nextStep,
prevStep,
arrow,
}: CardComponentProps) {
const { t } = useTranslation();
const { closeOnborda } = useOnborda();
const isFirst = currentStep === 0;
const isLast = currentStep === totalSteps - 1;
// This step is completed by clicking the highlighted element (the "New"
// button), not by a "Next" button — advancing manually would jump to a step
// whose target doesn't exist yet and block the button. So hide "Next" here.
const requiresAction = step.selector === '[data-onborda="create-profile"]';
return (
<div className="relative p-4 w-80 max-w-[90vw] rounded-lg border shadow-lg bg-popover text-popover-foreground">
<div className="flex gap-2 items-start justify-between">
<h3 className="text-sm font-semibold leading-tight">{step.title}</h3>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{currentStep + 1}/{totalSteps}
</span>
</div>
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
{step.content}
</div>
<div className="flex gap-2 items-center justify-between mt-4">
{isLast ? (
<span />
) : (
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => {
closeOnborda();
}}
>
{t("onboarding.buttons.skip")}
</Button>
)}
<div className="flex gap-2 items-center">
{!isFirst && !isLast && (
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => {
prevStep();
}}
>
{t("onboarding.buttons.back")}
</Button>
)}
{isLast ? (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
closeOnborda();
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
}}
>
{t("onboarding.buttons.finish")}
</Button>
) : requiresAction ? null : (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
nextStep();
}}
>
{t("onboarding.buttons.next")}
</Button>
)}
</div>
</div>
<span className="text-popover">{arrow}</span>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Onborda, type OnbordaProps, OnbordaProvider } from "onborda";
import { useTranslation } from "react-i18next";
import { OnboardingCard } from "@/components/onboarding-card";
// Name of the first-run product tour. Referenced by the trigger logic in
// `src/app/page.tsx` via `startOnborda(ONBOARDING_TOUR)`.
export const ONBOARDING_TOUR = "donut-onboarding";
export function OnboardingProvider({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const tours: OnbordaProps["steps"] = [
{
tour: ONBOARDING_TOUR,
steps: [
{
icon: null,
title: t("onboarding.steps.createProfile.title"),
content: t("onboarding.steps.createProfile.content"),
selector: '[data-onborda="create-profile"]',
// The "New" button sits in the top-right corner; "bottom-right"
// anchors the card's right edge to it so the card extends left/down
// and stays on-screen instead of overflowing the right viewport edge.
side: "bottom-right",
showControls: true,
pointerPadding: 8,
pointerRadius: 10,
},
{
icon: null,
title: t("onboarding.steps.dnsBlocking.title"),
content: t("onboarding.steps.dnsBlocking.content"),
selector: '[data-onborda="dns-blocklist"]',
// The DNS dropdown sits in the right-hand columns. A centered "bottom"
// card runs off the right edge; "bottom-right" anchors the card's right
// edge to the dropdown and extends it left/down, keeping it fully
// on-screen with its arrow pointing up at the option.
side: "bottom-right",
showControls: true,
pointerPadding: 6,
pointerRadius: 8,
},
],
},
];
return (
<OnbordaProvider>
<Onborda
steps={tours}
cardComponent={OnboardingCard}
interact
shadowRgb="0,0,0"
shadowOpacity="0.6"
>
{children}
</Onborda>
</OnbordaProvider>
);
}
+4 -6
View File
@@ -131,9 +131,9 @@ export function PermissionDialog({
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="size-8" />;
return <BsMic className="size-5 shrink-0" />;
case "camera":
return <BsCamera className="size-8" />;
return <BsCamera className="size-5 shrink-0" />;
}
};
@@ -195,13 +195,11 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
<DialogTitle className="flex items-center justify-center gap-2 text-xl">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
{getPermissionTitle(permissionType)}
</DialogTitle>
<DialogDescription className="text-base">
<DialogDescription className="text-base text-pretty">
{getPermissionDescription(permissionType)}
</DialogDescription>
</DialogHeader>
+34 -12
View File
@@ -441,6 +441,7 @@ function DnsCell({
<PopoverTrigger asChild>
<button
type="button"
data-onborda="dns-blocklist"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
title={
@@ -691,7 +692,7 @@ const TagsCell = React.memo<{
);
return (
<div className="w-40 h-6 cursor-pointer">
<div className="w-full h-6 cursor-pointer">
<Tooltip>
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
{hiddenCount > 0 && (
@@ -717,7 +718,7 @@ const TagsCell = React.memo<{
return (
<div
className={cn(
"w-40 h-6 relative",
"w-full h-6 relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
@@ -925,19 +926,17 @@ const NoteCell = React.memo<{
}, [openNoteEditorFor, profile.id]);
const displayNote = effectiveNote ?? "";
const trimmedNote =
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
const showTooltip = displayNote.length > 0;
if (openNoteEditorFor !== profile.id) {
return (
<div className="w-24 min-h-6">
<div className="w-full min-h-6">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"flex items-start px-2 py-1 min-h-6 w-full bg-transparent rounded border-none text-left",
"flex items-center px-2 py-1 min-h-6 w-full min-w-0 bg-transparent rounded border-none text-left",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
@@ -951,11 +950,11 @@ const NoteCell = React.memo<{
>
<span
className={cn(
"text-sm wrap-break-word",
"text-sm truncate block w-full",
!effectiveNote && "text-muted-foreground",
)}
>
{effectiveNote ? trimmedNote : t("profiles.note.empty")}
{effectiveNote ? displayNote : t("profiles.note.empty")}
</span>
</button>
</TooltipTrigger>
@@ -974,7 +973,7 @@ const NoteCell = React.memo<{
return (
<div
className={cn(
"w-24 relative",
"w-full relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
@@ -1052,6 +1051,13 @@ interface ProfilesDataTableProps {
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
/**
* When provided, the info dialog is controlled by the parent. Allows the
* command palette in page.tsx to open the dialog directly without lifting
* every other piece of internal table state.
*/
infoDialogProfile?: BrowserProfile | null;
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
}
export function ProfilesDataTable({
@@ -1084,6 +1090,8 @@ export function ProfilesDataTable({
onSetPassword,
onChangePassword,
onRemovePassword,
infoDialogProfile,
onInfoDialogProfileChange,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -1155,8 +1163,22 @@ export function ProfilesDataTable({
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [profileForInfoDialog, setProfileForInfoDialog] =
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
React.useState<BrowserProfile | null>(null);
const isInfoDialogControlled = onInfoDialogProfileChange !== undefined;
const profileForInfoDialog = isInfoDialogControlled
? (infoDialogProfile ?? null)
: internalInfoDialogProfile;
const setProfileForInfoDialog = React.useCallback(
(p: BrowserProfile | null) => {
if (isInfoDialogControlled) {
onInfoDialogProfileChange?.(p);
} else {
setInternalInfoDialogProfile(p);
}
},
[isInfoDialogControlled, onInfoDialogProfileChange],
);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
@@ -2836,7 +2858,7 @@ export function ProfilesDataTable({
},
},
],
[t],
[t, setProfileForInfoDialog],
);
const table = useReactTable({

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