Compare commits

...

130 Commits

Author SHA1 Message Date
andy f581b6ec59 Merge pull request #395 from huy97/feature/add-vietnamese-locale
feat(i18n): add Vietnamese (vi) locale
2026-05-31 16:36:33 -07:00
andy 43c86c2dfb Merge branch 'main' into feature/add-vietnamese-locale 2026-05-31 16:36:22 -07:00
zhom 42067367fd chore: version bump 2026-06-01 02:19:25 +04:00
zhom ce7213dccd chore: bump flake.lock 2026-06-01 01:41:12 +04:00
andy 799df28f61 Merge pull request #402 from zhom/dependabot/cargo/src-tauri/rust-dependencies-1b5fcb4ba9
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 30 updates
2026-05-31 14:40:59 -07:00
andy e501e7a260 Merge pull request #401 from zhom/dependabot/github_actions/github-actions-c1330d3724
ci(deps): bump the github-actions group across 1 directory with 3 updates
2026-05-31 14:40:46 -07:00
dependabot[bot] 801bd3fe90 ci(deps): bump the github-actions group across 1 directory with 3 updates
Bumps the github-actions group with 3 updates in the / directory: [anomalyco/opencode](https://github.com/anomalyco/opencode), [actions/ai-inference](https://github.com/actions/ai-inference) and [crate-ci/typos](https://github.com/crate-ci/typos).


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-31 21:11:20 +00:00
andy b4074c1ee6 Merge pull request #403 from zhom/contributors-readme-action-pHvR07AJ6G
docs(contributor): contributors readme action update
2026-05-31 14:07:43 -07:00
github-actions[bot] 08cde9c0dc docs(contributor): contrib-readme-action has updated readme 2026-05-31 21:06:50 +00:00
zhom 98f1c7452a feat: add onboarding 2026-06-01 01:05:35 +04:00
huy97 ddfdf68dd1 feat(i18n): add Vietnamese (vi) locale
Add Vietnamese translation with all 1572 keys matching en.json.
Register "vi" in SUPPORTED_LANGUAGES, LANGUAGE_FALLBACKS, and
settings dialog language type assertion. Update locale count
from 8 to 9 in docs (AGENTS.md, CONTRIBUTING.md) to account
for previously-omitted Korean (ko).
2026-05-30 23:13:08 +07:00
dependabot[bot] 2131ca3e3f deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 30 updates:

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


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

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

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

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

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

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

Updates `hyper` from 1.9.0 to 1.10.1
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.9.0...v1.10.1)

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

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

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

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

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

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

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

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

Updates `libredox` from 0.1.16 to 0.1.17

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The existing standalone donut-daemon tray binary is left untouched.
2026-05-29 00:10:48 +07:00
JockLee d5f260bd7e fix: open cloud login in external browser 2026-05-27 20:09:00 +07:00
github-actions[bot] 56c547d7e0 chore: update flake.nix for v0.24.4 [skip ci] (#385)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-26 02:21:50 +00:00
github-actions[bot] 4396754cbd docs: update CHANGELOG.md and README.md for v0.24.4 [skip ci] (#384)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-26 02:21:29 +00:00
zhom 60c7c72036 chore: versiom bump 2026-05-26 04:42:31 +04:00
zhom f81e8b6162 refactor: more robust camoufox proxy handling 2026-05-26 04:40:19 +04:00
github-actions[bot] e4ecd0d18a chore: update flake.nix for v0.24.3 [skip ci] (#383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-25 00:02:17 +00:00
github-actions[bot] 8bc2dc3102 docs: update CHANGELOG.md and README.md for v0.24.3 [skip ci] (#382)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-25 00:01:55 +00:00
zhom 55de231a37 docs: readme 2026-05-25 03:38:01 +04:00
zhom aab403fd9b docs: update preview 2026-05-25 02:31:06 +04:00
zhom 667a4c99f0 chore: version bump 2026-05-25 02:20:40 +04:00
zhom 9236ad38c8 refactor: cleanup 2026-05-25 02:19:20 +04:00
zhom 6850f2c573 chore: linting 2026-05-23 14:35:55 +04:00
zhom 0add6c2aae chore: update pnpm 2026-05-23 14:22:45 +04:00
zhom f54c359d15 chore: make telegram releases ai-generated 2026-05-23 14:22:45 +04:00
zhom 69da467ce0 refactor: cleanup, korean translation 2026-05-23 14:22:45 +04:00
zhom 375530e358 chore: workflow cleanup 2026-05-23 14:22:45 +04:00
andy d664e5cde6 Merge pull request #377 from zhom/dependabot/cargo/src-tauri/rust-dependencies-fa7ae92db0
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 25 updates
2026-05-23 03:22:35 -07:00
andy 096e4aaf4a Merge pull request #376 from zhom/dependabot/github_actions/github-actions-39fff3f52e
ci(deps): bump the github-actions group with 6 updates
2026-05-23 03:07:33 -07:00
dependabot[bot] 8305c45cb5 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 25 updates:

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Updates `rsqlite-vfs` from 0.1.0 to 0.1.1

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.15.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 09:05:26 +00:00
zhom 36263eac04 feat: add shortcuts 2026-05-17 21:02:11 +04:00
zhom 9e777ed37b refactor: reduce token usage 2026-05-17 21:02:11 +04:00
zhom 4d59805989 chore: use less tokens 2026-05-17 21:02:11 +04:00
zhom 28d135de06 fix: track gecko_id for extension groups 2026-05-17 21:02:11 +04:00
zhom d234172d0a chore: improve issue validation 2026-05-17 21:02:11 +04:00
andy 6cd257c40b Merge pull request #372 from zhom/dependabot/github_actions/github-actions-4cf24cbed6
ci(deps): bump the github-actions group across 1 directory with 6 updates
2026-05-17 19:01:54 +02:00
dependabot[bot] 7446f678d4 ci(deps): bump the github-actions group across 1 directory with 6 updates
Bumps the github-actions group with 6 updates in the / directory:

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



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

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

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

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

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

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

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

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

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


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

Updates `toml` from 0.9.12+spec-1.1.0 to 1.1.2+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.12...toml-v1.1.2)

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

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

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

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

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

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

Updates `zerofrom` from 0.1.7 to 0.1.8
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 09:42:11 +00:00
github-actions[bot] 99074280ea chore: update flake.nix for v0.24.2 [skip ci] (#370)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:29:04 +00:00
github-actions[bot] 85586ed8fa docs: update CHANGELOG.md and README.md for v0.24.2 [skip ci] (#369)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:28:42 +00:00
zhom 2e891dd9ec chore: version bump 2026-05-16 02:43:17 +04:00
zhom e5361b6905 fix: camoufox proxy pid connection 2026-05-16 02:41:28 +04:00
zhom f6daa642d0 refactor: browser update 2026-05-15 20:42:25 +04:00
zhom c84d547a8c feat: more mcp integrations 2026-05-15 19:59:44 +04:00
zhom c8a43b43f1 refactor: ui cleanup 2026-05-15 15:44:20 +04:00
zhom 56b0da990b refactor: cleanup 2026-05-14 20:04:19 +04:00
zhom 597efb7e58 chore: cleanup 2026-05-14 20:03:22 +04:00
github-actions[bot] ba72e4cb3b chore: update flake.nix for v0.24.1 [skip ci] (#364)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:28 +00:00
github-actions[bot] c2ace4b8d3 docs: update CHANGELOG.md and README.md for v0.24.1 [skip ci] (#363)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:10 +00:00
zhom 35a874ead0 chore: version bump 2026-05-12 20:52:10 +04:00
zhom f02397dba9 refactor: creation button disaster recovery 2026-05-12 20:50:29 +04:00
github-actions[bot] d5752633c8 chore: update flake.nix for v0.24.0 [skip ci] (#357)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:02:08 +00:00
github-actions[bot] 5752260018 docs: update CHANGELOG.md and README.md for v0.24.0 [skip ci] (#356)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:01:52 +00:00
zhom 405d7c5716 fix: pass correct parameter for dns list selection 2026-05-12 13:17:29 +04:00
zhom 7d9bed2114 chore: version bump 2026-05-12 13:04:51 +04:00
zhom 2633e2ba09 refactor: better error handling and prevention of creating ephemeral password protected profiles 2026-05-12 13:03:34 +04:00
zhom 06b5a41b37 feat: support latest camoufox 2026-05-12 02:19:59 +04:00
zhom bb5f4ea166 chore: update dependencies 2026-05-12 01:35:28 +04:00
zhom 9c1cb011a5 refactor: ui cleanup 2026-05-12 01:22:04 +04:00
zhom ed3c209f35 feat: full ui refresh 2026-05-11 23:13:03 +04:00
zhom 739b5e2449 chore: fix telegram notifications 2026-05-11 23:13:03 +04:00
zhom c3e498fc6e chore: fix issue validation 2026-05-11 23:13:03 +04:00
zhom b5f000849f refactor: sync cleanup 2026-05-11 23:13:03 +04:00
zhom 722aaecbbe refactor: proxy spawn 2026-05-11 23:13:03 +04:00
github-actions[bot] 85e0072915 chore: update flake.nix for v0.23.0 [skip ci] (#351)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-10 04:36:53 +00:00
github-actions[bot] 50d918eeda docs: update CHANGELOG.md and README.md for v0.23.0 [skip ci] (#350)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-10 04:36:34 +00:00
zhom 2e0ee1ddfe chore: version bump 2026-05-10 06:45:46 +04:00
zhom 8dc48ef526 chore: logging 2026-05-10 06:03:48 +04:00
zhom bc3c2c8cca chore: copy 2026-05-10 04:45:46 +04:00
zhom b4a8fd04d8 feat: password protected profiles 2026-05-10 04:32:59 +04:00
zhom 5bff4438f0 docs: remove fossa badge 2026-05-10 02:20:08 +04:00
zhom 0fe3e5bc50 feat: telegram notifications 2026-05-09 21:07:32 +04:00
zhom 90ccf77e3f chore: optimize issue validation 2026-05-09 18:30:52 +04:00
zhom 88e6d7e116 chore: linting 2026-05-09 18:26:27 +04:00
zhom dd613a4d59 refactor: reduce the number of s3 calls 2026-05-09 18:26:27 +04:00
dependabot[bot] cabb5a3e23 deps(rust)(deps): bump the rust-dependencies group (#349)
Bumps the rust-dependencies group in /src-tauri with 31 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri-plugin-opener](https://github.com/tauri-apps/plugins-workspace) | `2.5.3` | `2.5.4` |
| [tauri-plugin-fs](https://github.com/tauri-apps/plugins-workspace) | `2.5.0` | `2.5.1` |
| [tauri-plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.8` | `2.4.9` |
| [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace) | `2.4.1` | `2.4.2` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.7.0` | `2.7.1` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.52.1` | `1.52.3` |
| [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.38.4` | `0.39.0` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.6.8` | `0.6.10` |
| [utoipa](https://github.com/juhaku/utoipa) | `5.4.0` | `5.5.0` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.39.2` | `0.39.4` |
| [tray-icon](https://github.com/tauri-apps/tray-icon) | `0.23.1` | `0.24.0` |
| [tao](https://github.com/tauri-apps/tao) | `0.35.0` | `0.35.2` |
| [avif-serialize](https://github.com/kornelski/avif-serialize) | `0.8.8` | `0.8.9` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.61` | `1.2.62` |
| [filetime](https://github.com/alexcrichton/filetime) | `0.2.27` | `0.2.28` |
| [h2](https://github.com/hyperium/h2) | `0.4.13` | `0.4.14` |
| [no_std_io2](https://github.com/wcampbell0x2a/no-std-io2) | `0.9.3` | `0.9.4` |
| [pin-project](https://github.com/taiki-e/pin-project) | `1.1.11` | `1.1.12` |
| [pin-project-internal](https://github.com/taiki-e/pin-project) | `1.1.11` | `1.1.12` |
| [profiling](https://github.com/aclysma/profiling) | `1.0.17` | `1.0.18` |
| [profiling-procmacros](https://github.com/aclysma/profiling) | `1.0.17` | `1.0.18` |
| [rust_decimal](https://github.com/paupino/rust-decimal) | `1.41.0` | `1.42.0` |
| [serde_with](https://github.com/jonasbb/serde_with) | `3.18.0` | `3.19.0` |
| [serde_with_macros](https://github.com/jonasbb/serde_with) | `3.18.0` | `3.19.0` |
| [siphasher](https://github.com/jedisct1/rust-siphash) | `1.0.2` | `1.0.3` |
| [tauri-plugin](https://github.com/tauri-apps/tauri) | `2.6.0` | `2.6.1` |
| [utoipa-gen](https://github.com/juhaku/utoipa) | `5.4.0` | `5.5.0` |
| [wry](https://github.com/tauri-apps/wry) | `0.55.0` | `0.55.1` |
| [zvariant](https://github.com/z-galaxy/zbus) | `5.10.1` | `5.11.0` |
| [zvariant_derive](https://github.com/z-galaxy/zbus) | `5.10.1` | `5.11.0` |


Updates `tauri-plugin-opener` from 2.5.3 to 2.5.4
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/http-v2.5.3...http-v2.5.4)

Updates `tauri-plugin-fs` from 2.5.0 to 2.5.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.5.0...fs-v2.5.1)

Updates `tauri-plugin-deep-link` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/deep-link-v2.4.8...deep-link-v2.4.9)

Updates `tauri-plugin-single-instance` from 2.4.1 to 2.4.2
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.1...fs-v2.4.2)

Updates `tauri-plugin-dialog` from 2.7.0 to 2.7.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.7.0...log-v2.7.1)

Updates `tokio` from 1.52.1 to 1.52.3
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

Updates `sysinfo` from 0.38.4 to 0.39.0
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.38.4...v0.39.0)

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 `tower-http` from 0.6.8 to 0.6.10
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.8...tower-http-0.6.10)

Updates `utoipa` from 5.4.0 to 5.5.0
- [Release notes](https://github.com/juhaku/utoipa/releases)
- [Changelog](https://github.com/juhaku/utoipa/blob/master/utoipa-rapidoc/CHANGELOG.md)
- [Commits](https://github.com/juhaku/utoipa/compare/utoipa-5.4.0...utoipa-5.5.0)

Updates `quick-xml` from 0.39.2 to 0.39.4
- [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.2...v0.39.4)

Updates `tray-icon` from 0.23.1 to 0.24.0
- [Release notes](https://github.com/tauri-apps/tray-icon/releases)
- [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.23.1...tray-icon-v0.24)

Updates `tao` from 0.35.0 to 0.35.2
- [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...tao-v0.35.2)

Updates `avif-serialize` from 0.8.8 to 0.8.9
- [Commits](https://github.com/kornelski/avif-serialize/compare/v0.8.8...v0.8.9)

Updates `cc` from 1.2.61 to 1.2.62
- [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.61...cc-v1.2.62)

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

Updates `h2` from 0.4.13 to 0.4.14
- [Release notes](https://github.com/hyperium/h2/releases)
- [Changelog](https://github.com/hyperium/h2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/h2/compare/v0.4.13...v0.4.14)

Updates `no_std_io2` from 0.9.3 to 0.9.4
- [Release notes](https://github.com/wcampbell0x2a/no-std-io2/releases)
- [Changelog](https://github.com/wcampbell0x2a/no-std-io2/blob/main/CHANGELOG.md)
- [Commits](https://github.com/wcampbell0x2a/no-std-io2/compare/v0.9.3...v0.9.4)

Updates `pin-project` from 1.1.11 to 1.1.12
- [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.11...v1.1.12)

Updates `pin-project-internal` from 1.1.11 to 1.1.12
- [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.11...v1.1.12)

Updates `profiling` from 1.0.17 to 1.0.18
- [Changelog](https://github.com/aclysma/profiling/blob/master/CHANGELOG.md)
- [Commits](https://github.com/aclysma/profiling/commits)

Updates `profiling-procmacros` from 1.0.17 to 1.0.18
- [Changelog](https://github.com/aclysma/profiling/blob/master/CHANGELOG.md)
- [Commits](https://github.com/aclysma/profiling/commits)

Updates `rust_decimal` from 1.41.0 to 1.42.0
- [Release notes](https://github.com/paupino/rust-decimal/releases)
- [Changelog](https://github.com/paupino/rust-decimal/blob/master/CHANGELOG.md)
- [Commits](https://github.com/paupino/rust-decimal/compare/1.41.0...1.42.0)

Updates `serde_with` from 3.18.0 to 3.19.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.18.0...v3.19.0)

Updates `serde_with_macros` from 3.18.0 to 3.19.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.18.0...v3.19.0)

Updates `siphasher` from 1.0.2 to 1.0.3
- [Commits](https://github.com/jedisct1/rust-siphash/compare/1.0.2...1.0.3)

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

Updates `utoipa-gen` from 5.4.0 to 5.5.0
- [Release notes](https://github.com/juhaku/utoipa/releases)
- [Changelog](https://github.com/juhaku/utoipa/blob/master/utoipa-rapidoc/CHANGELOG.md)
- [Commits](https://github.com/juhaku/utoipa/compare/utoipa-gen-5.4.0...utoipa-gen-5.5.0)

Updates `wry` from 0.55.0 to 0.55.1
- [Release notes](https://github.com/tauri-apps/wry/releases)
- [Changelog](https://github.com/tauri-apps/wry/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/wry/compare/wry-v0.55...wry-v0.55.1)

Updates `zvariant` from 5.10.1 to 5.11.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.10.1...zvariant-5.11.0)

Updates `zvariant_derive` from 5.10.1 to 5.11.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.10.1...zvariant_derive-5.11.0)

---
updated-dependencies:
- dependency-name: tauri-plugin-opener
  dependency-version: 2.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-fs
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-deep-link
  dependency-version: 2.4.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-single-instance
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sysinfo
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  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: tower-http
  dependency-version: 0.6.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: utoipa
  dependency-version: 5.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: quick-xml
  dependency-version: 0.39.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tray-icon
  dependency-version: 0.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tao
  dependency-version: 0.35.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: avif-serialize
  dependency-version: 0.8.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.62
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: filetime
  dependency-version: 0.2.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: h2
  dependency-version: 0.4.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: no_std_io2
  dependency-version: 0.9.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pin-project
  dependency-version: 1.1.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pin-project-internal
  dependency-version: 1.1.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: profiling
  dependency-version: 1.0.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: profiling-procmacros
  dependency-version: 1.0.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rust_decimal
  dependency-version: 1.42.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with
  dependency-version: 3.19.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.19.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: siphasher
  dependency-version: 1.0.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin
  dependency-version: 2.6.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: utoipa-gen
  dependency-version: 5.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: wry
  dependency-version: 0.55.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zvariant
  dependency-version: 5.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zvariant_derive
  dependency-version: 5.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 10:25:26 +00:00
dependabot[bot] c981e18a7b ci(deps): bump the github-actions group with 3 updates (#348)
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


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

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

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

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.14.41
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 09:42:16 +00:00
dependabot[bot] 982ed36401 deps(rust)(deps): bump tauri from 2.11.0 to 2.11.1 in /src-tauri (#346)
Bumps [tauri](https://github.com/tauri-apps/tauri) from 2.11.0 to 2.11.1.
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.11.0...tauri-v2.11.1)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.11.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 04:33:09 +00:00
andy 4b52ced71f Merge pull request #343 from zhom/dependabot/cargo/src-tauri/openssl-0.10.79
deps(rust)(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
2026-05-06 11:29:14 +02:00
dependabot[bot] 99f9e04553 deps(rust)(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.78 to 0.10.79.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.78...openssl-v0.10.79)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.79
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 09:19:51 +00:00
zhom 53165e3cf0 chore: cleanup issue validation 2026-05-06 13:18:23 +04:00
github-actions[bot] 29e73bd2d8 chore: update flake.nix for v0.22.7 [skip ci] (#341)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-05 22:13:54 +00:00
github-actions[bot] 6441843d85 docs: update CHANGELOG.md and README.md for v0.22.7 [skip ci] (#340)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-05 22:13:36 +00:00
zhom 5356d59d72 chore: version bump 2026-05-05 22:34:56 +04:00
zhom 34450ad06b refactor: cleanup 2026-05-05 22:34:56 +04:00
zhom 904dda2bad chore: copy 2026-05-05 22:34:56 +04:00
github-actions[bot] 39b13ead5b chore: update flake.nix for v0.22.6 [skip ci] (#337)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-03 23:27:39 +00:00
github-actions[bot] 62c84b52fc docs: update CHANGELOG.md and README.md for v0.22.6 [skip ci] (#336)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-03 23:27:18 +00:00
zhom 828c3bb984 chore: version bump 2026-05-04 02:01:36 +04:00
zhom ffe35c1672 chore: rand bump 2026-05-04 02:00:41 +04:00
zhom 4a4cf81255 refactor: don't block ui on clade check 2026-05-04 01:57:42 +04:00
zhom 77be8cadaf feat: vpn manipulation via the api 2026-05-04 01:57:05 +04:00
zhom 3207e4fbd3 chore: pnpm bump 2026-05-02 20:00:05 +04:00
dependabot[bot] c18e9625fd deps(rust)(deps): bump the rust-dependencies group (#331)
Bumps the rust-dependencies group in /src-tauri with 34 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.10.3` | `2.11.0` |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.13.2` | `0.13.3` |
| [zip](https://github.com/zip-rs/zip2) | `8.5.1` | `8.6.0` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [maxminddb](https://github.com/oschwald/maxminddb-rust) | `0.27.3` | `0.28.1` |
| [boringtun](https://github.com/cloudflare/boringtun) | `0.7.0` | `0.7.1` |
| [smoltcp](https://github.com/smoltcp-rs/smoltcp) | `0.13.0` | `0.13.1` |
| [tray-icon](https://github.com/tauri-apps/tray-icon) | `0.22.1` | `0.23.1` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.5.6` | `2.6.0` |
| [cpubits](https://github.com/RustCrypto/utils) | `0.1.0` | `0.1.1` |
| [ctor](https://github.com/mmastrac/rust-ctor) | `0.2.9` | `0.8.0` |
| [fax](https://github.com/pdf-rs/fax) | `0.2.6` | `0.2.7` |
| [heapless](https://github.com/rust-embedded/heapless) | `0.9.2` | `0.9.3` |
| [idna_adapter](https://github.com/hsivonen/idna_adapter) | `1.2.1` | `1.2.2` |
| [imgref](https://github.com/kornelski/imgref) | `1.12.0` | `1.12.1` |
| [muda](https://github.com/tauri-apps/muda) | `0.17.2` | `0.19.1` |
| [plist](https://github.com/ebarnard/rust-plist) | `1.8.0` | `1.9.0` |
| [rustls](https://github.com/rustls/rustls) | `0.23.39` | `0.23.40` |
| [tauri-codegen](https://github.com/tauri-apps/tauri) | `2.5.5` | `2.6.0` |
| [tauri-macros](https://github.com/tauri-apps/tauri) | `2.5.5` | `2.6.0` |
| [tauri-plugin](https://github.com/tauri-apps/tauri) | `2.5.4` | `2.6.0` |
| [tauri-runtime](https://github.com/tauri-apps/tauri) | `2.10.1` | `2.11.0` |
| [tauri-runtime-wry](https://github.com/tauri-apps/tauri) | `2.10.1` | `2.11.0` |
| [tauri-utils](https://github.com/tauri-apps/tauri) | `2.8.3` | `2.9.0` |
| [tauri-winres](https://github.com/tauri-apps/winres) | `0.3.5` | `0.3.6` |
| [tendril](https://github.com/servo/html5ever) | `0.4.3` | `0.5.0` |
| [wasi](https://github.com/bytecodealliance/wasi-rs) | `0.9.0+wasi-snapshot-preview1` | `0.11.1+wasi-snapshot-preview1` |
| [wry](https://github.com/tauri-apps/wry) | `0.54.4` | `0.55.0` |
| [zbus](https://github.com/z-galaxy/zbus) | `5.14.0` | `5.15.0` |
| [zbus_macros](https://github.com/z-galaxy/zbus) | `5.14.0` | `5.15.0` |
| [zbus_names](https://github.com/z-galaxy/zbus) | `4.3.1` | `4.3.2` |
| [zvariant](https://github.com/z-galaxy/zbus) | `5.10.0` | `5.10.1` |
| [zvariant_derive](https://github.com/z-galaxy/zbus) | `5.10.0` | `5.10.1` |
| [zvariant_utils](https://github.com/z-galaxy/zbus) | `3.3.0` | `3.3.1` |


Updates `tauri` from 2.10.3 to 2.11.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.3...tauri-v2.11.0)

Updates `reqwest` from 0.13.2 to 0.13.3
- [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.2...v0.13.3)

Updates `zip` from 8.5.1 to 8.6.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v8.5.1...v8.6.0)

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 `maxminddb` from 0.27.3 to 0.28.1
- [Release notes](https://github.com/oschwald/maxminddb-rust/releases)
- [Changelog](https://github.com/oschwald/maxminddb-rust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/oschwald/maxminddb-rust/compare/v0.27.3...v0.28.1)

Updates `boringtun` from 0.7.0 to 0.7.1
- [Release notes](https://github.com/cloudflare/boringtun/releases)
- [Changelog](https://github.com/cloudflare/boringtun/blob/master/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/boringtun/compare/boringtun-0.7.0...boringtun-0.7.1)

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

Updates `tray-icon` from 0.22.1 to 0.23.1
- [Release notes](https://github.com/tauri-apps/tray-icon/releases)
- [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.22.1...tray-icon-v0.23.1)

Updates `tauri-build` from 2.5.6 to 2.6.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.5.6...tauri-build-v2.6.0)

Updates `cpubits` from 0.1.0 to 0.1.1
- [Commits](https://github.com/RustCrypto/utils/compare/cpubits-v0.1.0...cpubits-v0.1.1)

Updates `ctor` from 0.2.9 to 0.8.0
- [Changelog](https://github.com/mmastrac/linktime/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mmastrac/rust-ctor/commits)

Updates `fax` from 0.2.6 to 0.2.7
- [Commits](https://github.com/pdf-rs/fax/commits)

Updates `heapless` from 0.9.2 to 0.9.3
- [Release notes](https://github.com/rust-embedded/heapless/releases)
- [Changelog](https://github.com/rust-embedded/heapless/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-embedded/heapless/compare/v0.9.2...v0.9.3)

Updates `idna_adapter` from 1.2.1 to 1.2.2
- [Commits](https://github.com/hsivonen/idna_adapter/compare/v1.2.1...v1.2.2)

Updates `imgref` from 1.12.0 to 1.12.1
- [Commits](https://github.com/kornelski/imgref/compare/v1.12.0...v1.12.1)

Updates `muda` from 0.17.2 to 0.19.1
- [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.17.2...muda-v0.19.1)

Updates `plist` from 1.8.0 to 1.9.0
- [Release notes](https://github.com/ebarnard/rust-plist/releases)
- [Changelog](https://github.com/ebarnard/rust-plist/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ebarnard/rust-plist/compare/v1.8.0...v1.9.0)

Updates `rustls` from 0.23.39 to 0.23.40
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.39...v/0.23.40)

Updates `tauri-codegen` from 2.5.5 to 2.6.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-codegen-v2.5.5...tauri-codegen-v2.6.0)

Updates `tauri-macros` from 2.5.5 to 2.6.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-macros-v2.5.5...tauri-macros-v2.6.0)

Updates `tauri-plugin` from 2.5.4 to 2.6.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-plugin-v2.5.4...tauri-plugin-v2.6.0)

Updates `tauri-runtime` from 2.10.1 to 2.11.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-v2.10.1...tauri-runtime-v2.11.0)

Updates `tauri-runtime-wry` from 2.10.1 to 2.11.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-wry-v2.10.1...tauri-runtime-wry-v2.11.0)

Updates `tauri-utils` from 2.8.3 to 2.9.0
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-utils-v2.8.3...tauri-utils-v2.9.0)

Updates `tauri-winres` from 0.3.5 to 0.3.6
- [Release notes](https://github.com/tauri-apps/winres/releases)
- [Changelog](https://github.com/tauri-apps/winres/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/winres/compare/winres-v0.3.5...winres-v0.3.6)

Updates `tendril` from 0.4.3 to 0.5.0
- [Release notes](https://github.com/servo/html5ever/releases)
- [Commits](https://github.com/servo/html5ever/commits)

Updates `wasi` from 0.9.0+wasi-snapshot-preview1 to 0.11.1+wasi-snapshot-preview1
- [Commits](https://github.com/bytecodealliance/wasi-rs/commits/0.11.1)

Updates `wry` from 0.54.4 to 0.55.0
- [Release notes](https://github.com/tauri-apps/wry/releases)
- [Changelog](https://github.com/tauri-apps/wry/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/wry/compare/wry-v0.54.4...wry-v0.55)

Updates `zbus` from 5.14.0 to 5.15.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.14.0...zbus-5.15.0)

Updates `zbus_macros` from 5.14.0 to 5.15.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.14.0...zbus_macros-5.15.0)

Updates `zbus_names` from 4.3.1 to 4.3.2
- [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_names-4.3.1...zbus_names-4.3.2)

Updates `zvariant` from 5.10.0 to 5.10.1
- [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.10.0...zvariant-5.10.1)

Updates `zvariant_derive` from 5.10.0 to 5.10.1
- [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.10.0...zvariant_derive-5.10.1)

Updates `zvariant_utils` from 3.3.0 to 3.3.1
- [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.0...zvariant_utils-3.3.1)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: reqwest
  dependency-version: 0.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 8.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  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: maxminddb
  dependency-version: 0.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: boringtun
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: smoltcp
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tray-icon
  dependency-version: 0.23.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cpubits
  dependency-version: 0.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: ctor
  dependency-version: 0.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: fax
  dependency-version: 0.2.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: heapless
  dependency-version: 0.9.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: idna_adapter
  dependency-version: 1.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: imgref
  dependency-version: 1.12.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: muda
  dependency-version: 0.19.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: plist
  dependency-version: 1.9.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rustls
  dependency-version: 0.23.40
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-codegen
  dependency-version: 2.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-macros
  dependency-version: 2.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin
  dependency-version: 2.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime
  dependency-version: 2.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime-wry
  dependency-version: 2.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-utils
  dependency-version: 2.9.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-winres
  dependency-version: 0.3.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tendril
  dependency-version: 0.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: wasi
  dependency-version: 0.11.1+wasi-snapshot-preview1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: wry
  dependency-version: 0.55.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zbus
  dependency-version: 5.15.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zbus_macros
  dependency-version: 5.15.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zbus_names
  dependency-version: 4.3.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zvariant
  dependency-version: 5.10.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zvariant_derive
  dependency-version: 5.10.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zvariant_utils
  dependency-version: 3.3.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-02 10:38:08 +00:00
dependabot[bot] d06ddccd78 ci(deps): bump the github-actions group with 3 updates (#330)
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `pnpm/action-setup` from 6.0.3 to 6.0.4
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/903f9c1a6ebcba6cf41d87230be49611ac97822e...26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a)

Updates `anomalyco/opencode` from 1.14.24 to 1.14.31
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65...557734bd130a68188454bc691e153f9f3731830e)

Updates `crate-ci/typos` from 1.45.1 to 1.46.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/cf5f1c29a8ac336af8568821ec41919923b05a83...bbaefadf97b0ec5fdc942684b647f1a6ab250274)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.14.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-02 09:52:30 +00:00
github-actions[bot] 04297fc27d chore: update flake.nix for v0.22.5 [skip ci] (#328)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-29 21:54:30 +00:00
github-actions[bot] 1d404833ad docs: update CHANGELOG.md and README.md for v0.22.5 [skip ci] (#327)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-29 21:54:07 +00:00
andy f61a3905fa Merge pull request #326 from zhom/contributors-readme-action-EteKcTv4vm
docs(contributor): contributors readme action update
2026-04-29 22:26:06 +02:00
github-actions[bot] 79d8b83b57 docs(contributor): contrib-readme-action has updated readme 2026-04-29 20:23:54 +00:00
zhom e700b47b4c chore: version bump 2026-04-30 00:23:20 +04:00
zhom 57167b979f chore: copy 2026-04-30 00:23:20 +04:00
andy 571bfcb213 Merge pull request #325 from ThiagoMafra-Integrare/fix/missing-libxdo3-dependency
fix(deb,rpm): declare libxdo as runtime dependency
2026-04-29 19:20:42 +02:00
ThiagoMafra-Integrare 6721444822 fix(deb,rpm): declare libxdo as runtime dependency
Donut Browser uses libxdo at runtime (loaded via dlopen, not directly
linked), so Tauri's auto-dependency detection misses it. As a result,
the DEB/RPM packages install cleanly but launching the app fails
silently with:

  /usr/bin/donutbrowser: error while loading shared libraries:
  libxdo.so.3: cannot open shared object file: No such file or directory

Declare libxdo3 (Debian/Ubuntu) and libxdo (Fedora/openSUSE) explicitly
in bundle.linux.{deb,rpm}.depends so package managers pull the library
during install.
2026-04-29 10:49:33 -03:00
github-actions[bot] ef1dc3407f chore: update flake.nix for v0.22.4 [skip ci] (#324)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-28 21:24:36 +00:00
github-actions[bot] 1162f1e9f3 docs: update CHANGELOG.md and README.md for v0.22.4 [skip ci] (#323)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-28 21:24:17 +00:00
zhom 8d524e07f4 chore: version bump 2026-04-28 23:55:15 +04:00
zhom f8ce56481f chore: i18n 2026-04-28 23:50:56 +04:00
github-actions[bot] 97d01e4b54 chore: update flake.nix for v0.22.3 [skip ci] (#321)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 19:59:16 +00:00
github-actions[bot] 5980ce5e8d docs: update CHANGELOG.md and README.md for v0.22.3 [skip ci] (#320)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 19:58:56 +00:00
zhom 4cfbcde3de chore: version bump 2026-04-27 22:24:40 +04:00
zhom c9ae34f225 fix: correct browser port mapping 2026-04-27 22:24:40 +04:00
github-actions[bot] 0b30939b8f chore: update flake.nix for v0.22.2 [skip ci] (#315)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 03:57:48 +00:00
github-actions[bot] 3e99bffe06 docs: update CHANGELOG.md and README.md for v0.22.2 [skip ci] (#314)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 03:57:35 +00:00
207 changed files with 60453 additions and 12497 deletions
+8
View File
@@ -1,4 +1,12 @@
# macOS code signing + notarization for `pnpm tauri build`.
# Loaded into the build environment via scripts/run-with-env.mjs (and direnv via .envrc).
# APPLE_SIGNING_IDENTITY: the exact name of your Developer ID Application
# certificate as it appears in `security find-identity -v -p codesigning`.
# Example: "Developer ID Application: Your Name (TEAMID)"
# APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID: credentials for notarytool.
# APPLE_PASSWORD must be an app-specific password from appleid.apple.com,
# not your real Apple ID password.
APPLE_TEAM_ID=
APPLE_ID=
APPLE_PASSWORD=
+4
View File
@@ -1 +1,5 @@
use flake
# Load .env on top of the flake's environment so APPLE_SIGNING_IDENTITY,
# APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. are available to `tauri build`
# and any other tools spawned from this directory.
dotenv_if_exists .env
@@ -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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
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
+362 -69
View File
@@ -16,6 +16,11 @@ permissions:
pull-requests: write
id-token: write
env:
# Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: z-ai/glm-5.1
COMPOSER_MODEL: z-ai/glm-5.1
jobs:
analyze-issue:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
@@ -40,42 +45,216 @@ jobs:
echo "is_first_time=false" >> $GITHUB_OUTPUT
fi
- name: Build repo context and find related files
- name: Parse issue template fields
env:
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
node <<'EOF'
const fs = require('node:fs');
const body = process.env.ISSUE_BODY || '';
// GitHub issue templates render fields as `### Heading\nValue` blocks.
// Split on `###` at line start to recover them.
const fields = {};
const sections = body.split(/^###\s+/m);
for (const section of sections.slice(1)) {
const nl = section.indexOf('\n');
if (nl < 0) continue;
const heading = section.slice(0, nl).trim();
const value = section.slice(nl + 1).trim();
fields[heading] = value === '_No response_' ? '' : value;
}
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
// Convenience extractions for the prompt — empty string if missing.
const get = (k) => fields[k] || '';
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?'));
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
EOF
echo "Parsed fields:"
cat /tmp/issue-fields.json
- name: Build repo context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
# List all source files for the AI to pick from
# List all source files for the AI to choose from
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
- name: Select relevant files with AI
- name: Write shared knowledge files (scope + pricing)
run: |
cat > /tmp/scope-and-pricing.md <<'EOF'
# PROJECT SCOPE
- **Donut Browser** — this repo. A Tauri desktop launcher (Rust + Next.js) that
downloads, manages, and launches anti-detect browser profiles. In-scope for bug
reports about profile management, downloads, sync, proxy, VPN, the launcher UI,
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, 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 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
supported. Feature requests asking for them are out of scope.
# PAID vs FREE FEATURES
Source: donutbrowser.com pricing tiers (verbatim from translations).
## Free (no account required)
- Unlimited local profiles
- Chromium (Wayfern) and Firefox (Camoufox) browser engines
- Proxy support (HTTP/SOCKS5)
- VPN support (WireGuard)
- Profile Management API & MCP (list / create / launch / kill / config)
- Cookie & Extension Management
- Set as default browser
- **Profile sync IS FREE if the user self-hosts the `donut-sync` server**
## Pro ($16/mo) — adds:
- Browser Manipulation API & MCP (`type_text`, `click_element`,
`evaluate_javascript`, `screenshot`, `navigate`, etc.)
- Cross-OS fingerprinting (e.g. macOS user appearing as Windows)
- Profile Synchronizer for Wayfern
- 20 cloud profile backup (cloud sync via donutbrowser.com)
- Commercial use license
## Team ($80/mo) — adds:
- 100 cloud profile sync
- Team collaboration, profile sharing, unlimited seats
# ANTI-PATTERNS
- **Regression**: user explicitly mentions a previous version that worked
differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
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. 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.
- **AI-generated / template-violating report**: report doesn't follow the
template, may cite "official documentation" via context7, deepwiki, or any
non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative
sources are this GitHub repo and donutbrowser.com.
- **Speculation about internals**: never write a "Possible cause" / "Likely
cause" / "Root cause" section. Never cite internal file paths or line
numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
# Easiest path for the user: Donut → Settings → Advanced → Copy logs
# (puts the latest rotated log on the clipboard). If they prefer to
# attach files directly, the active log is `DonutBrowser.log`; older
# rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-…`).
- macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else)
- **`CDP not ready after N attempts on port X: HTTP 5xx ...`** —
an HTTP 5xx (503 / 502) response from a freshly-launched
browser's `/json/version` endpoint always means *something on
the loopback path is intercepting the connection*: a firewall,
an antivirus web-shield (Kaspersky, Bitdefender, ESET, Avast /
AVG, Yandex Protect on Windows; Little Snitch, LuLu on macOS),
a VPN client that hijacks 127.0.0.1, or a corporate MDM /
proxy (Zscaler, Cisco AnyConnect, Netskope). Chrome's
DevTools endpoint never returns 5xx itself — only synthetic
responses from interception layers do. **Do NOT speculate
about Gatekeeper, first-launch verification, code signing, or
quarantine** — none of those cause a 5xx response, and
Gatekeeper never delays a launch long enough to surface as
"120 attempts". Lead with: which AV / web-shield / firewall /
VPN / MDM is installed, and ask the user to try with the AV's
web-shield component temporarily disabled (not the whole AV).
EOF
- name: Build triage system prompt
run: |
# The static system prompt has apostrophes ("doesn't", "official docs"
# etc.) that collide with shell single-quoting if embedded directly in
# the jq filter. Build the full prompt to a file instead, then load it
# via --rawfile in the next step.
{
cat <<'TRIAGE_HEAD'
You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.
TRIAGE_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'TRIAGE_TAIL'
# OUTPUT
Return ONLY valid JSON. No preamble, no code fences. Schema:
{
"language": "en" or ISO 639-1 code,
"classification": one of ["bug-in-scope", "bug-upstream-camoufox", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
"operating_system": "macos" | "windows" | "linux" | "unknown",
"is_paid_feature": true | false,
"user_followed_template": true | false,
"regression_signal": quoted user snippet or null,
"user_cited_external_docs": URL string or null,
"files_to_read": array of at most 20 file paths from the list,
"notes": one short sentence describing what you observed
}
Classification guidance:
- "bug-upstream-camoufox": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.
- "bug-template-violation": missing or filled-in nonsense for required template fields.
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
- "regression": user names a prior version that worked.
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
TRIAGE_TAIL
} > /tmp/triage-system.txt
wc -c /tmp/triage-system.txt
- name: Stage 1 — Triage and file selection
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
# The triage call returns ONLY JSON. It classifies the issue and picks a
# short list of source files for the composer to read.
PAYLOAD=$(jq -n \
--arg model "$TRIAGE_MODEL" \
--rawfile system_prompt /tmp/triage-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile files /tmp/all-source-files.txt \
'{
model: "anthropic/claude-opus-4.6",
model: $model,
messages: [
{
role: "system",
content: "You are a file selector for Donut Browser (Tauri + Next.js + Rust anti-detect browser). Given an issue and a list of source files, output ONLY the 10 most likely relevant file paths, one per line. No explanations, no numbering, just paths."
},
{
role: "user",
content: ("Issue: " + $title + "\n\n" + $body + "\n\nFiles:\n" + $files)
}
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
]
}')
@@ -84,65 +263,171 @@ jobs:
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/selected-files.txt
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
# Read the selected files in full (skip binary files)
echo "" > /tmp/file-contents.txt
while IFS= read -r filepath; do
# Strip ```json fences if the model couldn't help itself.
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
# Validate; if the model returned junk, fall back to a minimal stub so the
# composer still gets called and produces SOMETHING.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification"
cat /tmp/triage-raw.txt
jq -n '{
language: "en",
classification: "bug-in-scope",
operating_system: "unknown",
is_paid_feature: false,
user_followed_template: true,
regression_signal: null,
user_cited_external_docs: null,
files_to_read: [],
notes: "triage call failed; defaulting"
}' > /tmp/triage.json
fi
echo "Triage result:"
cat /tmp/triage.json
- name: Read files chosen by triage
run: |
: > /tmp/file-context.txt
# files_to_read may be empty (e.g. upstream Camoufox) — that's fine.
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
filepath=$(echo "$filepath" | xargs)
[ -z "$filepath" ] && continue
# Reject paths that escape the repo or look fishy
case "$filepath" in
/*|*..*|*$'\n'*) continue ;;
esac
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath ===" >> /tmp/file-contents.txt
cat "$filepath" >> /tmp/file-contents.txt
echo "" >> /tmp/file-contents.txt
echo "=== $filepath ===" >> /tmp/file-context.txt
cat "$filepath" >> /tmp/file-context.txt
echo "" >> /tmp/file-context.txt
fi
done < /tmp/selected-files.txt
done
# Cap total context at 100 KB to keep token cost bounded.
head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt
mv /tmp/file-context.capped.txt /tmp/file-context.txt
wc -c /tmp/file-context.txt
# Cap total context at 100KB
head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt
- name: Build composer system prompt
run: |
# Same reason as the triage prompt: lots of apostrophes, no shell-quoting
# gymnastics. Build it to a file, load via --rawfile.
{
cat <<'COMPOSER_HEAD'
You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.
- name: Analyze issue with AI
COMPOSER_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'COMPOSER_TAIL'
# RULES — STRICT
## Output shape
- One sentence acknowledging the report.
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.
- Maximum 15 lines.
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
- No closing pleasantries ("please let me know", "happy to help", etc.).
## Forbidden — never do these
- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
- NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
- NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
- NEVER list more than one OS log path. Use ONLY the path matching the user's reported OS. If OS is unknown, ask for it instead of listing all three.
- NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
- NEVER call a report "a known and expected behavior" or "a false positive" if the user mentions a regression. The triage tells you when this applies.
## Classification handling
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 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.
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.
## Paid-feature awareness
If `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.
## Language
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
Recommend Settings → Advanced → Copy logs first — it bundles the
latest rotated log onto the clipboard without the user hunting for
a directory. If they want to attach files directly, point at the
path that matches `triage.operating_system`. The active log is
always `DonutBrowser.log`; rotated copies sit next to it.
- macos: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
- unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions)
If the issue body contains any of these, lead with the matching
response — do NOT speculate about other causes:
- `CDP not ready after N attempts on port X: HTTP 5xx ...` —
this is loopback interception by a firewall / antivirus
web-shield / VPN / MDM. Lead with that question (specifically:
Kaspersky, Bitdefender, ESET, Avast/AVG, Yandex Protect on
Windows; Little Snitch, LuLu, corporate MDM on macOS; any
VPN). Suggest temporarily disabling the AV's web-shield
component (NOT the whole AV) and retrying. Do NOT mention
Gatekeeper, first-launch verification, code signing, or
quarantine — none of those cause an HTTP 5xx response, and
Gatekeeper never delays a launch long enough to produce a
"120 attempts" failure.
COMPOSER_TAIL
} > /tmp/composer-system.txt
wc -c /tmp/composer-system.txt
- name: Stage 2 — Compose response
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
run: |
GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING='This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"'
# Use printf with %s so the apostrophe inside the string never has to
# cross a shell single-quote boundary.
printf '%s' 'This is the first issue from this user — start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
else
: > /tmp/greeting.txt
fi
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile system_prompt /tmp/composer-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile author /tmp/issue-author.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile triage /tmp/triage.json \
--rawfile greeting /tmp/greeting.txt \
--rawfile repo_context /tmp/repo-context.txt \
--rawfile context /tmp/file-context.txt \
--rawfile files /tmp/file-context.txt \
'{
model: "anthropic/claude-opus-4.6",
model: $model,
messages: [
{
role: "system",
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <container>` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n - Only ask for information that is actually missing. If the issue is already detailed, just acknowledge it.\n3. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Do NOT include a \"Possible cause\" section. Do not speculate about what code might be causing the issue.\n- Be brief and focused on collecting actionable information from the reporter.\n- If the issue already has everything needed (steps to reproduce, logs, version, OS), just acknowledge it.\n- Never exceed 15 lines.")
},
{
role: "user",
content: (
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
"Analyze this issue:\n\nTitle: " + $title +
"\nAuthor: " + $author +
"\n\nBody:\n" + $body +
"\n\nRelevant source files:\n" + $context
)
}
{ role: "system", content: $system_prompt },
{ role: "user",
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
+ "Title: " + $title
+ "\nAuthor: " + $author
+ "\n\n## Triage result\n" + $triage
+ "\n\n## Parsed template fields\n" + $fields
+ "\n\n## Raw issue body\n" + $body
+ "\n\n## Source files (selected by triage)\n" + $files) }
]
}')
@@ -154,28 +439,41 @@ jobs:
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty"
echo "::error::Composer returned empty response"
echo "Raw response:"
echo "$RESPONSE"
exit 1
fi
- name: Post comment and label
- name: Strip forbidden sections (defense in depth)
run: |
# Even with explicit prompt rules, LLMs sometimes still emit "Possible cause"
# and friends. Strip any such heading + its block. Also drop any stray
# `Label:` lines from earlier prompt iterations.
python3 - <<'EOF'
import re
path = '/tmp/ai-comment.txt'
text = open(path).read()
# Drop forbidden section headers and everything until a blank line or another header.
forbidden = re.compile(
r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)',
re.IGNORECASE | re.MULTILINE | re.DOTALL,
)
text = forbidden.sub('', text)
# Drop stale Label: lines (we don't label anymore).
text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE)
# Collapse 3+ blank lines.
text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n'
open(path, 'w').write(text)
EOF
- name: Post comment (no labeling)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
LABEL=$(grep -oP '^Label:\s*\K.*' /tmp/ai-comment.txt | tail -1 | tr '[:upper:]' '[:lower:]' | xargs)
sed -i '/^Label:/d' /tmp/ai-comment.txt
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
if [ "$LABEL" = "bug" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "bug" 2>/dev/null || true
elif [ "$LABEL" = "enhancement" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "enhancement" 2>/dev/null || true
fi
analyze-pr:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
@@ -204,26 +502,20 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Get changed files list
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
> /tmp/pr-files.txt
# Get the actual diff
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
--header "Accept: application/vnd.github.diff" \
> /tmp/pr-diff-full.txt 2>/dev/null || true
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
# Get CONTRIBUTING.md and README.md for context
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt
# Read full contents of all changed files (skip binary)
echo "" > /tmp/related-file-contents.txt
: > /tmp/related-file-contents.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
@@ -258,6 +550,7 @@ jobs:
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile title /tmp/pr-title.txt \
--rawfile body /tmp/pr-body.txt \
--rawfile author /tmp/pr-author.txt \
@@ -270,7 +563,7 @@ jobs:
--rawfile contributing /tmp/contributing.txt \
--rawfile file_context /tmp/pr-file-context.txt \
'{
model: "anthropic/claude-opus-4.6",
model: $model,
messages: [
{
role: "system",
@@ -327,7 +620,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65 #v1.14.24
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
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
+215
View File
@@ -0,0 +1,215 @@
name: Notify Telegram
# tauri-action creates the release with the default GITHUB_TOKEN, and GitHub
# Actions deliberately suppresses `release: published` events for releases
# made by GITHUB_TOKEN (to prevent recursive workflow chains). So we can't
# listen for `release: published` — it will never fire on stable releases.
#
# Instead, chain off the Release workflow via `workflow_run`, the same way
# `publish-repos.yml` does. `workflow_dispatch` is kept so a missed
# announcement can be replayed by hand.
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to announce (e.g. v0.23.0). Leave empty for latest stable."
required: false
type: string
workflow_run:
workflows: ["Release"]
types:
- completed
permissions:
contents: read
models: read
jobs:
notify:
if: >
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 0
- name: Resolve release tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
# `head_branch` of a workflow_run trigger is attacker-influenceable
# (anyone with push to a tag can choose its name), so we pass it via
# env and validate before use rather than splicing it into the
# shell script literally. See CodeQL actions/code-injection.
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
REPO: ${{ github.repository }}
run: |
if [[ -n "${INPUT_TAG:-}" ]]; then
TAG="${INPUT_TAG}"
elif [[ "${EVENT_NAME}" == "workflow_run" ]]; then
# The Release workflow runs on `push: tags: v*` so head_branch
# of the triggering run is the tag name. Reject anything that
# isn't a plain tag-shaped string to keep this resistant to
# shell metacharacters injected via a crafted ref name.
if [[ ! "${WORKFLOW_RUN_HEAD_BRANCH}" =~ ^[A-Za-z0-9._/-]+$ ]]; then
echo "::error::Refusing tag with unexpected characters: ${WORKFLOW_RUN_HEAD_BRANCH}"
exit 1
fi
TAG="${WORKFLOW_RUN_HEAD_BRANCH}"
else
TAG=$(gh release view --repo "${REPO}" --json tagName -q .tagName)
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved tag: ${TAG}"
- name: Skip pre-releases / missing releases
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
# Tag like `nightly-…` or `nightly` is never an announceable
# stable release. Short-circuit before hitting the API.
if [[ "${TAG}" == nightly* ]]; then
echo "Tag '${TAG}' is a rolling/nightly build, skipping Telegram post."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Only stable semver tags vX.Y.Z are eligible. Reject anything
# with a pre-release suffix (`-rc1`, `-beta`, etc.).
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag '${TAG}' is not a stable semver tag, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Confirm the release exists and isn't marked prerelease in the
# GitHub UI — guards against someone manually flipping the flag.
RELEASE_JSON=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isPrerelease,tagName 2>/dev/null || echo "")
if [[ -z "${RELEASE_JSON}" ]]; then
echo "Release ${TAG} not found via gh — skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
IS_PRE=$(jq -r .isPrerelease <<< "${RELEASE_JSON}")
if [[ "${IS_PRE}" == "true" ]]; then
echo "Release ${TAG} is marked prerelease, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Collect commits between previous tag and current tag
id: commits
if: steps.gate.outputs.skip != 'true'
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
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}."
- 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
- 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
# 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}"
VERSION_NUM="${TAG#v}"
RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
DL="https://github.com/${REPO}/releases/download/${VERSION}"
# Build the API payload in one jq pass — keeps every literal
# newline, every angle bracket, and every quote correctly escaped
# for both shell and JSON.
PAYLOAD=$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg version "$VERSION" \
--arg changes "$ESCAPED_CHANGES" \
--arg dl "$DL" \
--arg vnum "$VERSION_NUM" \
--arg release_url "$RELEASE_URL" \
'{
chat_id: $chat_id,
parse_mode: "HTML",
disable_web_page_preview: true,
text: (
"<b>Donut Browser " + $version + " released</b>\n\n" +
$changes + "\n" +
"<b>Download</b>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_aarch64.dmg\">macOS (Apple Silicon)</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64.dmg\">macOS (Intel)</a>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64-setup.exe\">Windows x64</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_amd64.AppImage\">Linux x64</a>\n\n" +
"<a href=\"" + $release_url + "\">Full release notes</a>"
)
}')
# Use --fail-with-body so we surface Telegram's error JSON on 4xx/5xx
# instead of just a curl exit code.
RESPONSE=$(curl -sSL --fail-with-body \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage") \
|| { echo "::error::Telegram API call failed"; echo "$RESPONSE"; exit 1; }
if [ "$(jq -r .ok <<< "$RESPONSE")" != "true" ]; then
echo "::error::Telegram API rejected the message:"
jq . <<< "$RESPONSE"
exit 1
fi
echo "Posted to Telegram (message_id $(jq -r .result.message_id <<< "$RESPONSE"))"
+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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
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@cf5f1c29a8ac336af8568821ec41919923b05a83 #v1.45.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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
+164 -1
View File
@@ -11,7 +11,7 @@ donutbrowser/
│ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, pt, ru, zh)
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
@@ -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
@@ -60,6 +72,96 @@ donutbrowser/
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Translations (mandatory)
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
## Backend error codes (mandatory)
User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BAR", "params": { … } }` strings — never raw English (`format!("Failed to …")`). The frontend resolves the code via `translateBackendError(t, err)` from `src/lib/backend-errors.ts`. Adding a new code requires four parallel edits:
1. Emit the JSON from Rust:
```rust
return Err(serde_json::json!({ "code": "FOO_BAR" }).to_string());
// or with params:
return Err(serde_json::json!({ "code": "FOO_BAR", "params": { "n": "5" } }).to_string());
```
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
4. Add `backendErrors.fooBar` to all nine locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
## Sub-page Dialog mode
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
```tsx
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
Account
</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4">…</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
```
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
### Cross-component tab control
When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky:
```tsx
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
```
Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses.
## Keyboard shortcuts
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
- `matchesGroupDigit(event)` returns 19 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut:
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
4. Add `shortcuts.yourId` (label) to all nine locale files.
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
## Singletons
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
@@ -83,6 +185,16 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## App data directory naming
`src-tauri/src/app_dirs.rs::app_name()` returns `"DonutBrowserDev"` when `cfg!(debug_assertions)` is true, `"DonutBrowser"` otherwise. So release builds (anything built via `tauri build` / `cargo build --release`) write to:
- macOS — `~/Library/Application Support/DonutBrowser/`
- Linux — `~/.local/share/DonutBrowser/`
- Windows — `%LOCALAPPDATA%\DonutBrowser\`
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
@@ -104,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.
+233
View File
@@ -1,6 +1,239 @@
# 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
- creation button disaster recovery
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.0 [skip ci] (#357)
## v0.24.0 (2026-05-12)
### Features
- support latest camoufox
- full ui refresh
### Bug Fixes
- pass correct parameter for dns list selection
### Refactoring
- better error handling and prevention of creating ephemeral password protected profiles
- ui cleanup
- sync cleanup
- proxy spawn
### Maintenance
- chore: version bump
- chore: update dependencies
- chore: fix telegram notifications
- chore: fix issue validation
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
## v0.23.0 (2026-05-10)
### Features
- password protected profiles
- telegram notifications
### Refactoring
- reduce the number of s3 calls
### Documentation
- remove fossa badge
### Maintenance
- chore: version bump
- chore: logging
- chore: copy
- chore: optimize issue validation
- chore: linting
- ci(deps): bump the github-actions group with 3 updates (#348)
- chore: cleanup issue validation
- chore: update flake.nix for v0.22.7 [skip ci] (#341)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#349)
- deps(rust)(deps): bump tauri from 2.11.0 to 2.11.1 in /src-tauri (#346)
- deps(rust)(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
## v0.22.7 (2026-05-05)
### Refactoring
- cleanup
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.6 [skip ci] (#337)
## v0.22.6 (2026-05-03)
### Features
- vpn manipulation via the api
### Refactoring
- don't block ui on clade check
### Documentation
- update CHANGELOG.md and README.md for v0.22.5 [skip ci] (#327)
### Maintenance
- chore: version bump
- chore: rand bump
- chore: pnpm bump
- ci(deps): bump the github-actions group with 3 updates (#330)
- chore: update flake.nix for v0.22.5 [skip ci] (#328)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#331)
## v0.22.5 (2026-04-29)
### Bug Fixes
- declare libxdo as runtime dependency
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.4 [skip ci] (#324)
## v0.22.4 (2026-04-28)
### Maintenance
- chore: version bump
- chore: i18n
- chore: update flake.nix for v0.22.3 [skip ci] (#321)
## v0.22.3 (2026-04-27)
### Bug Fixes
- correct browser port mapping
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.2 [skip ci] (#315)
## v0.22.2 (2026-04-27)
### Refactoring
- cookie management
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.1 [skip ci] (#313)
## v0.22.1 (2026-04-27)
### Bug Fixes
+1 -1
View File
@@ -73,7 +73,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
## Key Rules
- **Translations**: Any UI text changes must be reflected in all 7 locale files (`src/i18n/locales/`)
- **Translations**: Any UI text changes must be reflected in all 9 locale files (`src/i18n/locales/`)
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
+29 -11
View File
@@ -16,15 +16,9 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
</a>
<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" />
@@ -33,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
@@ -51,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.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:
@@ -61,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.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.22.1/Donut_0.22.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut-0.22.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut-0.22.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.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:
@@ -140,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"/>
@@ -162,6 +164,22 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
</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>
</table>
<!-- readme: collaborators,contributors -end -->
+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

+12 -12
View File
@@ -18,33 +18,33 @@
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1024.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0",
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.18",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.2",
"@types/node": "^25.7.0",
"@types/supertest": "^7.2.0",
"jest": "^30.3.0",
"jest": "^30.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.2"
"typescript": "^6.0.3"
},
"jest": {
"moduleFileExtensions": [
+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 {
+1 -1
View File
@@ -117,7 +117,7 @@ export class SyncController {
@Get("subscribe")
@Sse()
subscribe(@Req() req: Request): Observable<MessageEvent> {
return this.syncService.subscribe(this.getUserContext(req), 2000).pipe(
return this.syncService.subscribe(this.getUserContext(req), 5000).pipe(
map((event) => ({
data: event,
})),
+246 -47
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
CreateBucketCommand,
DeleteObjectCommand,
@@ -41,6 +42,18 @@ import type {
SubscribeEventDto,
} from "./dto/sync.dto.js";
/**
* Marker object written under each scope (user / team / self-hosted root).
* Subscribers HEAD this object on each poll and only LIST when its ETag has
* changed, which keeps the steady-state polling cost down to one Class-B
* HeadObject per scope per poll instead of N Class-A ListObjectsV2 calls.
*
* Filename starts with a dot so it sorts first and is unmistakably internal
* to donut-sync; client `list()` calls strip it from results so it never
* leaks into application data.
*/
const MANIFEST_KEY = ".donut-sync-manifest";
@Injectable()
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
@@ -149,6 +162,71 @@ export class SyncService implements OnModuleInit {
return `${ctx.prefix}${key}`;
}
/**
* Return every scope prefix the given user can write to. For self-hosted
* that's the bucket root (`""`); for cloud that's the user prefix plus an
* optional team prefix.
*/
private scopesFor(ctx: UserContext): string[] {
if (ctx.mode === "self-hosted") return [""];
const out = [ctx.prefix];
if (ctx.teamPrefix) out.push(ctx.teamPrefix);
return out;
}
/**
* Bump the manifest object for the scope that owns `scopedKey`. Writers call
* this fire-and-forget after any successful mutation so subscribers'
* cheap HEAD polls observe an ETag change and pull a fresh listing.
*
* Slightly over-eager by design: we bump on presign-issue (rather than on
* the actual S3 PUT), so a never-completed upload causes one wasted refresh
* on other devices. That's strictly cheaper than verifying every upload.
*/
private async bumpManifest(
ctx: UserContext,
scopedKey: string,
): Promise<void> {
const scope = this.scopeForKey(ctx, scopedKey);
if (scope === null) return;
const key = `${scope}${MANIFEST_KEY}`;
// Body just needs to be unique so the ETag changes; clients never read it.
const body = JSON.stringify({
updatedAt: new Date().toISOString(),
nonce: randomUUID(),
});
try {
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: "application/json",
}),
);
} catch (err) {
// Manifest bump failures must NEVER fail the user's request.
// Subscribers fall back to detecting changes on their next listing.
this.logger.warn(
`Manifest bump failed for ${key}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Resolve which scope owns a fully-scoped key. Returns null if the key
* doesn't belong to a known scope (which shouldn't happen in practice
* because validateKeyAccess gates the write paths).
*/
private scopeForKey(ctx: UserContext, scopedKey: string): string | null {
if (ctx.mode === "self-hosted") return "";
if (ctx.teamPrefix && scopedKey.startsWith(ctx.teamPrefix)) {
return ctx.teamPrefix;
}
if (scopedKey.startsWith(ctx.prefix)) return ctx.prefix;
return null;
}
/**
* Validate that a key is accessible by the user.
* For cloud mode, key must start with user's prefix or team prefix.
@@ -178,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 (
@@ -211,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 });
@@ -220,6 +305,11 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
// Notify subscribers via the per-scope manifest. Fire-and-forget; a
// failure here just means other devices pick up the change on their
// next full listing instead of immediately.
void this.bumpManifest(ctx, key);
return {
url,
expiresAt: expiresAt.toISOString(),
@@ -294,6 +384,10 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
if (deleted || tombstoneCreated) {
void this.bumpManifest(ctx, key);
}
return { deleted, tombstoneCreated };
}
@@ -311,19 +405,22 @@ export class SyncService implements OnModuleInit {
const userPrefix = ctx?.prefix || "";
const teamPrefix = ctx?.teamPrefix || "";
const objects = (response.Contents || []).map((obj) => {
let key = obj.Key || "";
if (teamPrefix && key.startsWith(teamPrefix)) {
key = key.substring(teamPrefix.length);
} else if (userPrefix && key.startsWith(userPrefix)) {
key = key.substring(userPrefix.length);
}
return {
key,
lastModified: obj.LastModified?.toISOString() || "",
size: obj.Size || 0,
};
});
const objects = (response.Contents || [])
// Don't leak donut-sync's internal manifest object to clients.
.filter((obj) => !(obj.Key || "").endsWith(MANIFEST_KEY))
.map((obj) => {
let key = obj.Key || "";
if (teamPrefix && key.startsWith(teamPrefix)) {
key = key.substring(teamPrefix.length);
} else if (userPrefix && key.startsWith(userPrefix)) {
key = key.substring(userPrefix.length);
}
return {
key,
lastModified: obj.LastModified?.toISOString() || "",
size: obj.Size || 0,
};
});
return {
objects,
@@ -373,6 +470,20 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
// One bump per scope touched by this batch (usually one).
if (items.length > 0) {
const scopesSeen = new Set<string>();
for (const item of dto.items) {
const key = this.scopeKey(ctx, item.key);
const scope = this.scopeForKey(ctx, key);
if (scope !== null && !scopesSeen.has(scope)) {
scopesSeen.add(scope);
// Use any key from the scope; bumpManifest only inspects scope.
void this.bumpManifest(ctx, key);
}
}
}
return { items };
}
@@ -475,66 +586,154 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
if (deletedCount > 0 || tombstoneCreated) {
void this.bumpManifest(ctx, prefix);
}
return { deletedCount, tombstoneCreated };
}
/**
* Long-lived per-client poll loop.
*
* Steady-state cost is one HEAD per scope per poll (Class B on R2). A LIST
* (Class A) is only issued when:
* 1. it's the client's first poll (need to seed the state map), or
* 2. a write touched the scope and bumped its manifest ETag.
*
* This is *eventual* cross-device sync, gated by the poll interval.
* Real-time push is intentionally not provided here — that lives in the
* paid backend.
*/
subscribe(
ctx: UserContext,
pollIntervalMs = 2000,
pollIntervalMs = 5000,
): Observable<SubscribeEventDto> {
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
const scopes = this.scopesFor(ctx);
let prefixes: string[];
if (ctx.mode === "self-hosted") {
prefixes = basePrefixes;
} else {
prefixes = basePrefixes.map((p) => `${ctx.prefix}${p}`);
if (ctx.teamPrefix) {
prefixes.push(...basePrefixes.map((p) => `${ctx.teamPrefix}${p}`));
}
}
// Per-connection state (not shared across subscribers)
// Per-connection state (not shared across subscribers).
const lastManifestEtag = new Map<string, string | undefined>();
let lastKnownState = new Map<string, string>();
let initialized = false;
const pollChanges$ = interval(pollIntervalMs).pipe(
startWith(0),
switchMap(async () => {
const events: SubscribeEventDto[] = [];
const currentState = new Map<string, string>();
for (const prefix of prefixes) {
// Phase 1 — cheap HEAD on each scope's manifest. This is the
// steady-state cost (Class B). If no manifest changed since the
// last poll, we don't touch S3 again this tick.
let anyScopeChanged = false;
for (const scope of scopes) {
const manifestKey = `${scope}${MANIFEST_KEY}`;
let currentEtag: string | undefined;
try {
const result = await this.list({ prefix, maxKeys: 1000 });
for (const obj of result.objects) {
const stateKey = `${obj.key}:${obj.lastModified}`;
currentState.set(obj.key, stateKey);
const previousStateKey = lastKnownState.get(obj.key);
if (previousStateKey !== stateKey) {
events.push({
type: "change",
key: obj.key,
lastModified: obj.lastModified,
size: obj.size,
});
}
const head = await this.s3Client.send(
new HeadObjectCommand({
Bucket: this.bucket,
Key: manifestKey,
}),
);
currentEtag = head.ETag;
} catch (err: unknown) {
const status =
err && typeof err === "object" && "$metadata" in err
? (err as { $metadata?: { httpStatusCode?: number } }).$metadata
?.httpStatusCode
: undefined;
const name =
err && typeof err === "object" && "name" in err
? (err as { name?: string }).name
: undefined;
if (name === "NotFound" || name === "NoSuchKey" || status === 404) {
// No manifest yet — treat as "no changes" (undefined ETag).
currentEtag = undefined;
} else {
this.logger.error(
`Manifest HEAD failed for ${manifestKey}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
}
} catch (error) {
console.error(`Failed to list prefix ${prefix}:`, error);
}
const previousEtag = lastManifestEtag.get(scope);
if (previousEtag !== currentEtag) {
anyScopeChanged = true;
}
lastManifestEtag.set(scope, currentEtag);
}
// After the first poll, only run the LIST when something actually
// changed in at least one scope.
if (initialized && !anyScopeChanged) {
return [];
}
// Phase 2 — one LIST per scope (not per base prefix). Filter to the
// four base prefixes client-side. This is the cost we pay only when
// a manifest told us there's something new to look at.
const currentState = new Map<string, string>();
for (const scope of scopes) {
let continuationToken: string | undefined;
do {
try {
const result = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: scope,
MaxKeys: 1000,
ContinuationToken: continuationToken,
}),
);
for (const obj of result.Contents || []) {
const fullKey = obj.Key;
if (!fullKey) continue;
const relativeKey = fullKey.startsWith(scope)
? fullKey.substring(scope.length)
: fullKey;
// Skip the manifest object itself + anything outside the
// four data prefixes.
if (relativeKey === MANIFEST_KEY) continue;
if (!basePrefixes.some((bp) => relativeKey.startsWith(bp))) {
continue;
}
const lastModified = obj.LastModified?.toISOString() || "";
const stateKey = `${relativeKey}:${lastModified}`;
currentState.set(relativeKey, stateKey);
const previousStateKey = lastKnownState.get(relativeKey);
if (previousStateKey !== stateKey) {
events.push({
type: "change",
key: relativeKey,
lastModified,
size: obj.Size || 0,
});
}
}
continuationToken = result.NextContinuationToken;
} catch (err) {
this.logger.error(
`List failed for scope '${scope}': ${err instanceof Error ? err.message : String(err)}`,
);
continuationToken = undefined;
}
} while (continuationToken);
}
// Detect deletes by comparing key sets.
for (const [key] of lastKnownState) {
if (!currentState.has(key)) {
events.push({
type: "delete",
key,
});
events.push({ type: "delete", key });
}
}
lastKnownState = currentState;
initialized = true;
return events;
}),
switchMap((events) => of(...events)),
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1767767207,
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
+7 -5
View File
@@ -34,6 +34,7 @@
libsoup_3
glib
gtk3
libayatana-appindicator
cairo
gdk-pixbuf
pango
@@ -84,6 +85,7 @@
pkgs.gdk-pixbuf
pkgs.glib
pkgs.gtk3
pkgs.libayatana-appindicator
pkgs.libsoup_3
pkgs.libxkbcommon
pkgs.openssl
@@ -94,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.22.1";
releaseVersion = "0.24.4";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.1/Donut_0.22.1_amd64.AppImage";
hash = "sha256-A/NGe6c+5O47p6cKFf9JKcjiVjebNXtinBWA/Y3bSD0=";
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.22.1/Donut_0.22.1_aarch64.AppImage";
hash = "sha256-U5nfbWPmgH1+FdAvOqCEpb+yDH0cHXYgHgJQdjIAAIQ=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage";
hash = "sha256-kdEzMO53bCUH7E8GPDewnIDLRIO5pWlO8B4TdpLAQIg=";
}
else
null;
+29 -31
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.22.2",
"version": "0.25.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -16,7 +16,7 @@
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "tauri",
"tauri": "node scripts/run-with-env.mjs tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
@@ -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",
@@ -45,58 +46,55 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "~2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "~2.5.0",
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "~2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.9",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "~2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@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",
"i18next": "^26.0.3",
"lucide-react": "^1.7.0",
"framer-motion": "^12.38.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.3",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-icons": "^5.6.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "~2.10.1",
"@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.5.2",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"tailwindcss": "^4.2.2",
"lint-staged": "^17.0.4",
"tailwindcss": "^4.3.0",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2"
"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"
}
},
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@11.2.2",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+2390 -3034
View File
File diff suppressed because it is too large Load Diff
+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
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Wrapper that loads `.env` into process.env (without overwriting anything
// already in the environment) and execs the given command. Used by the
// `tauri` npm script so `pnpm tauri build` picks up APPLE_SIGNING_IDENTITY,
// APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. without requiring direnv.
//
// Plain shell `source .env` works on macOS/Linux but not Windows; this
// wrapper is platform-agnostic.
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const envPath = resolve(projectRoot, ".env");
if (existsSync(envPath)) {
const content = readFileSync(envPath, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
// Don't overwrite values already exported by the parent shell — direnv
// / CI secrets / one-off `FOO=bar pnpm tauri ...` invocations win.
if (process.env[key] === undefined) {
process.env[key] = val;
}
}
}
const [, , cmd, ...args] = process.argv;
if (!cmd) {
console.error("usage: run-with-env.mjs <command> [args...]");
process.exit(2);
}
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
child.on("error", (err) => {
console.error(`Failed to spawn ${cmd}:`, err.message);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code ?? 1);
}
});
+12 -1
View File
@@ -171,10 +171,21 @@ async function startMinio(minioBin) {
async function buildDonutSync() {
log("Building donut-sync...");
// `nest build` runs incremental tsc, which silently skips emit when
// tsconfig.build.tsbuildinfo says nothing changed — even if dist/ was
// wiped. Drop the cache so we always produce a fresh dist.
const syncDir = path.join(ROOT_DIR, "donut-sync");
await rm(path.join(syncDir, "tsconfig.build.tsbuildinfo"), {
force: true,
});
await rm(path.join(syncDir, "dist"), { recursive: true, force: true });
execSync("pnpm build", {
cwd: path.join(ROOT_DIR, "donut-sync"),
cwd: syncDir,
stdio: process.env.VERBOSE ? "inherit" : "ignore",
});
if (!existsSync(path.join(syncDir, "dist", "main.js"))) {
throw new Error("donut-sync build did not produce dist/main.js");
}
log("donut-sync built");
}
+611 -891
View File
File diff suppressed because it is too large Load Diff
+11 -15
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.22.2"
version = "0.25.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -24,10 +24,6 @@ path = "src/main.rs"
name = "donut-proxy"
path = "src/bin/proxy_server.rs"
[[bin]]
name = "donut-daemon"
path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.47"
@@ -35,7 +31,7 @@ resvg = "0.47"
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "test"] }
tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
@@ -44,6 +40,7 @@ tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
tauri-plugin-clipboard-manager = "2"
log = "0.4"
env_logger = "0.11"
@@ -51,7 +48,7 @@ directories = "6"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7"
sysinfo = "0.38"
sysinfo = "0.39"
lazy_static = "1.5"
base64 = "0.22"
libc = "0.2"
@@ -86,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"] }
@@ -97,21 +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 = "1.1"
thiserror = "2.0"
regex-lite = "0.1"
tempfile = "3"
maxminddb = "0.27"
quick-xml = { version = "0.39", features = ["serialize"] }
maxminddb = "0.28"
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.22"
tao = "0.35"
# Tray icon decoding (main-process system tray)
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
@@ -143,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() {
+14 -1
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",
@@ -41,6 +52,8 @@
"macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission",
"log:default"
"log:default",
"clipboard-manager:default",
"clipboard-manager:allow-write-text"
]
}
-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"
+286 -28
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;
@@ -41,6 +40,7 @@ pub struct ApiProfile {
pub tags: Vec<String>,
pub is_running: bool,
pub proxy_bypass_rules: Vec<String>,
pub vpn_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -60,6 +60,7 @@ pub struct CreateProfileRequest {
pub browser: String,
pub version: String,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
@@ -76,6 +77,7 @@ pub struct UpdateProfileRequest {
pub browser: Option<String>,
pub version: Option<String>,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
@@ -84,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)]
@@ -140,6 +144,16 @@ struct ApiVpnResponse {
last_used: Option<i64>,
}
#[derive(Debug, Serialize, ToSchema)]
struct ApiVpnExportResponse {
id: String,
name: String,
/// Always "WireGuard"
vpn_type: String,
/// Raw `.conf` file content (decrypted)
config_data: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct ImportVpnRequest {
/// Raw WireGuard `.conf` file content
@@ -202,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(
@@ -213,6 +241,7 @@ struct OpenUrlRequest {
run_profile,
open_url_in_profile,
kill_profile,
import_profile_cookies,
get_groups,
get_group,
create_group,
@@ -255,6 +284,8 @@ struct OpenUrlRequest {
RunProfileResponse,
RunProfileRequest,
OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
ProxySettings,
)),
tags(
@@ -264,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),
)]
@@ -350,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))
@@ -357,6 +390,7 @@ impl ApiServer {
.routes(routes!(get_proxy, update_proxy, delete_proxy))
.routes(routes!(get_vpns, create_vpn))
.routes(routes!(import_vpn))
.routes(routes!(export_vpn))
.routes(routes!(get_vpn, update_vpn, delete_vpn))
.routes(routes!(get_extensions))
.routes(routes!(delete_extension_api))
@@ -377,16 +411,18 @@ 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.
.layer(middleware::from_fn(request_logging_middleware))
.layer(CorsLayer::permissive())
.with_state(state);
@@ -440,6 +476,8 @@ async fn auth_middleware(
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
let path = request.uri().path().to_string();
// Get the Authorization header
let auth_header = headers
.get("Authorization")
@@ -448,19 +486,31 @@ async fn auth_middleware(
let token = match auth_header {
Some(token) => token,
None => return Err(StatusCode::UNAUTHORIZED),
None => {
log::warn!("[api] Rejected {path}: missing Authorization header");
return Err(StatusCode::UNAUTHORIZED);
}
};
// Get the stored token
let settings_manager = crate::settings_manager::SettingsManager::instance();
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
Ok(None) => {
log::warn!(
"[api] Rejected {path}: API server has no stored token (was the API toggled off?)"
);
return Err(StatusCode::UNAUTHORIZED);
}
Err(e) => {
log::error!("[api] Failed to read stored API token: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
// Compare tokens
if token != stored_token {
log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED);
}
@@ -468,6 +518,38 @@ async fn auth_middleware(
Ok(next.run(request).await)
}
/// Logs every request: method, path, query, response status, duration.
/// Skips Authorization header and request bodies entirely.
async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let query = request.uri().query().map(|q| q.to_string());
let started = std::time::Instant::now();
let response = next.run(request).await;
let status = response.status();
let elapsed_ms = started.elapsed().as_millis();
let level = if status.is_server_error() {
log::Level::Error
} else if status.is_client_error() {
log::Level::Warn
} else {
log::Level::Info
};
match query {
Some(q) => log::log!(
level,
"[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)"
),
None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"),
}
response
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
@@ -504,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,
@@ -520,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
@@ -534,14 +637,12 @@ 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
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
})
.collect();
@@ -576,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) {
@@ -590,14 +694,12 @@ 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
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
},
}))
} else {
@@ -628,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 {
@@ -643,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(
@@ -652,7 +769,7 @@ async fn create_profile(
&request.version,
request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(),
None, // vpn_id
request.vpn_id.clone(),
camoufox_config,
wayfern_config,
request.group_id.clone(),
@@ -692,14 +809,12 @@ 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,
proxy_bypass_rules: profile.proxy_bypass_rules,
vpn_id: profile.vpn_id,
},
}))
}
@@ -733,6 +848,12 @@ async fn update_profile(
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty())
&& request.vpn_id.as_deref().is_some_and(|s| !s.is_empty())
{
return Err(StatusCode::BAD_REQUEST);
}
// Update profile fields
if let Some(new_name) = request.name {
if profile_manager
@@ -762,6 +883,21 @@ async fn update_profile(
}
}
if let Some(vpn_id) = request.vpn_id {
let normalized = if vpn_id.is_empty() {
None
} else {
Some(vpn_id)
};
if profile_manager
.update_profile_vpn(state.app_handle.clone(), &id, normalized)
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(launch_hook) = request.launch_hook {
let normalized = if launch_hook.trim().is_empty() {
None
@@ -841,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
}
@@ -1308,6 +1453,37 @@ async fn get_vpn(
.ok_or(StatusCode::NOT_FOUND)
}
#[utoipa::path(
get,
path = "/v1/vpns/{id}/export",
params(("id" = String, Path, description = "VPN configuration ID")),
responses(
(status = 200, description = "Decrypted VPN configuration", body = ApiVpnExportResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "VPN configuration not found"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn export_vpn(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<ApiVpnExportResponse>, StatusCode> {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match storage.load_config(&id) {
Ok(config) => Ok(Json(ApiVpnExportResponse {
id: config.id,
name: config.name,
vpn_type: config.vpn_type.to_string(),
config_data: config.config_data,
})),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
#[utoipa::path(
post,
path = "/v1/vpns/import",
@@ -1584,16 +1760,27 @@ async fn run_profile(
.await
.map_err(|_| StatusCode::CONFLICT)?;
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
let remote_debugging_port = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let port = listener
.local_addr()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.port();
drop(listener);
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
{
@@ -1690,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,
+29 -12
View File
@@ -928,18 +928,35 @@ impl AppAutoUpdater {
// Move new app to current location
fs::rename(installer_path, &current_app_path)?;
// Remove quarantine attributes from the new app
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
let _ = Command::new("xattr")
.args(["-cr", current_app_path.to_str().unwrap()])
.output();
// Remove the macOS quarantine attribute from the freshly-installed app
// so Gatekeeper doesn't block its first launch — but only if it's
// actually present. macOS Sequoia's App Management TCC fires on the
// modify-class syscall regardless of whether anything is actually
// modified, so we gate the call behind a read-only `getxattr` check.
let needs_quarantine_removal = {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok();
let attr_c = CString::new("com.apple.quarantine").ok();
match (path_c, attr_c) {
(Some(p), Some(a)) => {
// SAFETY: getxattr with a null buffer is a read-only size query.
let result =
unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
result >= 0
}
_ => false,
}
};
if needs_quarantine_removal {
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
}
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
+39
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");
}
@@ -108,6 +133,20 @@ pub fn dns_blocklist_dir() -> PathBuf {
cache_dir().join("dns_blocklists")
}
/// Resolve the directory that tauri-plugin-log writes to. Mirrors the
/// `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()
.app_log_dir()
.unwrap_or_else(|_| std::env::temp_dir())
}
#[cfg(test)]
thread_local! {
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
+3
View File
@@ -701,6 +701,9 @@ mod tests {
created_by_id: None,
created_by_email: None,
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);
}
}
}
+17 -10
View File
@@ -82,9 +82,14 @@ fn build_proxy_url(
#[tokio::main(flavor = "multi_thread")]
async fn main() {
// Initialize logger to write to stderr (which will be redirected to file)
// Initialize logger to write to stderr (which will be redirected to file).
//
// Default filter is Info — Debug pulls in reqwest/hyper internals which
// make the per-worker log unreadable on a busy browser session and obscure
// the actual lines we care about (binds, accept errors, upstream failures).
// RUST_LOG=debug or RUST_LOG=donut_proxy=trace still works for deep dives.
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.init();
@@ -343,8 +348,11 @@ async fn main() {
// Set high priority so this process is killed last under resource pressure
set_high_priority();
log::error!("Proxy worker starting, looking for config id: {}", id);
log::error!("Process PID: {}", std::process::id());
log::info!(
"Proxy worker starting (pid {}, config id {})",
std::process::id(),
id
);
// Retry config loading to handle file system race condition on Windows
// where the config file may not be immediately visible after being written
@@ -352,7 +360,7 @@ async fn main() {
let mut attempts = 0;
loop {
if let Some(config) = get_proxy_config(id) {
log::error!(
log::info!(
"Found config: id={}, port={:?}, upstream={}",
config.id,
config.local_port,
@@ -369,20 +377,19 @@ async fn main() {
);
process::exit(1);
}
log::error!("Config {} not found yet, retrying ({}/10)...", id, attempts);
log::debug!("Config {} not found yet, retrying ({}/10)...", id, attempts);
std::thread::sleep(std::time::Duration::from_millis(50));
}
};
// Run the proxy server - this should never return (infinite loop)
log::error!("Starting proxy server for config id: {}", id);
log::info!("Starting proxy server for config id: {}", id);
if let Err(e) = run_proxy_server(config).await {
log::error!("Failed to run proxy server: {}", e);
log::error!("Error details: {:?}", e);
log::error!("Proxy server failed: {} ({:?})", e, e);
process::exit(1);
}
// This should never be reached - run_proxy_server has an infinite loop
log::error!("ERROR: Proxy server returned unexpectedly (this should never happen)");
log::error!("Proxy server returned unexpectedly (this should never happen)");
process::exit(1);
} else {
log::error!("Invalid action for proxy-worker. Use 'start'");
+3
View File
@@ -1218,6 +1218,9 @@ mod tests {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+146 -194
View File
@@ -11,6 +11,7 @@ use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System;
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -83,32 +84,73 @@ impl BrowserRunner {
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
async fn resolve_launch_hook_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
let Some(url) = profile.launch_hook.as_deref() else {
return Ok(None);
fn fire_launch_hook(profile: &BrowserProfile) {
let Some(raw_url) = profile.launch_hook.as_deref() else {
return;
};
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return;
}
let parsed = match url::Url::parse(trimmed) {
Ok(u) => u,
Err(e) => {
log::warn!(
"Skipping launch hook for profile {} (ID: {}): invalid URL: {e}",
profile.name,
profile.id
);
return;
}
};
log::info!(
"Calling launch hook for profile {} (ID: {})",
profile.name,
profile.id
);
if !matches!(parsed.scheme(), "http" | "https") {
log::warn!(
"Skipping launch hook for profile {} (ID: {}): URL must be http or https",
profile.name,
profile.id
);
return;
}
PROXY_MANAGER
.fetch_proxy_from_url(url, Duration::from_millis(500))
.await
let url = parsed.to_string();
let profile_name = profile.name.clone();
let profile_id = profile.id.to_string();
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})");
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(e) => {
log::warn!("Launch hook client build failed for {url}: {e}");
return;
}
};
match client.get(&url).send().await {
Ok(resp) => {
log::info!(
"Launch hook {url} for profile {profile_name} returned status {}",
resp.status()
);
}
Err(e) => {
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}");
}
}
});
}
async fn resolve_launch_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? {
return Ok(Some(proxy_settings));
}
Self::fire_launch_hook(profile);
self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
@@ -291,8 +333,12 @@ impl BrowserRunner {
);
}
// Create ephemeral dir for ephemeral profiles
let override_profile_path = if profile.ephemeral {
// Create ephemeral dir for ephemeral or password-protected profiles
let override_profile_path = if profile.password_protected {
let dir = crate::profile::password::prepare_for_launch(profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir)
} else if profile.ephemeral {
let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir)
@@ -335,6 +381,7 @@ impl BrowserRunner {
camoufox_config,
url,
override_profile_path,
remote_debugging_port,
headless,
)
.await
@@ -526,10 +573,12 @@ impl BrowserRunner {
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
// Save the updated fingerprint to the profile so it persists
// Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint);
// 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();
}
@@ -542,8 +591,11 @@ impl BrowserRunner {
);
}
// Create ephemeral dir for ephemeral profiles
if profile.ephemeral {
// Create ephemeral dir for ephemeral or password-protected profiles
if profile.password_protected {
crate::profile::password::prepare_for_launch(profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
} else if profile.ephemeral {
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
}
@@ -604,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());
@@ -785,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(
@@ -1431,7 +1463,12 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
if profile.password_protected {
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -1771,7 +1808,12 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
if profile.password_protected {
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -2235,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: {})",
@@ -2264,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
@@ -2288,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
@@ -2550,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,
+63 -16
View File
@@ -12,6 +12,7 @@ use crate::camoufox::env_vars;
use crate::camoufox::fingerprint::types::*;
use crate::camoufox::fonts;
use crate::camoufox::geolocation;
use crate::camoufox::presets;
use crate::camoufox::webgl;
/// Browserforge mapping from YAML.
@@ -307,10 +308,59 @@ impl CamoufoxConfigBuilder {
}
/// Build the complete Camoufox launch configuration.
///
/// Prefers a real-fingerprint preset (matched against the Camoufox build's
/// Firefox version via `presets::preset_line_for`) when no explicit
/// fingerprint was passed. Falls back to the Bayesian network-based
/// synthesizer when presets are unavailable, so callers without a known
/// Firefox version (or with no preset for the requested OS) still get a
/// valid config — matching pre-v150 behaviour byte-for-byte.
pub fn build(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
// Generate or use provided fingerprint
let fingerprint = if let Some(fp) = self.fingerprint {
fp
let mut rng = rand::rng();
let ff_version = self.ff_version;
// 1) The caller supplied a fingerprint outright — honour it and skip
// presets entirely. This is the path tests and advanced consumers
// use to inject deterministic fixtures.
// 2) Otherwise, try a bundled preset for the requested OS / FF line.
// 3) Fall back to the Bayesian generator. This is also the path that
// runs for users whose Camoufox binary has no readable `version.json`
// (`ff_version == None`), or whose OS has no presets bundled.
let (mut config, target_os) = if let Some(fp) = self.fingerprint {
let target_os = env_vars::determine_ua_os(&fp.navigator.user_agent);
// `from_browserforge` already runs `handle_screen_xy` internally.
let config = from_browserforge(&fp, ff_version);
(config, target_os)
} else if let Some(preset) =
presets::get_random_preset(self.operating_system.as_deref(), ff_version)
{
let mut config = presets::from_preset(&preset, ff_version);
let target_os = config
.get("navigator.userAgent")
.and_then(|v| v.as_str())
.map(env_vars::determine_ua_os)
.or_else(|| {
// Last-resort heuristic from the platform string — keeps target_os
// sensible even if a preset somehow omits the user agent.
config
.get("navigator.platform")
.and_then(|v| v.as_str())
.map(|p| match p {
"Win32" => "windows",
"MacIntel" => "macos",
_ => "linux",
})
})
.unwrap_or("macos");
// Presets don't carry multi-monitor offsets, so default screenX/Y to
// (0, 0) — matches what real single-display users send.
config
.entry("window.screenX".to_string())
.or_insert(serde_json::json!(0));
config
.entry("window.screenY".to_string())
.or_insert(serde_json::json!(0));
(config, target_os)
} else {
let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?;
let options = FingerprintOptions {
@@ -320,21 +370,18 @@ impl CamoufoxConfigBuilder {
screen: self.screen_constraints,
..Default::default()
};
generator.get_fingerprint(&options)?.fingerprint
let fingerprint = generator.get_fingerprint(&options)?.fingerprint;
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
let config = from_browserforge(&fingerprint, ff_version);
(config, target_os)
};
// Determine target OS from user agent
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
// Convert fingerprint to config
let mut config = from_browserforge(&fingerprint, self.ff_version);
// Add random window history length
let mut rng = rand::rng();
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 {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -7,3 +7,21 @@ pub const FONTS_JSON: &str = include_str!("fonts.json");
pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml");
pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db");
pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml");
/// Real fingerprint presets bundled with the original Camoufox v135 line
/// (Firefox <= 148). Frozen upstream — kept around so users who haven't
/// upgraded their Camoufox binary keep getting matched fingerprints.
/// Mirrors `pythonlib/camoufox/fingerprint-presets.json` upstream.
pub const FINGERPRINT_PRESETS_V135_JSON: &str = include_str!("fingerprint-presets-v135.json");
/// Real fingerprint presets for every Camoufox release after the v135 line
/// (currently Firefox 149+ via the v150 build). This file is expected to
/// be refreshed regularly as upstream Camoufox tracks newer Firefox
/// releases — we keep the upstream filename here so each refresh is a
/// straight `cp` from `pythonlib/camoufox/fingerprint-presets-v150.json`.
pub const FINGERPRINT_PRESETS_NEWER_JSON: &str = include_str!("fingerprint-presets-v150.json");
/// Firefox major version at which the newer preset bundle takes over from
/// the frozen v135 bundle. Matches `PRESETS_V150_MIN_FF` in
/// `pythonlib/camoufox/fingerprints.py`.
pub const PRESETS_NEWER_MIN_FF: u32 = 149;
+1
View File
@@ -43,6 +43,7 @@ pub mod fingerprint;
pub mod fonts;
pub mod geolocation;
pub mod launcher;
pub mod presets;
pub mod webgl;
// Re-export main types for convenience
+405
View File
@@ -0,0 +1,405 @@
//! Real-fingerprint preset support for Camoufox.
//!
//! Mirrors the preset-selection logic from
//! `pythonlib/camoufox/fingerprints.py` (`_select_presets_file`,
//! `load_presets`, `get_random_preset`, `from_preset`).
//!
//! Camoufox ships two bundled preset files:
//! - `fingerprint-presets-v135.json` — real fingerprints harvested from
//! browsers running Firefox ≤148. The frozen "v135 line" — kept around
//! so users who haven't upgraded their Camoufox binary keep getting
//! consistent fingerprints.
//! - `fingerprint-presets-v150.json` — the *newer* bundle, refreshed by
//! upstream as Camoufox tracks newer Firefox versions. This is the
//! bundle every newer Camoufox release uses; we make no assumption that
//! Firefox 150 is the ceiling.
//!
//! At launch we know the bundled Firefox version (see
//! `config::get_firefox_version`) and pick `v135` or `newer` accordingly.
//! The split point lives in `data::PRESETS_NEWER_MIN_FF` (currently 149)
//! and is the only number we hard-code — anything ≥ that gets the newer
//! bundle, regardless of how far Firefox itself has moved on.
//!
//! Falling back to Bayesian-network synthesis (the previous default) is
//! still possible when no preset matches the requested OS.
use rand::prelude::IndexedRandom;
use regex_lite::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::camoufox::data;
#[derive(Debug, Clone, Deserialize)]
pub struct Navigator {
#[serde(rename = "userAgent")]
pub user_agent: Option<String>,
pub platform: Option<String>,
#[serde(rename = "hardwareConcurrency")]
pub hardware_concurrency: Option<u32>,
#[serde(rename = "maxTouchPoints")]
pub max_touch_points: Option<u32>,
pub oscpu: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Screen {
pub width: Option<u32>,
pub height: Option<u32>,
#[serde(rename = "colorDepth")]
pub color_depth: Option<u32>,
#[serde(rename = "availWidth")]
pub avail_width: Option<u32>,
#[serde(rename = "availHeight")]
pub avail_height: Option<u32>,
#[serde(rename = "devicePixelRatio")]
pub device_pixel_ratio: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebGl {
#[serde(rename = "unmaskedVendor")]
pub unmasked_vendor: Option<String>,
#[serde(rename = "unmaskedRenderer")]
pub unmasked_renderer: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Preset {
#[serde(default)]
pub navigator: Option<Navigator>,
#[serde(default)]
pub screen: Option<Screen>,
#[serde(default)]
pub webgl: Option<WebGl>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub fonts: Option<Vec<String>>,
#[serde(rename = "speechVoices", default)]
pub speech_voices: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PresetBundle {
/// Bundle schema version — upstream writes this as a JSON integer (e.g.
/// `1`), so we accept any JSON shape here and ignore it. Only the
/// `presets` map matters at runtime.
#[allow(dead_code)]
#[serde(default)]
pub version: Option<serde_json::Value>,
#[serde(default)]
pub presets: HashMap<String, Vec<Preset>>,
}
/// Which Camoufox release line the active binary belongs to. Determines
/// which preset bundle to load. The set is intentionally just two-valued:
/// the legacy v135 line and "everything newer" — upstream refreshes the
/// newer bundle as Firefox versions advance, but our routing logic stays
/// the same.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PresetLine {
V135,
Newer,
}
/// Pick the preset line that matches a Firefox major version, mirroring
/// `_select_presets_file` in the Python lib. Unknown / very old versions
/// fall back to the v135 bundle so the older Camoufox builds keep working.
pub fn preset_line_for(ff_version: Option<u32>) -> PresetLine {
match ff_version {
Some(v) if v >= data::PRESETS_NEWER_MIN_FF => PresetLine::Newer,
_ => PresetLine::V135,
}
}
/// Cache the parsed bundles forever — they're static, embedded data and
/// parsing the newer file twice would waste a few megs of CPU work on
/// every launch.
static V135_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
static NEWER_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
fn parse_bundle(json: &str) -> Option<PresetBundle> {
match serde_json::from_str::<PresetBundle>(json) {
Ok(b) => Some(b),
Err(e) => {
log::warn!("camoufox preset bundle failed to parse: {e}");
None
}
}
}
pub fn load_presets(line: PresetLine) -> Option<&'static PresetBundle> {
let slot = match line {
PresetLine::V135 => &V135_BUNDLE,
PresetLine::Newer => &NEWER_BUNDLE,
};
slot
.get_or_init(|| match line {
PresetLine::V135 => parse_bundle(data::FINGERPRINT_PRESETS_V135_JSON),
PresetLine::Newer => parse_bundle(data::FINGERPRINT_PRESETS_NEWER_JSON),
})
.as_ref()
}
/// Normalize the OS string the rest of the codebase uses ("macos", "windows",
/// "linux") to the preset key. Returns `None` for OSes that don't have any
/// presets bundled.
fn normalize_os(os: &str) -> Option<&'static str> {
match os {
"windows" | "win" => Some("windows"),
"macos" | "mac" | "darwin" => Some("macos"),
"linux" | "lin" => Some("linux"),
_ => None,
}
}
/// Pick a random preset for the requested OS. `None` if there are no
/// presets bundled for that OS (which can happen in tests with reduced
/// fixtures, or if a new OS is added before its preset bundle ships).
pub fn get_random_preset(os: Option<&str>, ff_version: Option<u32>) -> Option<Preset> {
let bundle = load_presets(preset_line_for(ff_version))?;
let candidates: Vec<&Preset> = match os.and_then(normalize_os) {
Some(os_key) => bundle.presets.get(os_key).map(|v| v.iter().collect())?,
None => bundle.presets.values().flatten().collect(),
};
if candidates.is_empty() {
return None;
}
candidates.choose(&mut rand::rng()).map(|p| (*p).clone())
}
/// Match python's `from_preset` — translate a real-fingerprint preset into
/// the CAMOU_CONFIG-style HashMap the rest of the launcher expects.
///
/// The caller is responsible for filling in fonts, voices, and the random
/// seeds; those are intentionally left out here so each call site can layer
/// its own RNG and font policy.
pub fn from_preset(preset: &Preset, ff_version: Option<u32>) -> HashMap<String, serde_json::Value> {
let mut config: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(nav) = &preset.navigator {
if let Some(ua) = &nav.user_agent {
let ua = if let Some(v) = ff_version {
rewrite_ua_firefox_version(ua, v)
} else {
ua.clone()
};
config.insert("navigator.userAgent".to_string(), serde_json::json!(ua));
}
if let Some(p) = &nav.platform {
config.insert("navigator.platform".to_string(), serde_json::json!(p));
}
if let Some(hc) = nav.hardware_concurrency {
config.insert(
"navigator.hardwareConcurrency".to_string(),
serde_json::json!(hc),
);
}
if let Some(mtp) = nav.max_touch_points {
config.insert(
"navigator.maxTouchPoints".to_string(),
serde_json::json!(mtp),
);
}
// navigator.oscpu — explicit, or derived from the platform.
let oscpu = nav.oscpu.clone().or_else(|| {
nav.platform.as_deref().and_then(|plat| match plat {
"MacIntel" => Some("Intel Mac OS X 10.15".to_string()),
"Win32" => Some("Windows NT 10.0; Win64; x64".to_string()),
p if p.to_ascii_lowercase().contains("linux") => Some("Linux x86_64".to_string()),
_ => None,
})
});
if let Some(o) = oscpu {
config.insert("navigator.oscpu".to_string(), serde_json::json!(o));
}
}
if let Some(s) = &preset.screen {
if let Some(w) = s.width {
config.insert("screen.width".to_string(), serde_json::json!(w));
}
if let Some(h) = s.height {
config.insert("screen.height".to_string(), serde_json::json!(h));
}
if let Some(cd) = s.color_depth {
config.insert("screen.colorDepth".to_string(), serde_json::json!(cd));
config.insert("screen.pixelDepth".to_string(), serde_json::json!(cd));
}
if let Some(aw) = s.avail_width {
config.insert("screen.availWidth".to_string(), serde_json::json!(aw));
}
if let Some(ah) = s.avail_height {
config.insert("screen.availHeight".to_string(), serde_json::json!(ah));
}
}
if let Some(w) = &preset.webgl {
if let Some(v) = &w.unmasked_vendor {
config.insert("webGl:vendor".to_string(), serde_json::json!(v));
}
if let Some(r) = &w.unmasked_renderer {
config.insert("webGl:renderer".to_string(), serde_json::json!(r));
}
}
if let Some(tz) = &preset.timezone {
config.insert("timezone".to_string(), serde_json::json!(tz));
}
config
}
fn rewrite_ua_firefox_version(ua: &str, version: u32) -> String {
let firefox_re = Regex::new(r"Firefox/\d+\.0").expect("static regex");
let rv_re = Regex::new(r"rv:\d+\.0").expect("static regex");
let first = firefox_re.replace_all(ua, format!("Firefox/{version}.0"));
rv_re
.replace_all(&first, format!("rv:{version}.0"))
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picks_v135_for_old_firefox() {
assert_eq!(preset_line_for(Some(135)), PresetLine::V135);
assert_eq!(preset_line_for(Some(148)), PresetLine::V135);
assert_eq!(preset_line_for(None), PresetLine::V135);
}
#[test]
fn picks_newer_for_anything_past_the_legacy_line() {
// The threshold is data::PRESETS_NEWER_MIN_FF (currently 149).
// Future Firefox versions all share the same bundle — there's
// intentionally no per-version routing past v135.
assert_eq!(preset_line_for(Some(149)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(150)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(160)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(200)), PresetLine::Newer);
}
#[test]
fn both_bundles_parse_and_cover_all_platforms() {
for (line, json) in [
(PresetLine::V135, data::FINGERPRINT_PRESETS_V135_JSON),
(PresetLine::Newer, data::FINGERPRINT_PRESETS_NEWER_JSON),
] {
let bundle: PresetBundle =
serde_json::from_str(json).unwrap_or_else(|e| panic!("bundle {line:?} parse error: {e}"));
for os in ["macos", "windows", "linux"] {
let presets = bundle.presets.get(os).unwrap_or_else(|| {
panic!("bundle {line:?} is missing presets for {os}");
});
assert!(
!presets.is_empty(),
"bundle {line:?} has zero presets for {os}"
);
}
}
}
#[test]
fn random_preset_returns_for_each_os() {
for os in ["macos", "windows", "linux"] {
let preset = get_random_preset(Some(os), Some(150)).expect("preset");
assert!(preset.navigator.is_some(), "navigator present for {os}");
}
}
#[test]
fn from_preset_rewrites_firefox_version() {
let preset = Preset {
navigator: Some(Navigator {
user_agent: Some(
"Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".to_string(),
),
platform: Some("Linux x86_64".to_string()),
hardware_concurrency: Some(8),
max_touch_points: Some(0),
oscpu: None,
}),
screen: None,
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
let config = from_preset(&preset, Some(150));
let ua = config
.get("navigator.userAgent")
.and_then(|v| v.as_str())
.unwrap();
assert!(ua.contains("Firefox/150.0"), "got: {ua}");
assert!(ua.contains("rv:150.0"), "got: {ua}");
// oscpu derived from "Linux x86_64" platform
assert_eq!(
config
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Linux x86_64"
);
}
#[test]
fn from_preset_derives_oscpu_for_mac_and_win() {
let mut preset = Preset {
navigator: Some(Navigator {
user_agent: None,
platform: Some("MacIntel".to_string()),
hardware_concurrency: None,
max_touch_points: None,
oscpu: None,
}),
screen: None,
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
assert_eq!(
from_preset(&preset, None)
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Intel Mac OS X 10.15"
);
preset.navigator.as_mut().unwrap().platform = Some("Win32".to_string());
assert_eq!(
from_preset(&preset, None)
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Windows NT 10.0; Win64; x64"
);
}
#[test]
fn screen_color_depth_fills_both_keys() {
let preset = Preset {
navigator: None,
screen: Some(Screen {
width: Some(1920),
height: Some(1080),
color_depth: Some(24),
avail_width: Some(1920),
avail_height: Some(1050),
device_pixel_ratio: Some(1.0),
}),
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
let config = from_preset(&preset, None);
assert_eq!(config.get("screen.colorDepth").unwrap(), 24);
assert_eq!(config.get("screen.pixelDepth").unwrap(), 24);
assert_eq!(config.get("screen.availWidth").unwrap(), 1920);
}
}
+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
+47 -9
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)]
@@ -127,8 +137,16 @@ lazy_static! {
impl CloudAuthManager {
fn new() -> Self {
let state = Self::load_auth_state_from_disk();
// Bound every cloud API call so no single slow / hung request can stall
// the startup chain (sync-token → proxy-config → wayfern-token), which
// otherwise gates Wayfern launch behind whichever endpoint is slowest.
let client = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| Client::new());
Self {
client: Client::new(),
client,
state: Mutex::new(state),
refresh_lock: tokio::sync::Mutex::new(()),
wayfern_token: Mutex::new(None),
@@ -405,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
@@ -990,7 +1019,15 @@ impl CloudAuthManager {
let token = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
let client = reqwest::Client::new();
// Bound the request: without a timeout, an unreachable
// api.donutbrowser.com hangs the background fetch indefinitely,
// which in turn forces wayfern_manager's launch-time wait to
// exhaust its full polling budget every time.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.connect_timeout(std::time::Duration::from_secs(4))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
async move {
let response = client
.post(&url)
@@ -1199,13 +1236,14 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?;
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
// Always clear the stored sync URL and token on cloud logout. While the
// user was signed in, the cloud auth flow populated these with the hosted
// sync server's URL + a server-issued token — leaving them in place would
// pre-fill the Self-Hosted tab with our production URL and a token the
// user never typed. The cloud-URL-only check we used to do here missed
// trailing-slash / scheme variants and any future cloud endpoint moves.
let manager = crate::settings_manager::SettingsManager::instance();
if let Ok(sync_settings) = manager.get_sync_settings() {
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
let _ = manager.save_sync_server_url(None);
}
}
let _ = manager.save_sync_server_url(None);
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
+148 -1
View File
@@ -1,6 +1,6 @@
use crate::profile::manager::ProfileManager;
use crate::profile::BrowserProfile;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
@@ -134,6 +134,24 @@ pub struct CookieReadResult {
pub total_count: usize,
}
/// Lightweight cookie metadata for the profile-info dialog. Computed without
/// decrypting any cookie values, so it stays cheap even for multi-MB Chromium
/// cookie stores and never blocks the runtime for noticeable time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieStats {
pub profile_id: String,
pub browser_type: String,
pub total_count: usize,
/// Every domain the profile has cookies for, sorted by cookie count desc.
pub domains: Vec<DomainCount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainCount {
pub domain: String,
pub count: usize,
}
/// Request to copy specific cookies
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieCopyRequest {
@@ -694,6 +712,135 @@ impl CookieManager {
})
}
/// Open the cookie SQLite database read-only without acquiring any lock.
///
/// `immutable=1` tells SQLite the file will not change during the read,
/// which causes it to skip all locking. That lets us read metadata even
/// while the browser holds an exclusive lock on the cookies database —
/// the trade-off is that we may see a slightly stale snapshot, which is
/// acceptable for the badge/preview use cases this powers.
fn open_cookie_db_readonly(db_path: &Path) -> Result<Connection, String> {
let path_str = db_path.to_string_lossy();
if path_str.contains('?') || path_str.contains('#') {
return Err(
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": "profile path contains a reserved URI character" }
})
.to_string(),
);
}
let uri = format!("file:{path_str}?mode=ro&immutable=1");
Connection::open_with_flags(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
let code = if e.to_string().to_lowercase().contains("locked") {
"COOKIE_DB_LOCKED"
} else {
"COOKIE_DB_UNAVAILABLE"
};
serde_json::json!({
"code": code,
"params": { "detail": e.to_string() }
})
.to_string()
})
}
/// Public API: read lightweight stats (total count + top 5 domains) for a
/// profile's cookie store. Reads from a snapshot view of the SQLite file
/// without holding a lock, so this works while the browser is running.
pub fn read_stats(profile_id: &str) -> Result<CookieStats, String> {
let profile_manager = ProfileManager::instance();
let profiles_dir = profile_manager.get_profiles_dir();
let profiles = profile_manager.list_profiles().map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
let db_path = Self::get_cookie_db_path(profile, &profiles_dir).map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e }
})
.to_string()
})?;
let conn = Self::open_cookie_db_readonly(&db_path)?;
let (count_sql, domain_sql) = match profile.browser.as_str() {
"camoufox" => (
"SELECT COUNT(*) FROM moz_cookies",
"SELECT host, COUNT(*) FROM moz_cookies GROUP BY host ORDER BY COUNT(*) DESC, host ASC",
),
"wayfern" => (
"SELECT COUNT(*) FROM cookies",
"SELECT host_key, COUNT(*) FROM cookies GROUP BY host_key ORDER BY COUNT(*) DESC, host_key ASC",
),
_ => {
return Err(
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": format!("unsupported browser: {}", profile.browser) }
})
.to_string(),
)
}
};
let total_count: usize = conn
.query_row(count_sql, [], |row| row.get::<_, i64>(0))
.map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})? as usize;
let mut stmt = conn.prepare(domain_sql).map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
let domains: Vec<DomainCount> = stmt
.query_map([], |row| {
Ok(DomainCount {
domain: row.get::<_, String>(0)?,
count: row.get::<_, i64>(1)? as usize,
})
})
.and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
.map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
Ok(CookieStats {
profile_id: profile_id.to_string(),
browser_type: profile.browser.clone(),
total_count,
domains,
})
}
/// Public API: Copy cookies between profiles
pub async fn copy_cookies(
app_handle: &AppHandle,
-351
View File
@@ -1,351 +0,0 @@
use directories::ProjectDirs;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
// Get log directory (use data directory instead of /tmp)
let log_dir = get_data_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("logs");
fs::create_dir_all(&log_dir)?;
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{daemon_path}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>{log_dir}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path = daemon_path.display(),
log_dir = log_dir.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn get_plist_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
if plist_path.exists() {
// First unload the launch agent if it's loaded
let _ = unload_launch_agent();
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
get_plist_path().is_some_and(|p| p.exists())
}
#[cfg(target_os = "macos")]
pub fn load_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Launch agent plist does not exist",
));
}
// Use launchctl load to start the daemon via launchd
// The -w flag writes the "disabled" key to the override plist
let output = Command::new("launchctl")
.args(["load", "-w"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// "already loaded" is not an error condition for us
if !stderr.contains("already loaded") {
return Err(io::Error::other(format!(
"launchctl load failed: {}",
stderr
)));
}
}
log::info!("Loaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Ok(());
}
let output = Command::new("launchctl")
.args(["unload"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Not being loaded is not an error
if !stderr.contains("Could not find specified service") {
log::warn!("launchctl unload warning: {}", stderr);
}
}
log::info!("Unloaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "linux")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let escaped_daemon_path = daemon_path
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod autostart;
pub mod services;
pub mod tray;
-51
View File
@@ -1,51 +0,0 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
-204
View File
@@ -1,204 +0,0 @@
use std::process::Command;
use tray_icon::menu::{Menu, MenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub fn load_icon() -> Icon {
// On Windows, use the full-color icon so it renders well on dark taskbars.
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
#[cfg(target_os = "windows")]
let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png");
#[cfg(not(target_os = "windows"))]
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&quit_item).unwrap();
Self { menu, quit_item }
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
let builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()));
// On macOS, template icons are automatically colored by the system for light/dark mode
#[cfg(target_os = "macos")]
let builder = builder.with_icon_as_template(true);
builder.build().expect("Failed to create tray icon")
}
/// Resolve the .app bundle path from the current daemon executable.
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos_dir = exe.parent()?;
let contents_dir = macos_dir.parent()?;
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
Some(app_dir.to_path_buf())
} else {
None
}
}
pub fn open_gui() {
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
// Launch the GUI binary directly. The daemon lives inside the same .app
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
// of launching the GUI. Directly running the binary avoids macOS's app
// activation machinery. The single-instance Tauri plugin in the GUI
// handles deduplication if a GUI instance is already running.
if let Some(app_bundle) = get_app_bundle_path() {
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
if gui_binary.exists() {
let _ = Command::new(&gui_binary).spawn();
} else {
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
}
} else {
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
}
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let app_path = exe_dir.join("donutbrowser.exe");
if app_path.exists() {
let _ = Command::new(app_path).spawn();
return;
}
}
}
let paths = [
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
Some(PathBuf::from(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
fn read_gui_pid() -> Option<u32> {
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
let content = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
val.get("gui_pid")?.as_u64().map(|p| p as u32)
}
fn kill_gui_by_pid() -> bool {
let Some(pid) = read_gui_pid() else {
return false;
};
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
ret == 0
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
if kill_gui_by_pid() {
log::info!("[daemon] GUI killed by PID");
return;
}
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
#[cfg(target_os = "macos")]
{
// Use spawn() instead of output() to avoid blocking the event loop.
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut\" to quit"])
.spawn();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
-152
View File
@@ -1,152 +0,0 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
pub struct DaemonClient {
app_handle: tauri::AppHandle,
connected: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
daemon_port: Arc<Mutex<Option<u16>>>,
}
impl DaemonClient {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self {
app_handle,
connected: Arc::new(AtomicBool::new(false)),
shutdown: Arc::new(AtomicBool::new(false)),
daemon_port: Arc::new(Mutex::new(None)),
}
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::SeqCst)
}
pub async fn connect(&self, port: u16) -> Result<(), String> {
*self.daemon_port.lock().await = Some(port);
let url = format!("ws://127.0.0.1:{}/ws/events", port);
log::info!("[daemon-client] Connecting to daemon at {}", url);
let (ws_stream, _) = connect_async(&url)
.await
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
self.connected.store(true, Ordering::SeqCst);
log::info!("[daemon-client] Connected to daemon");
let (mut write, mut read) = ws_stream.split();
let app_handle = self.app_handle.clone();
let connected = self.connected.clone();
let shutdown = self.shutdown.clone();
// Spawn task to handle incoming messages
tokio::spawn(async move {
while !shutdown.load(Ordering::SeqCst) {
match read.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"event" => {
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
// Forward event to Tauri frontend
if let Err(e) = app_handle.emit(&event, payload) {
log::error!("[daemon-client] Failed to emit event: {}", e);
}
}
}
"connected" => {
log::info!("[daemon-client] Received connection confirmation");
}
"pong" => {
log::debug!("[daemon-client] Received pong");
}
_ => {
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Some(Ok(Message::Ping(data))) => {
log::debug!("[daemon-client] Received ping");
if let Err(e) = write.send(Message::Pong(data)).await {
log::error!("[daemon-client] Failed to send pong: {}", e);
break;
}
}
Some(Ok(Message::Close(_))) => {
log::info!("[daemon-client] Daemon closed connection");
break;
}
Some(Err(e)) => {
log::error!("[daemon-client] WebSocket error: {}", e);
break;
}
None => {
log::info!("[daemon-client] WebSocket stream ended");
break;
}
_ => {}
}
}
connected.store(false, Ordering::SeqCst);
log::info!("[daemon-client] Disconnected from daemon");
});
Ok(())
}
pub fn disconnect(&self) {
self.shutdown.store(true, Ordering::SeqCst);
self.connected.store(false, Ordering::SeqCst);
}
}
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
let client = DaemonClient::new(app_handle);
if let Err(e) = client.connect(port).await {
log::error!("[daemon-client] Failed to connect: {}", e);
}
client
}
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
// Try default port first
let default_port = 10108;
log::info!(
"[daemon-client] Looking for daemon on port {}",
default_port
);
let client = DaemonClient::new(app_handle);
match client.connect(default_port).await {
Ok(()) => Some(client),
Err(e) => {
log::warn!(
"[daemon-client] Could not connect to daemon on default port: {}",
e
);
None
}
}
}
-360
View File
@@ -1,360 +0,0 @@
// Daemon Spawn - Start the daemon from the GUI
// Currently disabled; will be re-enabled in the future
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
{
false
}
} else {
false
}
}
#[cfg(target_os = "macos")]
fn is_dev_mode() -> bool {
if let Ok(current_exe) = std::env::current_exe() {
let path_str = current_exe.to_string_lossy();
path_str.contains("target/debug") || path_str.contains("target/release")
} else {
false
}
}
#[cfg(target_os = "macos")]
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let daemon_path = exe_dir.join("donut-daemon");
if daemon_path.exists() {
return Some(daemon_path);
}
}
}
// Try common installation paths
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
.unwrap_or_default(),
];
paths.into_iter().find(|path| path.exists())
}
#[cfg(any(target_os = "linux", windows))]
fn get_daemon_path() -> Option<PathBuf> {
// First, try to find it next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
let exe_dir = current_exe.parent()?;
// Check for daemon binary in same directory
#[cfg(target_os = "windows")]
let daemon_name = "donut-daemon.exe";
#[cfg(target_os = "linux")]
let daemon_name = "donut-daemon";
let daemon_path = exe_dir.join(daemon_name);
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
return Some(PathBuf::from(path));
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
None
}
pub fn spawn_daemon() -> Result<(), String> {
// Log the daemon state for debugging
let state = read_state();
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
// Check if already running
if is_daemon_running() {
log::info!("Daemon is already running (verified by PID check)");
return Ok(());
}
log::info!("Daemon is not running, attempting to start...");
// Log current exe location for debugging
let current_exe = std::env::current_exe().ok();
log::info!("Current exe: {:?}", current_exe);
// On macOS, use launchctl to start the daemon via launchd
// This ensures the daemon runs in the user's Aqua session with WindowServer access
// and survives app termination since it's managed by launchd, not as a child process
#[cfg(target_os = "macos")]
{
spawn_daemon_macos()?;
}
// On Linux, use direct spawn
#[cfg(target_os = "linux")]
{
spawn_daemon_unix()?;
}
#[cfg(windows)]
{
spawn_daemon_windows()?;
}
// Wait for daemon to start (max 3 seconds)
for i in 0..30 {
thread::sleep(Duration::from_millis(100));
if is_daemon_running() {
log::info!("Daemon started successfully after {}ms", (i + 1) * 100);
return Ok(());
}
}
// Check if we got a state file at least
let state = read_state();
if let Some(pid) = state.daemon_pid {
log::info!("Daemon appears to have started (PID {} in state file)", pid);
return Ok(());
}
Err("Daemon did not start within timeout".to_string())
}
#[cfg(target_os = "macos")]
fn spawn_daemon_macos() -> Result<(), String> {
use std::os::unix::process::CommandExt;
// In dev mode, use direct spawn instead of launchctl
// This avoids issues with plist paths pointing to wrong binaries
if is_dev_mode() {
log::info!("Dev mode detected, using direct spawn instead of launchctl");
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
return Ok(());
}
// Production mode: use launchctl for proper daemon management
// First, ensure the LaunchAgent plist is installed
let autostart_enabled = autostart::is_autostart_enabled();
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
if !autostart_enabled {
log::info!("Installing LaunchAgent plist for daemon management");
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
log::info!("LaunchAgent plist installed successfully");
}
// Load the launch agent via launchctl
log::info!("Loading daemon via launchctl...");
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn spawn_daemon_unix() -> Result<(), String> {
use std::os::unix::process::CommandExt;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
#[cfg(windows)]
fn spawn_daemon_windows() -> Result<(), String> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
Command::new(&daemon_path)
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
pub fn ensure_daemon_running() -> Result<(), String> {
if !is_daemon_running() {
spawn_daemon()?;
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
-134
View File
@@ -1,134 +0,0 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::events::{DaemonEmitter, DaemonEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct WsState {
event_emitter: Option<Arc<DaemonEmitter>>,
}
impl WsState {
pub fn new() -> Self {
Self {
event_emitter: None,
}
}
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
Self {
event_emitter: Some(emitter),
}
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WsState) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to daemon events if emitter is available
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
log::info!("[ws] Client connected");
// Send initial ping to confirm connection
let ping_msg = WsMessage {
msg_type: "connected".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
loop {
tokio::select! {
// Handle incoming messages from client
Some(msg) = receiver.next() => {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"ping" => {
let pong = WsMessage {
msg_type: "pong".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&pong) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
}
_ => {
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Ok(Message::Ping(data)) => {
let _ = sender.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
log::info!("[ws] Client disconnected");
break;
}
Err(e) => {
log::error!("[ws] Error receiving message: {}", e);
break;
}
_ => {}
}
}
// Forward daemon events to client
Some(daemon_event) = async {
if let Some(ref mut rx) = event_rx {
rx.recv().await.ok()
} else {
std::future::pending::<Option<DaemonEvent>>().await
}
} => {
let ws_msg = WsMessage {
msg_type: "event".to_string(),
event: Some(daemon_event.event_type),
payload: Some(daemon_event.payload),
};
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
if sender.send(Message::Text(msg_str.into())).await.is_err() {
log::error!("[ws] Failed to send event to client");
break;
}
}
}
else => break,
}
}
log::info!("[ws] WebSocket connection closed");
}
+94 -21
View File
@@ -290,24 +290,45 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
// For each browser where every registered version would be removed (no
// profile uses any), keep the newest one by semver. Without this, the
// version preserved depends on HashMap iteration order, so a freshly
// downloaded version can be deleted in favor of an older orphan — leaving
// the UI stuck on "needs to be downloaded".
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
let mut removal_versions_by_browser: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
for (browser, version) in &to_remove {
removal_versions_by_browser
.entry(browser.clone())
.or_default()
.push(version.clone());
}
to_remove.retain(|(browser, version)| {
let mut keep_per_browser: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (browser, versions) in &removal_versions_by_browser {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
if versions.len() >= total {
if let Some(latest) = versions
.iter()
.max_by(|a, b| crate::api_client::compare_versions(a, b))
{
keep_per_browser.insert(browser.clone(), latest.clone());
}
}
}
drop(data);
to_remove.retain(|(browser, version)| {
if keep_per_browser
.get(browser)
.is_some_and(|keep| keep == version)
{
log::info!("Keeping latest available version: {browser} {version}");
return false;
}
true
@@ -1275,21 +1296,73 @@ pub async fn ensure_active_browsers_downloaded(
};
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
match crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
)
.await
{
Ok(_) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
// Retry transient failures a few times. Each attempt is wrapped in an overall
// timeout so that a hang anywhere in the download pipeline (version resolution,
// a stalled stream, extraction) cannot block the next browser forever. This is
// the core of the bug fix: Wayfern going first must never starve Camoufox.
const MAX_ATTEMPTS: u32 = 3;
const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
let mut succeeded = false;
for attempt in 1..=MAX_ATTEMPTS {
let result = tokio::time::timeout(
ATTEMPT_TIMEOUT,
crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
),
)
.await;
match result {
Ok(Ok(_)) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
succeeded = true;
break;
}
Ok(Err(e)) => {
log::warn!(
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
);
}
Err(_) => {
// The download future itself hung past the overall timeout and was dropped,
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
// (the future may have re-resolved to a different version, so clear by
// browser prefix) and emit a terminal error event so the UI stops spinning.
log::warn!(
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
ATTEMPT_TIMEOUT.as_secs()
);
crate::downloader::clear_download_state_for_browser(browser);
let progress = crate::downloader::DownloadProgress {
browser: (*browser).to_string(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = crate::events::emit("download-progress", &progress);
}
}
Err(e) => {
log::warn!("Failed to auto-download {browser} {version}: {e}");
if attempt < MAX_ATTEMPTS {
// Short backoff before retrying a transient failure.
let backoff = std::time::Duration::from_secs(2u64.pow(attempt - 1));
tokio::time::sleep(backoff).await;
}
}
if !succeeded {
// Do NOT abort the whole routine: continue so the next browser (Camoufox)
// still gets its chance even though this one failed/timed out.
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
}
}
Ok(downloaded)
+125 -15
View File
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
// Maximum time to wait for the next chunk of a streaming download before treating
// the connection as stalled. Converts an indefinite hang into a terminal error so
// the UI can surface it and the caller can move on / retry.
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
@@ -44,6 +49,11 @@ impl Downloader {
Self {
client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
// Per-read idle timeout: if the connection stalls mid-stream with no bytes
// for this long, the read fails instead of hanging forever. This is the
// transport-level guard; the streaming loop also wraps each read in an
// explicit tokio timeout as defense-in-depth.
.read_timeout(STREAM_IDLE_TIMEOUT)
.build()
.unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(),
@@ -470,7 +480,26 @@ impl Downloader {
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
loop {
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
// surfaces as a terminal error instead of awaiting forever.
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Ok(item) => item,
Err(_) => {
drop(file);
// Keep any partial bytes on disk so a later attempt can resume via Range.
return Err(
format!(
"Download stalled: no data received for {}s",
STREAM_IDLE_TIMEOUT.as_secs()
)
.into(),
);
}
};
let Some(chunk) = next else {
break;
};
if let Some(token) = cancel_token {
if token.is_cancelled() {
drop(file);
@@ -694,20 +723,25 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
// Emit cancelled stage if the download was cancelled by user
if cancel_token.is_cancelled() {
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "cancelled".to_string(),
};
let _ = events::emit("download-progress", &progress);
}
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
// "cancelled"; any other failure (network error, stall timeout, bad status)
// maps to "error" so the frontend can show a concrete error toast.
let stage = if cancel_token.is_cancelled() {
"cancelled"
} else {
"error"
};
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: stage.to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to download browser: {e}").into());
}
@@ -844,6 +878,20 @@ impl Downloader {
// Do not delete files on verification failure; keep archive for manual retry.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Emit a terminal error stage so the UI shows an error instead of spinning.
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
downloading.contains(&download_key)
}
/// Clear all in-progress download bookkeeping for a browser.
///
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
/// by an outer timeout) before its own error path could run. Because
/// `download_browser_full` may re-resolve to a different version than requested, this
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
/// key is left behind regardless of which version was actually in flight.
pub fn clear_download_state_for_browser(browser: &str) {
let prefix = format!("{browser}-");
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.retain(|key| !key.starts_with(&prefix));
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.retain(|key, _| !key.starts_with(&prefix));
}
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -1110,6 +1177,49 @@ mod tests {
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content.len(), test_content.len());
}
#[test]
fn test_clear_download_state_for_browser_removes_stuck_keys() {
// Simulate a download future that was abandoned without running its own cleanup,
// leaving stuck bookkeeping for a version that differs from the requested one.
let key = "wayfern-1.2.3-resolved".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(key.clone());
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.insert(key.clone(), CancellationToken::new());
}
// A different browser's in-progress state must be left untouched.
let other = "camoufox-9.9.9".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(other.clone());
}
clear_download_state_for_browser("wayfern");
assert!(
!is_downloading("wayfern", "1.2.3-resolved"),
"stuck wayfern key should be cleared even when version differs from request"
);
{
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
assert!(
!tokens.contains_key(&key),
"stuck wayfern cancellation token should be cleared"
);
}
assert!(
is_downloading("camoufox", "9.9.9"),
"unrelated browser's download state must be preserved"
);
// Cleanup so we don't leak global state into other tests.
clear_download_state_for_browser("camoufox");
}
}
// Global singleton instance
+4 -1
View File
@@ -240,7 +240,7 @@ fn cleanup_legacy_dirs() {
}
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
if profile.ephemeral {
if profile.ephemeral || profile.password_protected {
if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
return dir;
}
@@ -279,6 +279,9 @@ mod tests {
created_by_id: None,
created_by_email: None,
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);
+70 -23
View File
@@ -12,6 +12,39 @@ use tokio::process::Command;
#[cfg(target_os = "macos")]
use std::fs::create_dir_all;
/// Returns true if `path` carries a `com.apple.quarantine` extended attribute.
///
/// Uses `getxattr` with a null buffer to query the attribute size only —
/// this is a read-only syscall and does NOT trigger macOS Sequoia's App
/// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS
/// fires the prompt on the modify-class syscall (`removexattr`) even when
/// the operation is a no-op, so skipping the call entirely when the
/// attribute is absent is the only way to stay quiet.
#[cfg(target_os = "macos")]
fn has_quarantine_attr(path: &Path) -> bool {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else {
return false;
};
let Ok(attr_c) = CString::new("com.apple.quarantine") else {
return false;
};
// SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0
// makes it a pure read-only size query.
let result = unsafe {
libc::getxattr(
path_c.as_ptr(),
attr_c.as_ptr(),
std::ptr::null_mut(),
0,
0,
0,
)
};
result >= 0
}
pub struct Extractor;
impl Extractor {
@@ -207,18 +240,23 @@ impl Extractor {
match extraction_result {
Ok(path) => {
// Remove quarantine attributes on macOS to prevent
// "app was prevented from modifying data" prompts
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
// but only if there's actually something to remove. Calling the
// modify-class `removexattr` syscall on a file without quarantine still
// fires macOS Sequoia's App Management TCC notification, so we skip
// the call entirely when the attribute is absent.
#[cfg(target_os = "macos")]
{
let _ = tokio::process::Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
dest_dir.to_str().unwrap_or("."),
])
.output()
.await;
if has_quarantine_attr(dest_dir) {
let _ = tokio::process::Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
dest_dir.to_str().unwrap_or("."),
])
.output()
.await;
}
}
log::info!(
@@ -419,9 +457,15 @@ impl Extractor {
log::info!("Copying .app to: {}", app_path.display());
// `-X` strips extended attributes (notably com.apple.quarantine) during
// the copy itself. Without it, `cp -R` preserves quarantine from the
// mounted DMG, which then has to be removed with `xattr -dr` — and that
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
// Management TCC notification ("Donut.app was prevented from modifying
// apps on your Mac"). Stripping at copy time is silent.
let output = Command::new("cp")
.args([
"-R",
"-RX",
app_entry.to_str().unwrap(),
app_path.to_str().unwrap(),
])
@@ -444,18 +488,21 @@ impl Extractor {
log::info!("Successfully copied .app bundle");
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output()
.await;
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output()
.await;
log::info!("Removed quarantine attributes");
// Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
// — but only if it's actually present. A no-op `removexattr` syscall on a
// signed .app bundle still trips macOS Sequoia's App Management privacy
// prompt ("Donut.app was prevented from modifying apps on your Mac"),
// even when no modification actually happens, so we gate the call behind
// a read-only `getxattr` check.
if has_quarantine_attr(&app_path) {
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output()
.await;
log::info!("Removed quarantine attributes");
} else {
log::info!("No quarantine attribute on .app, skipping xattr removal");
}
// Unmount the DMG
let output = Command::new("hdiutil")
+11 -13
View File
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -90,6 +94,7 @@ impl GroupManager {
name,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
groups_data.groups.push(group.clone());
@@ -136,6 +141,7 @@ impl GroupManager {
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
group.name = name;
group.updated_at = Some(crate::proxy_manager::now_secs());
let updated_group = group.clone();
self.save_groups_data(&groups_data)?;
@@ -167,6 +173,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
self.save_groups_data(&groups_data)?;
}
@@ -183,6 +190,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
} else {
groups_data.groups.push(group.clone());
}
@@ -268,7 +276,9 @@ impl GroupManager {
}
}
// Create result including all groups (even those with 0 count)
// Create result including all groups (even those with 0 count).
// The "Default" pseudo-group is intentionally not returned: profiles
// without a group_id are surfaced through the "All" filter instead.
let mut result = Vec::new();
for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0);
@@ -281,18 +291,6 @@ impl GroupManager {
});
}
// Add default group count (profiles without group_id), always include even if 0
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
sync_enabled: false,
last_sync: None,
};
// Insert at the beginning for consistent ordering with UI expectations
result.insert(0, default_group);
Ok(result)
}
}
+401 -139
View File
@@ -1,13 +1,19 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
// Store pending URLs that need to be handled when the window is ready
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// Set to true once the user has confirmed they want to quit, so the close
// interceptor lets the next CloseRequested through instead of looping back
// to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
mod api_client;
mod api_server;
mod app_auto_updater;
@@ -46,12 +52,8 @@ mod wayfern_terms;
pub mod cloud_auth;
mod commercial_license;
mod cookie_manager;
pub mod daemon;
pub mod daemon_client;
#[allow(dead_code)]
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
mod tag_manager;
mod team_lock;
@@ -72,6 +74,11 @@ use profile::manager::{
update_wayfern_config,
};
use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile, verify_profile_password,
};
use browser_version_manager::{
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_supported_browsers,
@@ -86,18 +93,19 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
save_table_sorting_settings, should_show_launch_on_login_prompt,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
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, set_e2e_password,
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
verify_e2e_password,
};
use tag_manager::get_all_tags;
@@ -183,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}");
@@ -305,8 +314,21 @@ async fn import_proxies_from_parsed(
}
#[tauri::command]
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
cookie_manager::CookieManager::read_cookies(&profile_id)
async fn read_profile_cookies(
profile_id: String,
) -> Result<cookie_manager::CookieReadResult, String> {
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_cookies(&profile_id))
.await
.map_err(|e| format!("Failed to read profile cookies: {e}"))?
}
#[tauri::command]
async fn get_profile_cookie_stats(
profile_id: String,
) -> Result<cookie_manager::CookieStats, String> {
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_stats(&profile_id))
.await
.map_err(|e| format!("Failed to read profile cookie stats: {e}"))?
}
#[tauri::command]
@@ -485,20 +507,20 @@ fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
}
}
#[tauri::command]
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
Ok(dir.join("manifest.json").exists())
fn is_mcp_in_claude_desktop_internal() -> bool {
let Some(dir) = claude_desktop_extension_dir() else {
return false;
};
dir.join("manifest.json").exists()
}
#[tauri::command]
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
@@ -587,8 +609,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
Ok(())
}
#[tauri::command]
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
if ext_dir.exists() {
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
@@ -650,85 +671,48 @@ fn update_claude_extensions_registry(
Ok(())
}
fn find_claude_cli() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = vec![
std::path::PathBuf::from("/usr/local/bin/claude"),
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
];
if let Some(home) = dirs::home_dir() {
candidates.insert(0, home.join(".local/bin/claude"));
candidates.push(home.join(".claude/local/claude"));
}
#[cfg(windows)]
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.insert(
0,
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
);
}
for p in &candidates {
if p.exists() {
return Some(p.clone());
}
}
None
}
#[tauri::command]
fn is_mcp_in_claude_code() -> Result<bool, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let output = std::process::Command::new(&cli)
.args(["mcp", "list"])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.contains("donut-browser"))
}
#[tauri::command]
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
let _ = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output();
let output = std::process::Command::new(&cli)
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
}
Ok(())
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
}
#[tauri::command]
fn remove_mcp_from_claude_code() -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let output = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
Ok(mcp_integrations::list_agents_with_status(&[(
"claude-desktop",
claude_desktop_connected,
)]))
}
#[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
Ok(())
if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
}
#[tauri::command]
@@ -742,6 +726,15 @@ async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::Traffic
Ok(crate::traffic_stats::get_all_traffic_snapshots_realtime())
}
#[tauri::command]
async fn get_profile_traffic_snapshot(
profile_id: String,
) -> Result<Option<crate::traffic_stats::TrafficSnapshot>, String> {
Ok(crate::traffic_stats::get_traffic_snapshot_for_profile(
&profile_id,
))
}
#[tauri::command]
async fn clear_all_traffic_stats() -> Result<(), String> {
crate::traffic_stats::clear_all_traffic_stats()
@@ -936,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}"))?;
@@ -1021,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)
@@ -1127,6 +1173,9 @@ async fn generate_sample_fingerprint(
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
if browser == "camoufox" {
@@ -1152,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();
@@ -1165,16 +1328,30 @@ 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()),
}))
.max_file_size(100_000) // 100KB
.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.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
@@ -1210,6 +1387,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -1220,19 +1398,11 @@ 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())
.title("Donut Browser")
.inner_size(800.0, 500.0)
.inner_size(880.0, 500.0)
.resizable(false)
.fullscreen(false)
.center()
@@ -1245,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")]
{
@@ -1338,18 +1534,31 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await;
});
// Auto-start MCP server if it was previously enabled
// Auto-start MCP server if it was previously enabled. Always log the
// decision so customer logs reveal whether MCP is actually running —
// "automation features don't work" is otherwise indistinguishable from
// "MCP server isn't enabled" without this line.
{
let mcp_handle = app.handle().clone();
let settings_mgr = settings_manager::SettingsManager::instance();
if let Ok(settings) = settings_mgr.load_settings() {
if settings.mcp_enabled {
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
});
match settings_mgr.load_settings() {
Ok(settings) => {
if settings.mcp_enabled {
log::info!("MCP server is enabled in settings, attempting auto-start");
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
});
} else {
log::info!(
"MCP server is DISABLED in settings (mcp_enabled=false). Browser automation tools will not be available until it's enabled in Settings → Integrations."
);
}
}
Err(e) => {
log::warn!("Could not read settings to determine MCP state: {e}");
}
}
}
@@ -1710,7 +1919,23 @@ pub fn run() {
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
for profile in profiles {
// Only walk profiles that either have a stored PID or that we last
// saw as running — for users with hundreds of idle profiles this
// turns an O(N) sysinfo scan into an O(running) scan. The Rust
// launch path always emits profile-running-changed when a profile
// STARTS, so newly-running profiles still get tracked here.
let profiles_to_check: Vec<_> = profiles
.into_iter()
.filter(|p| {
p.process_id.is_some()
|| last_running_states
.get(&p.id.to_string())
.copied()
.unwrap_or(false)
})
.collect();
for profile in profiles_to_check {
// Check browser status and track changes
match runner
.check_browser_status(app_handle_status.clone(), &profile)
@@ -1753,6 +1978,19 @@ pub fn run() {
);
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
// Must run BEFORE `mark_profile_stopped` because that
// releases any queued sync run, and a sync that picks up
// the on-disk dir before re-encryption finishes uploads
// the previous snapshot (issue: encrypted profiles not
// syncing fresh data).
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit_and_wait(&profile)
.await;
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
@@ -1882,21 +2120,31 @@ pub fn run() {
// Start cloud auth background refresh loop
let app_handle_cloud = app.handle().clone();
tauri::async_runtime::spawn(async move {
// On startup, refresh sync token and proxy if cloud auth is active.
// On startup, refresh sync token, proxy config, and wayfern token in
// PARALLEL. Previously they were awaited sequentially, so the wayfern
// token request didn't even start until the earlier two API calls had
// finished. Wayfern launch can race with this task — a few seconds of
// serialized API calls translates directly into a slow first launch
// because launch_wayfern blocks waiting for the token to land.
// api_call_with_retry handles 401/refresh internally — no direct
// refresh_access_token call needed.
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
// Request wayfern token on startup for paid users
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
let sync_token_fut = async {
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
}
};
let proxy_fut = async {
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
};
let wayfern_fut = async {
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
}
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
}
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
});
@@ -1904,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,
@@ -1932,15 +2183,16 @@ pub fn run() {
rename_profile,
get_app_settings,
save_app_settings,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
read_log_files,
open_log_directory,
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,
@@ -1999,11 +2251,13 @@ pub fn run() {
stop_api_server,
get_api_server_status,
get_all_traffic_snapshots,
get_profile_traffic_snapshot,
clear_all_traffic_stats,
get_traffic_stats_for_period,
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync,
set_proxy_sync_enabled,
set_group_sync_enabled,
@@ -2017,8 +2271,11 @@ pub fn run() {
enable_sync_for_all_entities,
set_e2e_password,
check_has_e2e_password,
verify_e2e_password,
delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies,
get_profile_cookie_stats,
copy_profile_cookies,
import_cookies_from_file,
export_profile_cookies,
@@ -2032,12 +2289,9 @@ pub fn run() {
stop_mcp_server,
get_mcp_server_status,
get_mcp_config,
is_mcp_in_claude_desktop,
add_mcp_to_claude_desktop,
remove_mcp_from_claude_desktop,
is_mcp_in_claude_code,
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
list_mcp_agents,
add_mcp_to_agent,
remove_mcp_from_agent,
// VPN commands
import_vpn_config,
list_vpn_configs,
@@ -2050,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,
@@ -2076,6 +2329,14 @@ pub fn run() {
// DNS blocklist commands
dns_blocklist::get_dns_blocklist_cache_status,
dns_blocklist::refresh_dns_blocklists,
// Profile password commands
set_profile_password,
change_profile_password,
remove_profile_password,
verify_profile_password,
unlock_profile,
lock_profile,
is_profile_locked,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -2122,6 +2383,7 @@ mod tests {
"generate_sample_fingerprint",
"cloud_get_wayfern_token",
"cloud_refresh_wayfern_token",
"lock_profile",
];
// Extract command names from the generate_handler! macro in this file
+574
View File
@@ -0,0 +1,574 @@
// MCP client integrations — installs/removes the donut-browser MCP server in
// 14 popular AI assistant clients. Ports the add-mcp registry to Rust.
//
// Claude Desktop is managed via Claude's local extensions bundle
// (manifest.json + node bridge), since the desktop app supports only stdio
// servers via its plain JSON config but exposes HTTP through the extension
// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other
// agents (including Claude Code) use the generic config-file installer here.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const SERVER_NAME: &str = "donut-browser";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum AgentCategory {
DesktopApp,
Cli,
Editor,
EditorExt,
}
#[derive(Debug, Clone, Copy)]
enum ConfigFormat {
Json,
Toml,
Yaml,
}
#[derive(Debug, Clone)]
struct AgentSpec {
id: &'static str,
display_name: &'static str,
category: AgentCategory,
/// Top-level key (supports dot notation) where the server is written.
config_key: &'static str,
format: ConfigFormat,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct McpAgentInfo {
pub id: String,
pub display_name: String,
pub category: AgentCategory,
pub connected: bool,
/// True when the underlying client appears to be installed on the system
/// (its config directory exists), regardless of whether we have installed
/// the donut-browser server into it.
pub detected: bool,
}
fn home() -> Option<PathBuf> {
dirs::home_dir()
}
#[cfg(target_os = "macos")]
fn vscode_user_dir() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Code")
.join("User")
})
}
#[cfg(target_os = "windows")]
fn vscode_user_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Code").join("User"))
}
#[cfg(target_os = "linux")]
fn vscode_user_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Code").join("User"))
}
#[cfg(target_os = "macos")]
fn zed_config_dir() -> Option<PathBuf> {
home().map(|h| h.join("Library").join("Application Support").join("Zed"))
}
#[cfg(target_os = "windows")]
fn zed_config_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Zed"))
}
#[cfg(target_os = "linux")]
fn zed_config_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("zed"))
}
#[cfg(target_os = "windows")]
fn goose_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Block")
.join("goose")
.join("config")
.join("config.yaml")
})
}
#[cfg(not(target_os = "windows"))]
fn goose_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("goose").join("config.yaml"))
}
/// Resolve the global config path for an agent. Returns `None` on unsupported
/// platforms (none currently — every supported agent has a defined path on
/// macOS/Linux/Windows).
fn config_path_for(agent_id: &str) -> Option<PathBuf> {
let h = home()?;
match agent_id {
"antigravity" => Some(
h.join(".gemini")
.join("antigravity")
.join("mcp_config.json"),
),
"cline" => vscode_user_dir().map(|d| {
d.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json")
}),
"cline-cli" => {
let base = std::env::var("CLINE_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".cline"));
Some(
base
.join("data")
.join("settings")
.join("cline_mcp_settings.json"),
)
}
"claude-code" => Some(h.join(".claude.json")),
"claude-desktop" => claude_desktop_config_path(),
"codex" => {
let base = std::env::var("CODEX_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".codex"));
Some(base.join("config.toml"))
}
"cursor" => Some(h.join(".cursor").join("mcp.json")),
"gemini-cli" => Some(h.join(".gemini").join("settings.json")),
"goose" => goose_config_path(),
"github-copilot-cli" => Some(
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".copilot"))
.join("mcp-config.json"),
),
"mcporter" => {
// add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back
// to mcporter.jsonc if it already exists, else default to mcporter.json.
let dir = h.join(".mcporter");
let json_path = dir.join("mcporter.json");
let jsonc_path = dir.join("mcporter.jsonc");
if json_path.exists() {
Some(json_path)
} else if jsonc_path.exists() {
Some(jsonc_path)
} else {
Some(json_path)
}
}
"opencode" => Some(h.join(".config").join("opencode").join("opencode.json")),
"vscode" => vscode_user_dir().map(|d| d.join("mcp.json")),
"zed" => zed_config_dir().map(|d| d.join("settings.json")),
_ => None,
}
}
#[cfg(target_os = "macos")]
fn claude_desktop_config_path() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "windows")]
fn claude_desktop_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "linux")]
fn claude_desktop_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Claude").join("claude_desktop_config.json"))
}
const AGENT_SPECS: &[AgentSpec] = &[
AgentSpec {
id: "claude-desktop",
display_name: "Claude Desktop",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "claude-code",
display_name: "Claude Code",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cursor",
display_name: "Cursor",
category: AgentCategory::Editor,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "vscode",
display_name: "VS Code",
category: AgentCategory::Editor,
config_key: "servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "zed",
display_name: "Zed",
category: AgentCategory::Editor,
config_key: "context_servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline-cli",
display_name: "Cline CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline",
display_name: "Cline VSCode",
category: AgentCategory::EditorExt,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "codex",
display_name: "Codex",
category: AgentCategory::Cli,
config_key: "mcp_servers",
format: ConfigFormat::Toml,
},
AgentSpec {
id: "gemini-cli",
display_name: "Gemini CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "github-copilot-cli",
display_name: "GitHub Copilot CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "goose",
display_name: "Goose",
category: AgentCategory::Cli,
config_key: "extensions",
format: ConfigFormat::Yaml,
},
AgentSpec {
id: "antigravity",
display_name: "Antigravity",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "opencode",
display_name: "OpenCode",
category: AgentCategory::Cli,
config_key: "mcp",
format: ConfigFormat::Json,
},
AgentSpec {
id: "mcporter",
display_name: "MCPorter",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
];
fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> {
AGENT_SPECS.iter().find(|s| s.id == agent_id)
}
fn detect_agent_directory(agent_id: &str) -> bool {
// Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate
// parent of the config file. Used only for UI annotation; install/uninstall
// always operates on the resolved config path.
let Some(h) = home() else {
return false;
};
match agent_id {
"antigravity" => h.join(".gemini").exists(),
"cline" => config_path_for("cline")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"cline-cli" => config_path_for("cline-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"claude-code" => h.join(".claude").exists(),
"claude-desktop" => claude_desktop_config_path()
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"codex" => h.join(".codex").exists(),
"cursor" => h.join(".cursor").exists(),
"gemini-cli" => h.join(".gemini").exists(),
"github-copilot-cli" => config_path_for("github-copilot-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"goose" => goose_config_path().is_some_and(|p| p.exists()),
"mcporter" => h.join(".mcporter").exists(),
"opencode" => h.join(".config").join("opencode").exists(),
"vscode" => vscode_user_dir().is_some_and(|d| d.exists()),
"zed" => zed_config_dir().is_some_and(|d| d.exists()),
_ => false,
}
}
/// Transform the donut-browser HTTP server config into the per-agent shape.
/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge
/// (handled by the extension installer in lib.rs).
fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value {
use serde_json::json;
match agent_id {
"zed" => json!({ "source": "custom", "type": "http", "url": url }),
"opencode" => json!({ "type": "remote", "url": url, "enabled": true }),
"antigravity" => json!({ "serverUrl": url }),
"cursor" => json!({ "url": url }),
"cline" | "cline-cli" => json!({
"url": url,
"type": "streamableHttp",
"disabled": false,
}),
"codex" => json!({ "type": "http", "url": url }),
"github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }),
"goose" => json!({
"name": SERVER_NAME,
"description": "",
"type": "streamable_http",
"uri": url,
"headers": {},
"enabled": true,
"timeout": 300,
}),
"vscode" => json!({ "type": "http", "url": url }),
// claude-code, claude-desktop, gemini-cli, mcporter — passthrough
_ => json!({ "type": "http", "url": url }),
}
}
/// Detect whether a server config object looks like our donut-browser HTTP
/// endpoint by URL prefix. Matches across the various per-agent key shapes
/// (`url`, `uri`, `serverUrl`).
fn config_matches_donut(value: &serde_json::Value) -> bool {
for key in ["url", "uri", "serverUrl"] {
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if s.contains("/mcp/")
&& (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost"))
{
return true;
}
}
}
false
}
fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value {
let Ok(content) = fs::read_to_string(path) else {
return serde_json::Value::Null;
};
match format {
ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null),
ConfigFormat::Toml => toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|t| serde_json::to_value(t).ok())
.unwrap_or(serde_json::Value::Null),
ConfigFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(&content)
.ok()
.and_then(|y| serde_json::to_value(y).ok())
.unwrap_or(serde_json::Value::Null),
}
}
fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = match format {
ConfigFormat::Json => {
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))?
}
ConfigFormat::Toml => {
let toml_val: toml::Value = serde_json::from_value(value.clone())
.map_err(|e| format!("Failed to convert to TOML: {e}"))?;
toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))?
}
ConfigFormat::Yaml => {
let yaml_val: serde_yaml::Value = serde_yaml::from_str(
&serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?,
)
.map_err(|e| format!("Failed to convert to YAML: {e}"))?;
serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))?
}
};
fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
/// Navigate `config_key` (dot notation), creating object literals at each
/// missing level. Returns a mutable reference to the bottom container so the
/// caller can set/remove server entries.
fn ensure_nested_object<'a>(
root: &'a mut serde_json::Value,
config_key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !root.is_object() {
*root = serde_json::Value::Object(serde_json::Map::new());
}
let mut current = root.as_object_mut().expect("just set to object");
let parts: Vec<&str> = config_key.split('.').collect();
for part in &parts {
let entry = current
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
current = entry.as_object_mut().expect("just ensured object");
}
current
}
fn nested_object<'a>(
root: &'a serde_json::Value,
config_key: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let mut current = root.as_object()?;
for part in config_key.split('.') {
current = current.get(part)?.as_object()?;
}
Some(current)
}
fn is_generic_agent_connected(agent_id: &str) -> bool {
let Some(spec) = spec_for(agent_id) else {
return false;
};
let Some(path) = config_path_for(agent_id) else {
return false;
};
if !path.exists() {
return false;
}
let root = read_value(&path, spec.format);
let Some(servers) = nested_object(&root, spec.config_key) else {
return false;
};
if let Some(entry) = servers.get(SERVER_NAME) {
return config_matches_donut(entry);
}
servers.values().any(config_matches_donut)
}
/// Install or remove the donut-browser entry from a generic agent. Returns
/// `true` if a write happened. Callers handle higher-level dispatch (Claude
/// Desktop extension setup, Claude Code CLI invocation).
pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let path = config_path_for(agent_id)
.ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?;
let mut root = if path.exists() {
read_value(&path, spec.format)
} else {
serde_json::Value::Object(serde_json::Map::new())
};
if !root.is_object() {
root = serde_json::Value::Object(serde_json::Map::new());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.insert(
SERVER_NAME.to_string(),
transform_remote_config(agent_id, url),
);
write_value(&path, &root, spec.format)
}
pub fn uninstall_generic(agent_id: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let Some(path) = config_path_for(agent_id) else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let mut root = read_value(&path, spec.format);
if !root.is_object() {
return Ok(());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.remove(SERVER_NAME);
write_value(&path, &root, spec.format)
}
pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec<McpAgentInfo> {
AGENT_SPECS
.iter()
.map(|spec| {
let connected = connected_overrides
.iter()
.find(|(id, _)| *id == spec.id)
.map(|(_, c)| *c)
.unwrap_or_else(|| is_generic_agent_connected(spec.id));
McpAgentInfo {
id: spec.id.to_string(),
display_name: spec.display_name.to_string(),
category: spec.category,
connected,
detected: detect_agent_directory(spec.id),
}
})
.collect()
}
pub fn agent_exists(agent_id: &str) -> bool {
spec_for(agent_id).is_some()
}
+611 -58
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 {
@@ -112,6 +154,17 @@ impl McpServer {
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
// Log the failed gate so customer logs explain why an MCP tool returned
// an error. Include enough state (logged-in vs not, plan, status) for
// support to diagnose without leaking secrets.
let summary = match CLOUD_AUTH.get_user().await {
Some(state) => format!(
"logged_in=true plan={} status={} period={:?}",
state.user.plan, state.user.subscription_status, state.user.plan_period,
),
None => "logged_in=false".to_string(),
};
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
return Err(McpError {
code: -32000,
message: format!("{feature} requires an active paid subscription"),
@@ -1092,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(),
@@ -1343,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"]
}),
},
]
}
@@ -1458,103 +1600,158 @@ impl McpServer {
.cloned()
.unwrap_or(serde_json::json!({}));
// Surface the call in logs so customer reports show which tools the MCP
// client is actually invoking (and therefore which gate any subsequent
// error came from). Log only the tool name and the profile_id arg —
// arbitrary URLs / JS / selectors can be sensitive.
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}");
let started = std::time::Instant::now();
let result = self.dispatch_tool_call(tool_name, &arguments).await;
let elapsed_ms = started.elapsed().as_millis();
match &result {
Ok(_) => {
log::info!(
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> ok ({elapsed_ms} ms)"
);
}
Err(e) => {
log::warn!(
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> error code={} msg={:?} ({elapsed_ms} ms)",
e.code,
e.message
);
}
}
result
}
async fn dispatch_tool_call(
&self,
tool_name: &str,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
match tool_name {
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).await,
"get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_run_profile(&arguments).await
self.handle_run_profile(arguments).await
}
"kill_profile" => self.handle_kill_profile(&arguments).await,
"create_profile" => self.handle_create_profile(&arguments).await,
"update_profile" => self.handle_update_profile(&arguments).await,
"delete_profile" => self.handle_delete_profile(&arguments).await,
"kill_profile" => self.handle_kill_profile(arguments).await,
"create_profile" => self.handle_create_profile(arguments).await,
"update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_profile(arguments).await,
"list_tags" => self.handle_list_tags().await,
"list_proxies" => self.handle_list_proxies().await,
"get_profile_status" => self.handle_get_profile_status(&arguments).await,
"get_profile_status" => self.handle_get_profile_status(arguments).await,
// Group management
"list_groups" => self.handle_list_groups().await,
"get_group" => self.handle_get_group(&arguments).await,
"create_group" => self.handle_create_group(&arguments).await,
"update_group" => self.handle_update_group(&arguments).await,
"delete_group" => self.handle_delete_group(&arguments).await,
"assign_profiles_to_group" => self.handle_assign_profiles_to_group(&arguments).await,
"get_group" => self.handle_get_group(arguments).await,
"create_group" => self.handle_create_group(arguments).await,
"update_group" => self.handle_update_group(arguments).await,
"delete_group" => self.handle_delete_group(arguments).await,
"assign_profiles_to_group" => self.handle_assign_profiles_to_group(arguments).await,
// Full proxy management
"get_proxy" => self.handle_get_proxy(&arguments).await,
"create_proxy" => self.handle_create_proxy(&arguments).await,
"update_proxy" => self.handle_update_proxy(&arguments).await,
"delete_proxy" => self.handle_delete_proxy(&arguments).await,
"get_proxy" => self.handle_get_proxy(arguments).await,
"create_proxy" => self.handle_create_proxy(arguments).await,
"update_proxy" => self.handle_update_proxy(arguments).await,
"delete_proxy" => self.handle_delete_proxy(arguments).await,
// Proxy import/export
"export_proxies" => self.handle_export_proxies(&arguments).await,
"import_proxies" => self.handle_import_proxies(&arguments).await,
"export_proxies" => self.handle_export_proxies(arguments).await,
"import_proxies" => self.handle_import_proxies(arguments).await,
// VPN management
"import_vpn" => self.handle_import_vpn(&arguments).await,
"import_vpn" => self.handle_import_vpn(arguments).await,
"list_vpn_configs" => self.handle_list_vpn_configs().await,
"delete_vpn" => self.handle_delete_vpn(&arguments).await,
"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,
"delete_vpn" => self.handle_delete_vpn(arguments).await,
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management — viewing and editing both require a paid plan.
"get_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_get_profile_fingerprint(arguments).await
}
"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)
.handle_update_profile_proxy_bypass_rules(arguments)
.await
}
// DNS blocklist management
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(&arguments).await,
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(arguments).await,
"get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
"create_extension_group" => self.handle_create_extension_group(&arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(&arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(&arguments).await,
"create_extension_group" => self.handle_create_extension_group(arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
"assign_extension_group_to_profile" => {
self
.handle_assign_extension_group_to_profile(&arguments)
.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,
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
// Synchronizer tools
"start_sync_session" => {
Self::require_paid_subscription("Synchronizer").await?;
self.handle_start_sync_session(&arguments).await
self.handle_start_sync_session(arguments).await
}
"stop_sync_session" => self.handle_stop_sync_session(&arguments).await,
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
"get_sync_sessions" => self.handle_get_sync_sessions().await,
"remove_sync_follower" => self.handle_remove_sync_follower(&arguments).await,
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
// Browser interaction tools (require paid subscription)
"navigate" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_navigate(&arguments).await
self.handle_navigate(arguments).await
}
"screenshot" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_screenshot(&arguments).await
self.handle_screenshot(arguments).await
}
"evaluate_javascript" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_evaluate_javascript(&arguments).await
self.handle_evaluate_javascript(arguments).await
}
"click_element" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_click_element(&arguments).await
self.handle_click_element(arguments).await
}
"type_text" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_type_text(&arguments).await
self.handle_type_text(arguments).await
}
"get_page_content" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_content(&arguments).await
self.handle_get_page_content(arguments).await
}
"get_page_info" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_info(&arguments).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,
@@ -1641,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);
@@ -1685,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": [{
@@ -2685,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,
@@ -4217,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?;
@@ -4264,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
}]
}))
}
@@ -4315,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(
@@ -4514,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"));
+702
View File
@@ -0,0 +1,702 @@
//! Per-file encryption for password-protected profiles.
//!
//! Each on-disk file in `profiles/{uuid}/profile/` has:
//! - **Filename**: `urlsafe_no_pad(HMAC-SHA256(profile_key, plaintext_relpath))[..32]`.
//! Deterministic so cross-machine sync sees stable filenames; same plaintext
//! path with same key always produces the same on-disk name.
//! - **Content**: `nonce(12B) || AES-256-GCM(profile_key, path_len(2B-LE) || plaintext_path || file_bytes)`.
//! The plaintext relpath is encoded inside the ciphertext so a launch can
//! reconstruct the directory tree without a separate manifest.
//!
//! Wrong password fails the AES-GCM auth tag on the first decrypt, which
//! doubles as password verification.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ring::hmac;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use crate::sync::encryption::{decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt};
/// Length of the on-disk HMAC filename in chars.
const HMAC_FILENAME_LEN: usize = 32;
/// Marker file written into encrypted profile dirs so launch code can verify
/// the password before attempting to decrypt actual user data files.
const VERIFY_FILE_NAME: &str = ".donut-pw-verify";
const VERIFY_FILE_PATH: &str = "__donut_pw_verify__";
lazy_static::lazy_static! {
/// In-memory cache of derived per-profile encryption keys, keyed by profile UUID.
/// Only populated while a profile is unlocked / running. Never persisted.
static ref KEY_CACHE: Mutex<HashMap<uuid::Uuid, [u8; 32]>> = Mutex::new(HashMap::new());
}
#[derive(Debug, thiserror::Error)]
pub enum PasswordError {
#[error("io error: {0}")]
Io(String),
#[error("encryption error: {0}")]
Encryption(String),
#[error("invalid password")]
WrongPassword,
#[error("invalid file format")]
InvalidFormat,
}
pub type PasswordResult<T> = Result<T, PasswordError>;
impl From<std::io::Error> for PasswordError {
fn from(e: std::io::Error) -> Self {
PasswordError::Io(e.to_string())
}
}
/// Compute the HMAC-SHA256 derived on-disk filename for a plaintext relative path.
pub fn hmac_filename(key: &[u8; 32], plaintext_relpath: &str) -> String {
let signing_key = hmac::Key::new(hmac::HMAC_SHA256, key);
let tag = hmac::sign(&signing_key, plaintext_relpath.as_bytes());
let encoded = URL_SAFE_NO_PAD.encode(tag.as_ref());
encoded.chars().take(HMAC_FILENAME_LEN).collect()
}
/// Encrypt a single file's contents with its plaintext relative path embedded.
pub fn encrypt_profile_file(
key: &[u8; 32],
plaintext_relpath: &str,
file_bytes: &[u8],
) -> PasswordResult<Vec<u8>> {
let path_bytes = plaintext_relpath.as_bytes();
if path_bytes.len() > u16::MAX as usize {
return Err(PasswordError::Encryption("relpath too long".into()));
}
let mut plaintext = Vec::with_capacity(2 + path_bytes.len() + file_bytes.len());
plaintext.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
plaintext.extend_from_slice(path_bytes);
plaintext.extend_from_slice(file_bytes);
encrypt_bytes(key, &plaintext).map_err(PasswordError::Encryption)
}
/// Decrypt one file's bytes back into `(plaintext_relpath, file_bytes)`.
pub fn decrypt_profile_file(
key: &[u8; 32],
encrypted_bytes: &[u8],
) -> PasswordResult<(String, Vec<u8>)> {
let plaintext = decrypt_bytes(key, encrypted_bytes).map_err(|_| PasswordError::WrongPassword)?;
if plaintext.len() < 2 {
return Err(PasswordError::InvalidFormat);
}
let path_len = u16::from_le_bytes([plaintext[0], plaintext[1]]) as usize;
if plaintext.len() < 2 + path_len {
return Err(PasswordError::InvalidFormat);
}
let path = std::str::from_utf8(&plaintext[2..2 + path_len])
.map_err(|_| PasswordError::InvalidFormat)?
.to_string();
let content = plaintext[2 + path_len..].to_vec();
Ok((path, content))
}
fn build_excludes(patterns: &[&str]) -> GlobSet {
let mut builder = GlobSetBuilder::new();
for p in patterns {
if let Ok(g) = Glob::new(p) {
builder.add(g);
}
}
builder.build().unwrap_or_else(|_| GlobSet::empty())
}
fn walk_files(
base: &Path,
current: &Path,
excludes: &GlobSet,
out: &mut Vec<(String, PathBuf)>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
let relative = path
.strip_prefix(base)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
if excludes.is_match(&relative) {
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if metadata.is_dir() {
walk_files(base, &path, excludes, out)?;
} else if metadata.is_file() {
out.push((relative, path));
}
}
Ok(())
}
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("donut-tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)
}
fn write_verifier(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
let encrypted = encrypt_profile_file(key, VERIFY_FILE_PATH, b"donut-verify")?;
let path = encrypted_dir.join(VERIFY_FILE_NAME);
atomic_write(&path, &encrypted)?;
Ok(())
}
/// Verify a derived key against an encrypted profile dir. Returns Ok(()) on
/// success, `Err(WrongPassword)` if the password is wrong, or another error
/// for I/O / format problems.
pub fn verify_key_against_dir(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
let path = encrypted_dir.join(VERIFY_FILE_NAME);
if !path.exists() {
return Err(PasswordError::InvalidFormat);
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
if relpath != VERIFY_FILE_PATH || content != b"donut-verify" {
return Err(PasswordError::InvalidFormat);
}
Ok(())
}
/// Encrypt every file under `plaintext_dir` into `encrypted_dir`, replacing
/// it. Files matching `exclude_patterns` are dropped.
pub fn encrypt_profile_dir(
key: &[u8; 32],
plaintext_dir: &Path,
encrypted_dir: &Path,
exclude_patterns: &[&str],
) -> PasswordResult<()> {
if encrypted_dir.exists() {
std::fs::remove_dir_all(encrypted_dir)?;
}
std::fs::create_dir_all(encrypted_dir)?;
let excludes = build_excludes(exclude_patterns);
let mut files = Vec::new();
if plaintext_dir.exists() {
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut files)?;
}
for (relpath, abs) in files {
let bytes = std::fs::read(&abs)?;
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
atomic_write(&on_disk, &encrypted)?;
}
write_verifier(key, encrypted_dir)?;
Ok(())
}
/// Decrypt every file in `encrypted_dir` back into `plaintext_dir` (which is
/// created if missing). Returns the per-file mtimes captured after writing,
/// keyed by plaintext relpath. Caller can use them as the "before-launch"
/// snapshot to skip unchanged files on re-encrypt.
pub fn decrypt_profile_dir(
key: &[u8; 32],
encrypted_dir: &Path,
plaintext_dir: &Path,
) -> PasswordResult<HashMap<String, SystemTime>> {
std::fs::create_dir_all(plaintext_dir)?;
let mut mtimes = HashMap::new();
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
for entry in entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
let dest = plaintext_dir.join(&relpath);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, &content)?;
if let Ok(m) = dest.metadata().and_then(|m| m.modified()) {
mtimes.insert(relpath, m);
}
}
Ok(mtimes)
}
/// Re-encrypt the contents of `plaintext_dir` back into `encrypted_dir`,
/// preserving on-disk filenames for files whose plaintext content didn't
/// change. Returns the number of files re-encrypted.
///
/// `before_launch_mtimes` is the snapshot captured by `decrypt_profile_dir`.
/// Files whose mtime hasn't moved are left untouched on disk.
pub fn reencrypt_changed_files(
key: &[u8; 32],
plaintext_dir: &Path,
encrypted_dir: &Path,
exclude_patterns: &[&str],
before_launch_mtimes: &HashMap<String, SystemTime>,
) -> PasswordResult<usize> {
std::fs::create_dir_all(encrypted_dir)?;
let excludes = build_excludes(exclude_patterns);
let mut current_files = Vec::new();
if plaintext_dir.exists() {
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut current_files)?;
}
let mut current_paths: HashSet<String> = HashSet::new();
let mut rewrote = 0usize;
for (relpath, abs) in current_files {
current_paths.insert(relpath.clone());
let cur_mtime = abs.metadata().and_then(|m| m.modified()).ok();
let unchanged = match (cur_mtime, before_launch_mtimes.get(&relpath)) {
(Some(now), Some(before)) => now == *before,
_ => false,
};
if unchanged {
continue;
}
let bytes = std::fs::read(&abs)?;
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
atomic_write(&on_disk, &encrypted)?;
rewrote += 1;
}
// Delete on-disk files for plaintext paths that no longer exist
let valid_names: HashSet<String> = current_paths
.iter()
.map(|p| hmac_filename(key, p))
.collect();
for entry in std::fs::read_dir(encrypted_dir)?.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
if !valid_names.contains(&name) {
let _ = std::fs::remove_file(&path);
}
}
write_verifier(key, encrypted_dir)?;
Ok(rewrote)
}
/// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in
/// place. Used when changing a profile password without launching it.
pub fn rekey_profile_dir(
old_key: &[u8; 32],
new_key: &[u8; 32],
encrypted_dir: &Path,
) -> PasswordResult<()> {
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
let mut decrypted: Vec<(String, Vec<u8>)> = Vec::new();
for entry in &entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(old_key, &bytes)?;
decrypted.push((relpath, content));
}
// Decryption succeeded for every file; safe to rewrite the directory.
for entry in entries {
let path = entry.path();
if path.is_file() {
let _ = std::fs::remove_file(&path);
}
}
for (relpath, content) in decrypted {
let encrypted = encrypt_profile_file(new_key, &relpath, &content)?;
let on_disk = encrypted_dir.join(hmac_filename(new_key, &relpath));
atomic_write(&on_disk, &encrypted)?;
}
write_verifier(new_key, encrypted_dir)?;
Ok(())
}
// ---------- key cache ----------
pub fn cache_key(profile_id: uuid::Uuid, key: [u8; 32]) {
if let Ok(mut guard) = KEY_CACHE.lock() {
guard.insert(profile_id, key);
}
}
pub fn get_cached_key(profile_id: &uuid::Uuid) -> Option<[u8; 32]> {
KEY_CACHE.lock().ok()?.get(profile_id).copied()
}
pub fn drop_cached_key(profile_id: &uuid::Uuid) {
if let Ok(mut guard) = KEY_CACHE.lock() {
guard.remove(profile_id);
}
}
pub fn has_cached_key(profile_id: &uuid::Uuid) -> bool {
KEY_CACHE
.lock()
.map(|g| g.contains_key(profile_id))
.unwrap_or(false)
}
/// Convenience: derive + verify against the encrypted dir + cache the key on success.
pub fn unlock(
profile_id: uuid::Uuid,
password: &str,
salt: &str,
encrypted_dir: &Path,
) -> PasswordResult<()> {
let key = derive_profile_key(password, salt).map_err(PasswordError::Encryption)?;
verify_key_against_dir(&key, encrypted_dir)?;
cache_key(profile_id, key);
Ok(())
}
pub fn fresh_salt() -> String {
generate_salt()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_key() -> [u8; 32] {
derive_profile_key("hunter2", &generate_salt()).unwrap()
}
#[test]
fn test_hmac_filename_deterministic() {
let key = [7u8; 32];
let a = hmac_filename(&key, "Default/Cookies");
let b = hmac_filename(&key, "Default/Cookies");
assert_eq!(a, b);
assert_eq!(a.len(), HMAC_FILENAME_LEN);
}
#[test]
fn test_hmac_filename_different_keys() {
let a = hmac_filename(&[1u8; 32], "Default/Cookies");
let b = hmac_filename(&[2u8; 32], "Default/Cookies");
assert_ne!(a, b);
}
#[test]
fn test_hmac_filename_different_paths() {
let key = [1u8; 32];
let a = hmac_filename(&key, "Default/Cookies");
let b = hmac_filename(&key, "Default/Login Data");
assert_ne!(a, b);
}
#[test]
fn test_file_roundtrip() {
let key = make_key();
let original = b"hello world".to_vec();
let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap();
let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap();
assert_eq!(path, "Default/Cookies");
assert_eq!(content, original);
}
#[test]
fn test_file_wrong_key_fails() {
let key1 = make_key();
let key2 = make_key();
let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap();
assert!(matches!(
decrypt_profile_file(&key2, &encrypted),
Err(PasswordError::WrongPassword)
));
}
#[test]
fn test_file_truncated_ciphertext() {
let key = make_key();
let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap();
// Drop the auth tag
let truncated = &encrypted[..encrypted.len() - 1];
assert!(decrypt_profile_file(&key, truncated).is_err());
}
#[test]
fn test_dir_roundtrip() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(plain.join("Default")).unwrap();
std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap();
std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
std::fs::write(plain.join("Local State"), b"state").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
// No plaintext filenames on disk
let names: Vec<String> = std::fs::read_dir(&enc)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
for n in &names {
assert!(!n.contains("Cookies"), "plaintext leaked: {n}");
assert!(!n.contains("Bookmarks"));
assert!(!n.contains("Local State"));
}
// Verify file present
assert!(enc.join(VERIFY_FILE_NAME).exists());
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
assert_eq!(mtimes.len(), 3);
assert_eq!(
std::fs::read(restored.join("Default/Cookies")).unwrap(),
b"sqlite-data"
);
assert_eq!(
std::fs::read(restored.join("Default/Bookmarks")).unwrap(),
b"{\"x\":1}"
);
assert_eq!(
std::fs::read(restored.join("Local State")).unwrap(),
b"state"
);
}
#[test]
fn test_dir_excludes() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(plain.join("Default/Cache")).unwrap();
std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap();
std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap();
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
// Only Cookies (1 file) should be present, not Cache contents
assert_eq!(mtimes.len(), 1);
assert!(mtimes.contains_key("Default/Cookies"));
assert!(restored.join("Default/Cookies").exists());
assert!(!restored.join("Default/Cache/data").exists());
}
#[test]
fn test_verify_against_wrong_key() {
let key1 = make_key();
let key2 = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("file"), b"data").unwrap();
encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap();
assert!(verify_key_against_dir(&key1, &enc).is_ok());
assert!(matches!(
verify_key_against_dir(&key2, &enc),
Err(PasswordError::WrongPassword)
));
}
#[test]
fn test_reencrypt_skips_unchanged() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("a"), b"AAA").unwrap();
std::fs::write(plain.join("b"), b"BBB").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
// Capture pre-rewrite ciphertext bytes
let name_a = hmac_filename(&key, "a");
let name_b = hmac_filename(&key, "b");
let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap();
let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap();
// Modify only "a" in the restored tree
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap();
let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
assert_eq!(rewrote, 1);
let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap();
let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap();
assert_ne!(
cipher_a_before, cipher_a_after,
"changed file should have new ciphertext"
);
assert_eq!(
cipher_b_before, cipher_b_after,
"unchanged file should have stable ciphertext"
);
}
#[test]
fn test_reencrypt_handles_added_and_removed() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("keep"), b"k").unwrap();
std::fs::write(plain.join("delete"), b"d").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
std::fs::remove_file(restored.join("delete")).unwrap();
std::fs::write(restored.join("new"), b"n").unwrap();
reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
let names: HashSet<String> = std::fs::read_dir(&enc)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&hmac_filename(&key, "keep")));
assert!(names.contains(&hmac_filename(&key, "new")));
assert!(!names.contains(&hmac_filename(&key, "delete")));
assert!(names.contains(VERIFY_FILE_NAME));
}
#[test]
fn test_rekey_changes_filenames_and_content() {
let old = make_key();
let new = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("x"), b"data").unwrap();
encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap();
let old_name = hmac_filename(&old, "x");
let new_name = hmac_filename(&new, "x");
assert_ne!(old_name, new_name);
rekey_profile_dir(&old, &new, &enc).unwrap();
assert!(!enc.join(&old_name).exists());
assert!(enc.join(&new_name).exists());
verify_key_against_dir(&new, &enc).unwrap();
assert!(matches!(
verify_key_against_dir(&old, &enc),
Err(PasswordError::WrongPassword)
));
let restored = work.path().join("restored");
decrypt_profile_dir(&new, &enc, &restored).unwrap();
assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data");
}
#[test]
fn test_atomic_write_leaves_original_intact_if_tmp_lingers() {
let work = TempDir::new().unwrap();
let target = work.path().join("file");
std::fs::write(&target, b"original").unwrap();
// Simulate a stale tmp from a crashed write
std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap();
// A successful write should overwrite the original even when stale tmp exists
atomic_write(&target, b"new").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"new");
}
#[test]
fn test_key_cache_lifecycle() {
let id = uuid::Uuid::new_v4();
assert!(!has_cached_key(&id));
cache_key(id, [9u8; 32]);
assert!(has_cached_key(&id));
assert_eq!(get_cached_key(&id), Some([9u8; 32]));
drop_cached_key(&id);
assert!(!has_cached_key(&id));
}
#[test]
fn test_unlock_helper() {
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("x"), b"data").unwrap();
let salt = generate_salt();
let key = derive_profile_key("correct horse", &salt).unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let id = uuid::Uuid::new_v4();
drop_cached_key(&id);
assert!(unlock(id, "wrong", &salt, &enc).is_err());
assert!(!has_cached_key(&id));
assert!(unlock(id, "correct horse", &salt, &enc).is_ok());
assert!(has_cached_key(&id));
drop_cached_key(&id);
}
}
+271 -40
View File
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url;
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
});
{
let mut f = fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)
}
pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
@@ -184,6 +198,9 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -285,6 +302,9 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -340,6 +360,14 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Save profile info
@@ -352,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)?;
@@ -385,7 +422,7 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?;
let json = serde_json::to_string_pretty(profile)?;
fs::write(profile_file, json)?;
atomic_write(&profile_file, json.as_bytes())?;
// Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -410,8 +447,26 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
let content = match fs::read_to_string(&metadata_file) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Skipping profile at {}: failed to read metadata.json: {e}",
path.display()
);
continue;
}
};
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!(
"Skipping profile at {}: invalid metadata.json: {e}",
path.display()
);
continue;
}
};
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
@@ -420,7 +475,7 @@ impl ProfileManager {
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
let _ = atomic_write(&metadata_file, json.as_bytes());
}
}
}
@@ -458,10 +513,13 @@ impl ProfileManager {
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile with new name
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -642,7 +700,7 @@ impl ProfileManager {
pub fn assign_profiles_to_group(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_ids: Vec<String>,
group_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
@@ -665,16 +723,17 @@ impl ProfileManager {
}
profile.group_id = group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id {
let group_id_clone = new_group_id.clone();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
let _ =
crate::sync::enable_group_sync_if_needed(&group_id_clone, &app_handle_clone).await;
let _ = crate::sync::enable_group_sync_if_needed(&group_id_clone).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_group_sync(group_id_clone).await;
}
@@ -719,10 +778,13 @@ impl ProfileManager {
}
}
profile.tags = deduped;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -753,10 +815,13 @@ impl ProfileManager {
// Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -780,9 +845,12 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -809,9 +877,12 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.proxy_bypass_rules = rules;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -833,9 +904,12 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.dns_blocklist = dns_blocklist;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -987,6 +1061,14 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: source.dns_blocklist,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_profile(&new_profile)?;
@@ -1044,6 +1126,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1104,6 +1188,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1120,7 +1206,7 @@ impl ProfileManager {
pub async fn update_profile_proxy(
&self,
app_handle: tauri::AppHandle,
_app_handle: tauri::AppHandle,
profile_id: &str,
proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
@@ -1150,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
@@ -1158,28 +1245,46 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id, &app_handle).await;
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_proxy_sync(new_proxy_id.clone()).await;
}
}
}
// 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
@@ -1188,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)
@@ -1238,8 +1334,9 @@ impl ProfileManager {
})?;
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id;
profile.vpn_id = vpn_id.clone();
profile.proxy_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self
.save_profile(&profile)
@@ -1247,6 +1344,18 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
let _ = crate::sync::enable_vpn_sync_if_needed(new_vpn_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_vpn_sync(new_vpn_id.clone()).await;
}
}
}
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1271,9 +1380,26 @@ impl ProfileManager {
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id;
profile.extension_group_id = extension_group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller.
if profile.is_sync_enabled() {
if let Some(new_group_id) = extension_group_id {
tauri::async_runtime::spawn(async move {
let _ = crate::sync::enable_extension_group_sync_if_needed(&new_group_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_extension_group_sync(new_group_id).await;
}
});
}
}
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Failed to emit profile update event: {e}");
}
@@ -1413,13 +1539,18 @@ impl ProfileManager {
};
let mut merged = latest_profile.clone();
let mut detected_stop = false;
if let Some(pid) = found_pid {
if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}");
}
if let Some(prev) = old_pid {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, pid);
}
}
} else if merged.process_id.is_some() {
// Clear the PID if no process found
@@ -1427,6 +1558,15 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}");
}
detected_stop = true;
}
if detected_stop {
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(&app_handle, &merged)
{
merged = updated;
}
}
// Emit profile update event to frontend
@@ -1441,7 +1581,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager
async fn check_camoufox_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager;
@@ -1470,10 +1610,14 @@ impl ProfileManager {
};
if latest.process_id != camoufox_process.processId {
let old_pid = latest.process_id;
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, camoufox_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1515,6 +1659,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1551,6 +1701,12 @@ impl ProfileManager {
);
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
// Emit profile update event to frontend
if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1565,7 +1721,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager
async fn check_wayfern_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager;
@@ -1594,10 +1750,14 @@ impl ProfileManager {
};
if latest.process_id != wayfern_process.processId {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, wayfern_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1639,6 +1799,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1663,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(),
@@ -2056,6 +2229,38 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("http or https"));
}
#[test]
fn test_validate_launch_hook_accepts_https_url() {
let result = super::validate_launch_hook(Some("https://example.com/track")).unwrap();
assert_eq!(result.as_deref(), Some("https://example.com/track"));
}
#[test]
fn test_validate_launch_hook_rejects_garbage_with_code() {
let err = super::validate_launch_hook(Some("not a url")).unwrap_err();
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
}
#[test]
fn test_validate_launch_hook_rejects_non_http_scheme_with_code() {
let err = super::validate_launch_hook(Some("ftp://example.com/hook")).unwrap_err();
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
}
#[test]
fn test_validate_launch_hook_empty_clears_hook() {
let result = super::validate_launch_hook(Some("")).unwrap();
assert!(result.is_none());
let result_ws = super::validate_launch_hook(Some(" ")).unwrap();
assert!(result_ws.is_none());
let result_none = super::validate_launch_hook(None).unwrap();
assert!(result_none.is_none());
}
}
#[allow(clippy::too_many_arguments)]
@@ -2154,12 +2359,34 @@ pub fn update_profile_note(
.map_err(|e| format!("Failed to update profile note: {e}"))
}
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
pub(crate) fn validate_launch_hook(launch_hook: Option<&str>) -> Result<Option<String>, String> {
let Some(raw) = launch_hook else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
let ok = url::Url::parse(trimmed)
.ok()
.map(|u| matches!(u.scheme(), "http" | "https"))
.unwrap_or(false);
if !ok {
return Err(serde_json::json!({ "code": "INVALID_LAUNCH_HOOK_URL" }).to_string());
}
Ok(Some(trimmed.to_string()))
}
#[tauri::command]
pub fn update_profile_launch_hook(
app_handle: tauri::AppHandle,
profile_id: String,
launch_hook: Option<String>,
) -> Result<BrowserProfile, String> {
validate_launch_hook(launch_hook.as_deref())?;
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
@@ -2242,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(
@@ -2273,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
@@ -2301,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
+2
View File
@@ -1,4 +1,6 @@
pub mod encryption;
pub mod manager;
pub mod password;
pub mod types;
pub use manager::ProfileManager;
File diff suppressed because it is too large Load Diff
+15
View File
@@ -69,6 +69,21 @@ pub struct BrowserProfile {
pub created_by_email: Option<String>,
#[serde(default)]
pub dns_blocklist: Option<String>,
/// True when the on-disk profile dir is encrypted with a per-profile password.
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)]
pub password_protected: bool,
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
/// profiles that pre-date this field — those are treated as ancient by
/// 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 {
+14
View File
@@ -584,6 +584,9 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -664,6 +667,9 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -715,6 +721,14 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.profile_manager.save_profile(&profile)?;
+107 -415
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());
}
}
@@ -174,6 +190,10 @@ pub struct ProxyManager {
// Track active proxy IDs by profile name for targeted cleanup
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
// Consecutive cleanup passes during which a browser PID looked dead.
// We only reap a worker after it has been missed in N consecutive scans —
// a single sysinfo blip under load shouldn't kill a still-running worker.
dead_browser_misses: Mutex<HashMap<u32, u8>>,
}
impl ProxyManager {
@@ -183,6 +203,7 @@ impl ProxyManager {
profile_proxies: Mutex::new(HashMap::new()),
profile_active_proxy_ids: Mutex::new(HashMap::new()),
stored_proxies: Mutex::new(HashMap::new()),
dead_browser_misses: Mutex::new(HashMap::new()),
};
// Load stored proxies on initialization
@@ -450,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,
@@ -641,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),
@@ -705,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();
@@ -825,6 +849,42 @@ impl ProxyManager {
Ok(updated_proxy)
}
/// Update the in-memory `sync_enabled` / `last_sync` fields of a stored
/// proxy and persist the change to disk. Returns the updated proxy or
/// `Err` if the proxy isn't found / is cloud-managed.
///
/// This is the canonical write path for sync-state changes — direct
/// `fs::write` from a sync command would leave the in-memory cache
/// (`stored_proxies`) stale, and the next `get_stored_proxies()` would
/// return the old `sync_enabled`, breaking the UI toggle.
pub fn set_stored_proxy_sync_state(
&self,
proxy_id: &str,
sync_enabled: bool,
last_sync: Option<u64>,
) -> Result<StoredProxy, String> {
let updated_proxy = {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
let proxy = stored_proxies
.get_mut(proxy_id)
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
if proxy.is_cloud_managed {
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
}
proxy.sync_enabled = sync_enabled;
proxy.last_sync = last_sync;
proxy.clone()
};
self
.save_proxy(&updated_proxy)
.map_err(|e| format!("Failed to save proxy: {e}"))?;
Ok(updated_proxy)
}
// Delete a stored proxy
pub fn delete_stored_proxy(
&self,
@@ -1074,149 +1134,6 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
pub async fn fetch_proxy_from_url(
&self,
url: &str,
timeout: std::time::Duration,
) -> Result<Option<ProxySettings>, String> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!("Launch hook returned status {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
let body = body.trim();
if body.is_empty() {
return Err("Launch hook returned empty response".to_string());
}
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
return Ok(Some(settings));
}
match Self::parse_dynamic_proxy_text(body) {
Ok(settings) => Ok(Some(settings)),
Err(text_error) => Err(format!(
"Failed to parse launch hook response: {text_error}"
)),
}
}
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
let obj = json
.as_object()
.ok_or_else(|| "JSON response is not an object".to_string())?;
let raw_host = obj
.get("ip")
.or_else(|| obj.get("host"))
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
// and extract the proxy type from it if no explicit type field is provided
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
(rest.to_string(), None)
} else if let Some((proto, rest)) = raw_host.split_once("://") {
(rest.to_string(), Some(proto.to_lowercase()))
} else {
(raw_host.to_string(), None)
};
let port = obj
.get("port")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
as u16;
let proxy_type = obj
.get("type")
.or_else(|| obj.get("proxy_type"))
.or_else(|| obj.get("protocol"))
.and_then(|v| v.as_str())
.map(|s| s.to_lowercase())
.or(protocol_from_host)
.unwrap_or_else(|| "http".to_string());
let username = obj
.get("username")
.or_else(|| obj.get("user"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let password = obj
.get("password")
.or_else(|| obj.get("pass"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Ok(ProxySettings {
proxy_type,
host,
port,
username,
password,
})
}
// Parse plain text proxy payload using the same logic as proxy import
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
let line = body
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if line.is_empty() {
return Err("Empty text response".to_string());
}
match Self::parse_single_proxy_line(line) {
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
}),
ProxyParseResult::Ambiguous {
possible_formats, ..
} => Err(format!(
"Ambiguous proxy format. Could be: {}",
possible_formats.join(" or ")
)),
ProxyParseResult::Invalid { reason, .. } => {
Err(format!("Failed to parse proxy response: {reason}"))
}
}
}
// Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2095,17 +2012,52 @@ impl ProxyManager {
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
);
let dead_browser_entries: Vec<(u32, String, Option<String>)> = snapshot
.into_iter()
.filter(|(browser_pid, _, _)| {
// The sentinel PID=0 is used as a placeholder during launch,
// before update_proxy_pid has recorded the real browser PID.
*browser_pid != 0
&& system
.process(sysinfo::Pid::from_u32(*browser_pid))
.is_none()
})
.collect();
// Two-state classification: alive PIDs reset their miss counter,
// dead PIDs increment it. A worker is only reaped after MISS_THRESHOLD
// consecutive misses (~60s by default given the 30s cleanup cadence),
// so a single sysinfo blip under heavy load doesn't kill a healthy worker.
const MISS_THRESHOLD: u8 = 2;
let mut alive_pids: Vec<u32> = Vec::new();
let mut dead_candidates: Vec<(u32, String, Option<String>)> = Vec::new();
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
for (browser_pid, proxy_id, profile_id) in snapshot {
snapshot_pids.insert(browser_pid);
// The sentinel PID=0 is used as a placeholder during launch,
// before update_proxy_pid has recorded the real browser PID.
if browser_pid == 0 {
continue;
}
if system
.process(sysinfo::Pid::from_u32(browser_pid))
.is_some()
{
alive_pids.push(browser_pid);
} else {
dead_candidates.push((browser_pid, proxy_id, profile_id));
}
}
let dead_browser_entries: Vec<(u32, String, Option<String>)> = {
let mut misses = self.dead_browser_misses.lock().unwrap();
// Forget PIDs no longer tracked at all (worker already torn down elsewhere).
misses.retain(|pid, _| snapshot_pids.contains(pid));
// Reset miss count for any PID that's currently alive.
for pid in &alive_pids {
misses.remove(pid);
}
// Increment dead candidates and select those past threshold.
let mut to_reap = Vec::new();
for (browser_pid, proxy_id, profile_id) in dead_candidates {
let count = misses.entry(browser_pid).or_insert(0);
*count = count.saturating_add(1);
if *count >= MISS_THRESHOLD {
misses.remove(&browser_pid);
to_reap.push((browser_pid, proxy_id, profile_id));
}
}
to_reap
};
for (browser_pid, proxy_id, profile_id) in dead_browser_entries {
log::info!(
@@ -2241,8 +2193,6 @@ mod tests {
use hyper::Response;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Helper function to build donut-proxy binary for testing
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -3223,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()),
@@ -3511,263 +3462,4 @@ mod tests {
delete_proxy_config(&id);
}
#[test]
fn test_parse_dynamic_proxy_json_standard_format() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 8080);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_json_host_alias() {
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.username.as_deref(), Some("u"));
assert_eq!(result.password.as_deref(), Some("p"));
}
#[test]
fn test_parse_dynamic_proxy_json_port_as_string() {
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.port, 9090);
}
#[test]
fn test_parse_dynamic_proxy_json_with_proxy_type() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "socks4");
// "protocol" field alias
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_normalizes_case() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "http");
}
#[test]
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.port, 1080);
// Protocol in host should be used as proxy_type when no explicit type field
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.host, "10.0.0.1");
assert_eq!(result2.proxy_type, "http");
// Explicit type field takes precedence over protocol in host
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.host, "10.0.0.1");
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_missing_ip() {
let body = r#"{"port": 8080}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("ip") || err.contains("host"));
}
#[test]
fn test_parse_dynamic_proxy_json_missing_port() {
let body = r#"{"ip": "1.2.3.4"}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn test_parse_dynamic_proxy_json_invalid_json() {
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
assert!(err.contains("Invalid JSON"));
}
#[test]
fn test_parse_dynamic_proxy_json_not_object() {
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
assert!(err.contains("not an object"));
}
#[test]
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
let body = "proxy.example.com:8080:user1:pass1";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_text_protocol_url_format() {
let body = "http://user:pass@proxy.example.com:3128";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_dynamic_proxy_text_with_whitespace() {
let body = " \n proxy.example.com:8080:user:pass \n ";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
}
#[test]
fn test_parse_dynamic_proxy_text_empty() {
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_parse_dynamic_proxy_text_whitespace_only() {
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
assert!(err.contains("Empty"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_json_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_text_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 1080);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_fetch_proxy_from_url_respects_timeout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let err = pm
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
.await
.unwrap_err();
assert!(err.contains("Failed to fetch launch hook"));
}
}
-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(),
}
}
+151 -93
View File
@@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpListener;
use tokio::net::TcpStream;
/// Combined read+write trait for tunnel target streams, allowing
@@ -919,8 +918,8 @@ async fn handle_http(
return Ok(response);
}
log::error!(
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
log::trace!(
"Handling HTTP request: {} {} (host: {:?})",
req.method(),
req.uri(),
req.uri().host()
@@ -1148,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;
}
@@ -1183,7 +1185,7 @@ pub async fn handle_proxy_connection(
}
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
log::error!(
log::info!(
"Proxy worker starting, looking for config id: {}",
config.id
);
@@ -1197,7 +1199,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}
};
log::error!(
log::info!(
"Found config: id={}, port={:?}, upstream={}, profile_id={:?}",
config.id,
config.local_port,
@@ -1205,49 +1207,67 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
config.profile_id
);
log::error!("Starting proxy server for config id: {}", config.id);
// Initialize traffic tracker with profile ID if available
// This can now be called multiple times to update the tracker
// Initialize traffic tracker with profile ID if available.
// This can be called multiple times to update the tracker.
init_traffic_tracker(config.id.clone(), config.profile_id.clone());
log::error!(
"Traffic tracker initialized for proxy: {} (profile_id: {:?})",
config.id,
config.profile_id
);
// Verify tracker was initialized correctly
if let Some(tracker) = crate::traffic_stats::get_traffic_tracker() {
log::error!(
"Tracker verified: proxy_id={}, profile_id={:?}",
tracker.proxy_id,
tracker.profile_id
);
} else {
log::error!("WARNING: Tracker was not initialized!");
}
// Determine the bind address
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0)));
log::error!("Attempting to bind proxy server to {}", bind_addr);
log::info!("Attempting to bind proxy server to {}", bind_addr);
// Bind to the port
let listener = TcpListener::bind(bind_addr).await?;
// Bind to the port. Use SO_REUSEADDR so that a freshly-restarted worker
// can bind a port that the previous worker left in TIME_WAIT, and retry
// briefly to absorb transient races with the OS releasing the socket.
let listener = {
let mut attempts: u32 = 0;
loop {
let socket = tokio::net::TcpSocket::new_v4()?;
let _ = socket.set_reuseaddr(true);
match socket.bind(bind_addr) {
Ok(()) => match socket.listen(1024) {
Ok(l) => break l,
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"listen() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(e) => {
return Err(format!("Failed to listen on {bind_addr} after 5 attempts: {e}").into())
}
},
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"bind() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(e) => return Err(format!("Failed to bind {bind_addr} after 5 attempts: {e}").into()),
}
}
};
let actual_port = listener.local_addr()?.port();
log::error!("Successfully bound to port {}", actual_port);
log::info!("Successfully bound to port {}", actual_port);
// Update config with actual port and local_url
let mut updated_config = config.clone();
updated_config.local_port = Some(actual_port);
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
// Save the updated config
log::error!(
"Saving updated config with local_url={:?}",
updated_config.local_url
);
if !crate::proxy_storage::update_proxy_config(&updated_config) {
log::error!("Failed to update proxy config");
return Err("Failed to update proxy config".into());
@@ -1259,12 +1279,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
Some(updated_config.upstream_url.clone())
};
log::error!("Proxy server bound to 127.0.0.1:{}", actual_port);
log::error!(
log::info!(
"Proxy server listening on 127.0.0.1:{} (ready to accept connections)",
actual_port
);
log::error!("Proxy server entering accept loop - process should stay alive");
log::info!("Proxy server entering accept loop - process should stay alive");
// Start a background task to write lightweight session snapshots for real-time updates
// These are much smaller than full stats and can be written frequently (~100 bytes every 2 seconds)
@@ -1295,52 +1314,54 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
loop {
interval.tick().await;
if let Some(tracker) = get_traffic_tracker() {
let (sent, recv, requests) = tracker.get_snapshot();
let current_bytes = sent + recv;
let time_since_activity = last_activity_time.elapsed();
let time_since_flush = last_flush_time.elapsed();
let has_traffic = current_bytes > 0 || requests > 0;
// Catch panics so a poisoned lock or unexpected error inside
// flush_to_disk doesn't abort the flush task and leave stats
// unwritten for the lifetime of the worker. The captured state
// is all Copy or atomic-assignment, so AssertUnwindSafe is sound.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if let Some(tracker) = get_traffic_tracker() {
let (sent, recv, requests) = tracker.get_snapshot();
let current_bytes = sent + recv;
let time_since_activity = last_activity_time.elapsed();
let time_since_flush = last_flush_time.elapsed();
let has_traffic = current_bytes > 0 || requests > 0;
// Determine flush frequency based on activity
// When active: flush every 5 seconds
// When idle: flush every 30 seconds
let desired_interval_secs =
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
5u64
} else {
30u64
};
let desired_interval_secs =
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
5u64
} else {
30u64
};
// Update interval if needed
if desired_interval_secs != current_interval_secs {
current_interval_secs = desired_interval_secs;
interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
}
if desired_interval_secs != current_interval_secs {
current_interval_secs = desired_interval_secs;
interval =
tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
}
// Only flush if enough time has passed since last flush
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
let should_flush = time_since_flush >= flush_interval;
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
let should_flush = time_since_flush >= flush_interval;
if should_flush {
match tracker.flush_to_disk() {
Ok(Some((sent, recv))) => {
// Successful flush with data
last_flush_time = std::time::Instant::now();
if sent > 0 || recv > 0 {
last_activity_time = std::time::Instant::now();
if should_flush {
match tracker.flush_to_disk() {
Ok(Some((sent, recv))) => {
last_flush_time = std::time::Instant::now();
if sent > 0 || recv > 0 {
last_activity_time = std::time::Instant::now();
}
}
Ok(None) => {
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
}
}
Ok(None) => {
// No data to flush - this is normal
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
// Don't update flush time on error - retry sooner
}
}
}
}));
if let Err(panic) = result {
log::error!("Panic caught in proxy traffic flush task; continuing: {panic:?}");
}
}
});
@@ -1431,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.
@@ -1485,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" => {
@@ -1581,7 +1643,7 @@ async fn handle_connect_from_buffer(
.await?;
client_stream.flush().await?;
log::error!("DEBUG: Sent 200 Connection Established response, starting tunnel");
log::trace!("Sent 200 Connection Established response, starting tunnel");
// Now tunnel data bidirectionally with counting
// Wrap streams to count bytes transferred
@@ -1598,17 +1660,17 @@ async fn handle_connect_from_buffer(
let (mut client_read, mut client_write) = tokio::io::split(counting_client);
let (mut target_read, mut target_write) = tokio::io::split(counting_target);
log::error!("DEBUG: Starting bidirectional tunnel");
log::trace!("Starting bidirectional tunnel");
// Spawn two tasks to forward data in both directions
let client_to_target = tokio::spawn(async move {
let result = tokio::io::copy(&mut client_read, &mut target_write).await;
match result {
Ok(bytes) => {
log::error!("DEBUG: Tunneled {} bytes from client->target", bytes);
log::trace!("Tunneled {bytes} bytes from client->target");
}
Err(e) => {
log::error!("Error forwarding client->target: {:?}", e);
log::debug!("Error forwarding client->target: {e:?}");
}
}
});
@@ -1617,10 +1679,10 @@ async fn handle_connect_from_buffer(
let result = tokio::io::copy(&mut target_read, &mut client_write).await;
match result {
Ok(bytes) => {
log::error!("DEBUG: Tunneled {} bytes from target->client", bytes);
log::trace!("Tunneled {bytes} bytes from target->client");
}
Err(e) => {
log::error!("Error forwarding target->client: {:?}", e);
log::debug!("Error forwarding target->client: {e:?}");
}
}
});
@@ -1628,10 +1690,10 @@ async fn handle_connect_from_buffer(
// Wait for either direction to finish (connection closed)
tokio::select! {
_ = client_to_target => {
log::error!("DEBUG: Client->target tunnel closed");
log::trace!("Client->target tunnel closed");
}
_ = target_to_client => {
log::error!("DEBUG: Target->client tunnel closed");
log::trace!("Target->client tunnel closed");
}
}
@@ -1640,11 +1702,7 @@ async fn handle_connect_from_buffer(
client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed);
let final_recv =
target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed);
log::error!(
"DEBUG: Tunnel closed - sent: {} bytes, received: {} bytes",
final_sent,
final_recv
);
log::trace!("Tunnel closed - sent: {final_sent} bytes, received: {final_recv} bytes");
// Update domain-specific byte counts now that tunnel is complete
if let Some(tracker) = get_traffic_tracker() {
+136 -55
View File
@@ -50,13 +50,18 @@ 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
/// copy is always re-encrypted regardless of this flag.
#[serde(default)]
pub keep_decrypted_profiles_in_ram: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -88,10 +93,11 @@ 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,
}
}
}
@@ -177,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()
}
@@ -789,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;
}
}
@@ -814,26 +808,103 @@ pub async fn save_app_settings(
Ok(settings)
}
/// Read the most recent N log files concatenated into a single string,
/// suitable for paste-into-issue-tracker. Newest entries appear LAST so the
/// reader sees fresh context at the bottom of the buffer. Capped at 5 MB to
/// keep clipboard payloads sane.
#[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}"))
pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, String> {
let dir = crate::app_dirs::log_dir(&app_handle);
if !dir.exists() {
return Err("Log directory does not exist yet".to_string());
}
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
.map_err(|e| format!("Failed to read log dir: {e}"))?
.filter_map(|r| r.ok())
.filter_map(|e| {
let p = e.path();
let m = e.metadata().ok()?.modified().ok()?;
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
if p.is_file() && (ext == "log" || ext == "txt") {
Some((p, m))
} else {
None
}
})
.collect();
entries.sort_by_key(|(_, m)| *m);
const MAX_BYTES: usize = 5 * 1024 * 1024;
let mut out = String::with_capacity(64 * 1024);
for (path, _) in entries.iter().rev() {
let header = format!("===== {} =====\n", path.display());
if out.len() + header.len() >= MAX_BYTES {
break;
}
out.push_str(&header);
if let Ok(content) = std::fs::read_to_string(path) {
let take = MAX_BYTES.saturating_sub(out.len());
if take == 0 {
break;
}
if content.len() > take {
// Tail truncation — keep the END of older files so newest data is preserved.
out.push_str("[…truncated — older content elided…]\n");
out.push_str(&content[content.len() - take + 64..]);
} else {
out.push_str(&content);
}
if !out.ends_with('\n') {
out.push('\n');
}
}
}
// Reverse the per-file order so chronological newest is at the bottom.
// (We pushed newest-first above to budget the tail; flip now.)
let mut sections: Vec<&str> = out.split("===== ").filter(|s| !s.is_empty()).collect();
sections.reverse();
let final_out = sections
.into_iter()
.map(|s| format!("===== {s}"))
.collect::<String>();
Ok(final_out)
}
/// Reveal the log directory in the OS file manager.
#[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}"))
}
pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), String> {
let dir = crate::app_dirs::log_dir(&app_handle);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create log dir: {e}"))?;
}
let path = dir.to_string_lossy().to_string();
#[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}"))
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
Ok(())
}
#[tauri::command]
@@ -886,6 +957,17 @@ pub async fn save_sync_settings(
sync_server_url: Option<String>,
sync_token: Option<String>,
) -> Result<SyncSettings, String> {
// Cloud login and self-hosted sync share the same sync engine and a
// profile can't be sync'd to two backends at once. Block any *write*
// (non-null URL or token) while the user is signed into their cloud
// account — the clearing path (both `None`) is always allowed so logged-
// in users can wipe a stale self-hosted config that pre-dates their
// sign-in.
let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some();
if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string());
}
let manager = SettingsManager::instance();
manager
@@ -931,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()
@@ -1066,10 +1169,11 @@ 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,
};
let save_result = manager.save_settings(&test_settings);
@@ -1130,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
+135 -3
View File
@@ -4,10 +4,40 @@ use aes_gcm::{
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use std::collections::HashMap;
use std::sync::Mutex;
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
const E2E_FILE_VERSION: u8 = 1;
/// Argon2id is intentionally expensive (~80150 ms per call). During an
/// encryption rollover, every synced entity (proxy, group, vpn, extension,
/// extension group, profile metadata) goes through `derive_profile_key`,
/// which without caching means hundreds of sequential 100 ms derivations.
///
/// Cache the derived key keyed on (sha256(password), salt). Entries are
/// evicted on `set_e2e_password` / `delete_e2e_password` so a password
/// change cannot use stale keys.
type DerivedKeyCache = HashMap<([u8; 32], String), [u8; 32]>;
static KEY_CACHE: std::sync::LazyLock<Mutex<DerivedKeyCache>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
fn password_fingerprint(pwd: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(pwd.as_bytes());
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
fn invalidate_key_cache() {
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.clear();
}
}
fn get_e2e_password_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("e2e_password.dat")
}
@@ -17,6 +47,7 @@ fn get_vault_password() -> String {
}
pub fn store_e2e_password(password: &str) -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path();
if let Some(parent) = file_path.parent() {
@@ -149,6 +180,7 @@ pub fn has_e2e_password() -> bool {
}
pub fn remove_e2e_password() -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path();
if file_path.exists() {
std::fs::remove_file(&file_path)
@@ -157,8 +189,20 @@ pub fn remove_e2e_password() -> Result<(), String> {
Ok(())
}
/// Derive a per-profile encryption key using Argon2id
/// Derive a per-profile encryption key using Argon2id, with an in-process
/// cache keyed on `(sha256(password), salt)`. Repeated calls with the same
/// password+salt are O(1); a password change calls `invalidate_key_cache`
/// to drop stale entries.
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
let pwd_fp = password_fingerprint(user_password);
let cache_key = (pwd_fp, profile_salt.to_string());
if let Ok(cache) = KEY_CACHE.lock() {
if let Some(cached) = cache.get(&cache_key) {
return Ok(*cached);
}
}
let salt_bytes = BASE64
.decode(profile_salt)
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
@@ -175,6 +219,11 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8
let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes[..32]);
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.insert(cache_key, key);
}
Ok(key)
}
@@ -220,13 +269,75 @@ pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String
.map_err(|e| format!("Decryption failed: {e}"))
}
/// Versioned encryption envelope used for non-profile entities (proxies,
/// VPNs, groups, extensions, extension groups). Each upload has its own
/// random per-entity salt so the bucket can't be rainbow-table-attacked
/// even with a shared password across many entities.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct EncryptedEnvelope {
/// Format version. Increment when changing how `ct` is structured.
pub v: u32,
/// Base64 of the per-entity salt. Plaintext on the wire — salts are public.
pub salt: String,
/// Base64 of `nonce(12B) || AES-256-GCM ciphertext` (output of `encrypt_bytes`).
pub ct: String,
}
/// Wrap a plaintext JSON byte slice into an encrypted envelope if the user
/// has E2E enabled. Returns `(payload_bytes, content_type)` ready to upload.
/// On no-password, returns the original JSON unchanged.
pub fn maybe_seal_for_upload(json: &[u8]) -> Result<(Vec<u8>, &'static str), String> {
let pwd = match load_e2e_password()? {
Some(p) => p,
None => return Ok((json.to_vec(), "application/json")),
};
let salt = generate_salt();
let key = derive_profile_key(&pwd, &salt)?;
let ct = encrypt_bytes(&key, json)?;
let envelope = EncryptedEnvelope {
v: 1,
salt,
ct: BASE64.encode(&ct),
};
let payload =
serde_json::to_vec(&envelope).map_err(|e| format!("Failed to serialize envelope: {e}"))?;
Ok((payload, "application/json"))
}
/// Reverse of `maybe_seal_for_upload`. Returns the inner plaintext JSON
/// bytes regardless of whether `raw` was an envelope or legacy plaintext.
///
/// Distinguishes three cases:
/// - `raw` is plaintext JSON, no password set → returns `raw` unchanged.
/// - `raw` is an envelope, password set → decrypts and returns plaintext.
/// - `raw` is an envelope, no password set → returns `Err(EncryptedEnvelope)`
/// so callers (subscription / startup probe) can show "enter password to
/// continue syncing" UI.
pub fn maybe_unseal_after_download(raw: &[u8]) -> Result<Vec<u8>, String> {
// Try parsing as envelope first; envelopes are JSON objects with a "v" field.
if let Ok(env) = serde_json::from_slice::<EncryptedEnvelope>(raw) {
if env.v != 1 {
return Err(format!("Unsupported envelope version: {}", env.v));
}
let pwd = load_e2e_password()?.ok_or_else(|| "ENCRYPTION_PASSWORD_REQUIRED".to_string())?;
let key = derive_profile_key(&pwd, &env.salt)?;
let ct = BASE64
.decode(&env.ct)
.map_err(|e| format!("Invalid envelope ciphertext: {e}"))?;
return decrypt_bytes(&key, &ct);
}
// Not an envelope — legacy plaintext. Caller will JSON-parse it directly.
Ok(raw.to_vec())
}
// Tauri commands
#[tauri::command]
pub fn set_e2e_password(password: String) -> Result<(), String> {
pub async fn set_e2e_password(password: String) -> Result<(), String> {
if password.len() < 8 {
return Err("Password must be at least 8 characters".to_string());
}
enforce_team_owner_for_encryption_change().await?;
store_e2e_password(&password)
}
@@ -236,10 +347,31 @@ pub fn check_has_e2e_password() -> bool {
}
#[tauri::command]
pub fn delete_e2e_password() -> Result<(), String> {
pub fn verify_e2e_password(password: String) -> Result<bool, String> {
match load_e2e_password()? {
Some(stored) => Ok(stored == password),
None => Err(serde_json::json!({ "code": "NO_E2E_PASSWORD_SET" }).to_string()),
}
}
#[tauri::command]
pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?;
remove_e2e_password()
}
/// On Team plans, only the team owner is allowed to flip the E2E password
/// state — otherwise members could lock each other out by changing the key.
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
use crate::cloud_auth::CLOUD_AUTH;
if let Some(state) = CLOUD_AUTH.get_user().await {
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
return Err("TEAM_OWNER_ONLY".to_string());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
File diff suppressed because it is too large Load Diff
+14
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,6 +62,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*",
"**/.DS_Store",
".donut-sync/**",
// 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",
];
/// A single file entry in the manifest
+27 -6
View File
@@ -7,13 +7,16 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_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, set_extension_group_sync_enabled,
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,
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
};
@@ -21,3 +24,21 @@ pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, Syn
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
/// reassignment shows up on other devices without waiting for the next
/// scheduled tick. Spawns the async queue call so this helper is callable
/// from both sync and async contexts.
pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
let profile_id = profile.id.to_string();
tauri::async_runtime::spawn(async move {
if let Some(scheduler) = get_global_scheduler() {
scheduler.queue_profile_sync(profile_id).await;
}
});
}
+10 -3
View File
@@ -716,16 +716,18 @@ impl SyncScheduler {
match entity_type.as_str() {
"profile" => {
let profile_manager = ProfileManager::instance();
let has_profile = {
let local_sync_enabled = {
if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
profile_uuid
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
.is_some_and(|p| p.is_sync_enabled())
} else {
false
}
};
if has_profile {
if local_sync_enabled {
log::info!(
"Profile {} was deleted remotely, deleting locally",
entity_id
@@ -733,6 +735,11 @@ impl SyncScheduler {
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
}
} else {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
entity_id
);
}
}
"proxy" => {
+16
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatRequest {
@@ -11,6 +12,11 @@ pub struct StatResponse {
#[serde(rename = "lastModified")]
pub last_modified: Option<String>,
pub size: Option<u64>,
/// User-defined S3 object metadata (`x-amz-meta-*`), lowercased keys without
/// the prefix. `None` from older servers that don't return it. Used to read
/// `updated-at` for sync conflict resolution without downloading the body.
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
pub content_type: Option<String>,
#[serde(rename = "expiresIn")]
pub expires_in: Option<u64>,
/// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`).
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
pub url: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
/// The metadata the server actually signed into the URL. The client must send
/// exactly these as `x-amz-meta-*` headers on the PUT or S3 rejects it. `None`
/// from older servers → client sends no metadata headers (body-GET fallback).
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -166,6 +180,7 @@ pub enum SyncError {
SerializationError(String),
ConflictError(String),
InvalidData(String),
Cancelled,
}
impl std::fmt::Display for SyncError {
@@ -178,6 +193,7 @@ impl std::fmt::Display for SyncError {
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
}
}
}
+4
View File
@@ -52,6 +52,10 @@ pub struct VpnConfig {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on config edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
/// Parsed WireGuard configuration
+12
View File
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
sync_enabled: bool,
#[serde(default)]
last_sync: Option<u64>,
#[serde(default)]
updated_at: Option<u64>,
}
/// VPN storage manager with encryption
@@ -247,6 +249,7 @@ impl VpnStorage {
last_used: config.last_used,
sync_enabled: config.sync_enabled,
last_sync: config.last_sync,
updated_at: config.updated_at,
};
// Update existing or add new
@@ -280,6 +283,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
}
@@ -300,6 +304,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
.collect(),
)
@@ -356,6 +361,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -367,6 +373,7 @@ impl VpnStorage {
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
let mut config = self.load_config(id)?;
config.name = new_name.to_string();
config.updated_at = Some(crate::proxy_manager::now_secs());
self.save_config(&config)?;
Ok(config)
}
@@ -420,6 +427,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -463,6 +471,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -487,6 +496,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let config2 = VpnConfig {
@@ -498,6 +508,7 @@ mod tests {
last_used: Some(3000),
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config1).unwrap();
@@ -524,6 +535,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
+47 -6
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 {
@@ -639,14 +645,25 @@ impl WayfernManager {
.has_active_paid_subscription()
.await
{
log::info!("Wayfern token not ready for paid user, waiting...");
for _ in 0..15 {
// Brief wait for the background token fetch — when the API is healthy
// the token usually lands in well under a second. If api.donutbrowser.com
// is unreachable we don't want to gate the whole launch on it; the
// browser still works without the token (cross-OS fingerprinting just
// won't be enabled for this session, and the next launch will pick it
// up once the token arrives).
log::info!("Wayfern token not ready for paid user, waiting briefly...");
for _ in 0..3 {
tokio::time::sleep(Duration::from_secs(1)).await;
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if wayfern_token.is_some() {
break;
}
}
if wayfern_token.is_none() {
log::warn!(
"Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)"
);
}
}
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
@@ -692,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",
@@ -770,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}"),
}
}
@@ -838,6 +876,7 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
used_fingerprint,
})
}
@@ -979,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!(
@@ -1021,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.22.2",
"version": "0.25.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -19,7 +19,7 @@
"active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"],
"externalBin": ["binaries/donut-proxy"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -42,11 +42,11 @@
"linux": {
"deb": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
"depends": ["xdg-utils", "libxdo3", "libayatana-appindicator3-1"]
},
"rpm": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
"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,
}
}

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