Compare commits

..
Author SHA1 Message Date
zhom a01901ef07 feat: add tips 2026-09-09 06:16:14 -07:00
dependabot[bot] 3a0e5a41c0 deps(deps): bump next from 16.3.2 to 16.3.3 (#603)
Bumps [next](https://github.com/vercel/next.js) from 16.3.2 to 16.3.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.2...v16.3.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.3
  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-09-09 06:12:18 +00:00
zhom dd42d46753 refactor: cleanup 2026-09-09 10:09:14 +04:00
zhom 598d3bd513 refactor: cleanup 2026-09-08 07:04:14 +04:00
dependabot[bot] c417c669c4 deps(rust)(deps): bump the rust-dependencies group (#600)
Bumps the rust-dependencies group in /src-tauri with 36 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri-plugin-opener](https://github.com/tauri-apps/plugins-workspace) | `2.5.4` | `2.5.5` |
| [tauri-plugin-fs](https://github.com/tauri-apps/plugins-workspace) | `2.5.1` | `2.5.2` |
| [tauri-plugin-shell](https://github.com/tauri-apps/plugins-workspace) | `2.3.5` | `2.3.6` |
| [tauri-plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.9` | `2.4.10` |
| [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace) | `2.4.3` | `2.4.4` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.7.2` | `2.7.3` |
| [tauri-plugin-log](https://github.com/tauri-apps/plugins-workspace) | `2.9.0` | `2.9.1` |
| [tauri-plugin-clipboard-manager](https://github.com/tauri-apps/plugins-workspace) | `2.3.2` | `2.3.3` |
| [flate2](https://github.com/rust-lang/flate2-rs) | `1.1.9` | `1.1.10` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.25.0` | `1.26.0` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.7.0` | `0.7.1` |
| [argon2](https://github.com/RustCrypto/password-hashes) | `0.5.3` | `0.6.0` |
| [aes](https://github.com/RustCrypto/block-ciphers) | `0.9.2` | `0.9.3` |
| [shadowsocks](https://github.com/shadowsocks/shadowsocks-rust) | `1.24.0` | `1.25.0` |
| [hyper](https://github.com/hyperium/hyper) | `1.11.0` | `1.11.1` |
| [secret-service](https://github.com/hwchen/secret-service-rs) | `5.1.0` | `5.2.0` |
| [block-padding](https://github.com/RustCrypto/utils) | `0.3.3` | `0.4.2` |
| [chacha20](https://github.com/RustCrypto/stream-ciphers) | `0.10.1` | `0.10.2` |
| [const-oid](https://github.com/RustCrypto/formats) | `0.9.6` | `0.10.2` |
| [cpufeatures](https://github.com/RustCrypto/utils) | `0.3.0` | `0.3.1` |
| [ctr](https://github.com/RustCrypto/block-modes) | `0.9.2` | `0.10.1` |
| [ghash](https://github.com/RustCrypto/universal-hashes) | `0.5.1` | `0.6.0` |
| [hermit-abi](https://github.com/hermit-os/hermit-rs) | `0.5.2` | `0.5.3` |
| [hkdf](https://github.com/RustCrypto/KDFs) | `0.12.4` | `0.13.0` |
| [imgref](https://github.com/kornelski/imgref) | `1.12.2` | `1.12.3` |
| [indexmap](https://github.com/indexmap-rs/indexmap) | `2.14.0` | `2.14.1` |
| libredox | `0.1.20` | `0.1.23` |
| [md-5](https://github.com/RustCrypto/hashes) | `0.10.6` | `0.11.0` |
| [open](https://github.com/Byron/open-rs) | `5.4.2` | `5.4.3` |
| [password-hash](https://github.com/RustCrypto/traits) | `0.5.0` | `0.6.1` |
| [polyval](https://github.com/RustCrypto/universal-hashes) | `0.6.2` | `0.7.3` |
| [sealed](https://github.com/jmg-duarte/sealed-rs) | `0.6.0` | `0.7.0` |
| [shadowsocks-crypto](https://github.com/shadowsocks/shadowsocks-crypto) | `0.6.2` | `0.8.0` |
| [smallvec](https://github.com/servo/rust-smallvec) | `1.15.2` | `1.16.0` |
| spin | `0.10.1` | `0.12.3` |
| [untrusted](https://github.com/briansmith/untrusted) | `0.9.0` | `0.7.1` |


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

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

Updates `tauri-plugin-shell` from 2.3.5 to 2.3.6
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/nfc-v2.3.5...nfc-v2.3.6)

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

Updates `tauri-plugin-single-instance` from 2.4.3 to 2.4.4
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.3...fs-v2.4.4)

Updates `tauri-plugin-dialog` from 2.7.2 to 2.7.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/dialog-v2.7.2...dialog-v2.7.3)

Updates `tauri-plugin-log` from 2.9.0 to 2.9.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.9.0...log-v2.9.1)

Updates `tauri-plugin-clipboard-manager` from 2.3.2 to 2.3.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/os-v2.3.2...nfc-v2.3.3)

Updates `flate2` from 1.1.9 to 1.1.10
- [Release notes](https://github.com/rust-lang/flate2-rs/releases)
- [Commits](https://github.com/rust-lang/flate2-rs/compare/1.1.9...1.1.10)

Updates `uuid` from 1.25.0 to 1.26.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/1.25.0...v1.26.0)

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

Updates `argon2` from 0.5.3 to 0.6.0
- [Commits](https://github.com/RustCrypto/password-hashes/compare/argon2-v0.5.3...argon2-v0.6.0)

Updates `aes` from 0.9.2 to 0.9.3
- [Commits](https://github.com/RustCrypto/block-ciphers/compare/aes-v0.9.2...aes-v0.9.3)

Updates `shadowsocks` from 1.24.0 to 1.25.0
- [Release notes](https://github.com/shadowsocks/shadowsocks-rust/releases)
- [Commits](https://github.com/shadowsocks/shadowsocks-rust/compare/v1.24.0...v1.25.0)

Updates `hyper` from 1.11.0 to 1.11.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.11.0...v1.11.1)

Updates `secret-service` from 5.1.0 to 5.2.0
- [Release notes](https://github.com/hwchen/secret-service-rs/releases)
- [Changelog](https://github.com/open-source-cooperative/secret-service-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hwchen/secret-service-rs/compare/v5.1.0...v5.2.0)

Updates `block-padding` from 0.3.3 to 0.4.2
- [Commits](https://github.com/RustCrypto/utils/compare/block-padding-v0.3.3...block-padding-v0.4.2)

Updates `chacha20` from 0.10.1 to 0.10.2
- [Commits](https://github.com/RustCrypto/stream-ciphers/compare/chacha20-v0.10.1...chacha20-v0.10.2)

Updates `const-oid` from 0.9.6 to 0.10.2
- [Commits](https://github.com/RustCrypto/formats/compare/const-oid/v0.9.6...const-oid/v0.10.2)

Updates `cpufeatures` from 0.3.0 to 0.3.1
- [Commits](https://github.com/RustCrypto/utils/compare/cpufeatures-v0.3.0...cpufeatures-v0.3.1)

Updates `ctr` from 0.9.2 to 0.10.1
- [Commits](https://github.com/RustCrypto/block-modes/compare/ctr-v0.9.2...ctr-v0.10.1)

Updates `ghash` from 0.5.1 to 0.6.0
- [Commits](https://github.com/RustCrypto/universal-hashes/compare/ghash-v0.5.1...ghash-v0.6.0)

Updates `hermit-abi` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/hermit-os/hermit-rs/releases)
- [Commits](https://github.com/hermit-os/hermit-rs/compare/hermit-abi-0.5.2...hermit-sys-0.5.3)

Updates `hkdf` from 0.12.4 to 0.13.0
- [Commits](https://github.com/RustCrypto/KDFs/compare/hkdf-v0.12.4...hkdf-v0.13.0)

Updates `imgref` from 1.12.2 to 1.12.3
- [Commits](https://github.com/kornelski/imgref/compare/v1.12.2...v1.12.3)

Updates `indexmap` from 2.14.0 to 2.14.1
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.14.0...2.14.1)

Updates `libredox` from 0.1.20 to 0.1.23

Updates `md-5` from 0.10.6 to 0.11.0
- [Commits](https://github.com/RustCrypto/hashes/compare/md-5-v0.10.6...md2-v0.11.0)

Updates `open` from 5.4.2 to 5.4.3
- [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.4.2...v5.4.3)

Updates `password-hash` from 0.5.0 to 0.6.1
- [Commits](https://github.com/RustCrypto/traits/compare/password-hash-v0.5.0...password-hash-v0.6.1)

Updates `polyval` from 0.6.2 to 0.7.3
- [Commits](https://github.com/RustCrypto/universal-hashes/compare/polyval-v0.6.2...polyval-v0.7.3)

Updates `sealed` from 0.6.0 to 0.7.0
- [Commits](https://github.com/jmg-duarte/sealed-rs/commits)

Updates `shadowsocks-crypto` from 0.6.2 to 0.8.0
- [Release notes](https://github.com/shadowsocks/shadowsocks-crypto/releases)
- [Commits](https://github.com/shadowsocks/shadowsocks-crypto/compare/v0.6.2...v0.8.0)

Updates `smallvec` from 1.15.2 to 1.16.0
- [Release notes](https://github.com/servo/rust-smallvec/releases)
- [Commits](https://github.com/servo/rust-smallvec/compare/v1.15.2...v1.16.0)

Updates `spin` from 0.10.1 to 0.12.3

Updates `untrusted` from 0.9.0 to 0.7.1
- [Commits](https://github.com/briansmith/untrusted/commits)

---
updated-dependencies:
- dependency-name: tauri-plugin-opener
  dependency-version: 2.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-fs
  dependency-version: 2.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-shell
  dependency-version: 2.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-deep-link
  dependency-version: 2.4.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-single-instance
  dependency-version: 2.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-log
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-clipboard-manager
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: flate2
  dependency-version: 1.1.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: uuid
  dependency-version: 1.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: argon2
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: aes
  dependency-version: 0.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: shadowsocks
  dependency-version: 1.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hyper
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: secret-service
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: block-padding
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: chacha20
  dependency-version: 0.10.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: const-oid
  dependency-version: 0.10.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cpufeatures
  dependency-version: 0.3.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: ctr
  dependency-version: 0.10.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: ghash
  dependency-version: 0.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hermit-abi
  dependency-version: 0.5.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hkdf
  dependency-version: 0.13.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: imgref
  dependency-version: 1.12.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: indexmap
  dependency-version: 2.14.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: md-5
  dependency-version: 0.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: open
  dependency-version: 5.4.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: password-hash
  dependency-version: 0.6.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: polyval
  dependency-version: 0.7.3
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: sealed
  dependency-version: 0.7.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: shadowsocks-crypto
  dependency-version: 0.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: smallvec
  dependency-version: 1.16.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: spin
  dependency-version: 0.12.3
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: untrusted
  dependency-version: 0.7.1
  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-09-05 09:50:20 +00:00
dependabot[bot] b60ffca115 ci(deps): bump the github-actions group with 2 updates (#599)
Bumps the github-actions group with 2 updates: [anomalyco/opencode/github](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `anomalyco/opencode/github` from 1.18.23 to 1.18.27
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/ef2880f379129aa048be9e9353e30aa168d42c17...4b7e19e315cca414121ba1d61523fef74bb3ae8b)

Updates `crate-ci/typos` from 1.49.0 to 1.50.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/8a48f81b6c64dcfea44b3633223084c4be58ac5f...d43b6c087ac471e2ea7b8af622ff15f05c0c365b)

---
updated-dependencies:
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.50.1
  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-09-05 09:07:25 +00:00
zhom 7682fc6b57 refactor: cleanup 2026-09-05 07:32:03 +04:00
dependabot[bot] 4f655e2173 ci(deps): bump anomalyco/opencode/github in the github-actions group (#593)
Bumps the github-actions group with 1 update: [anomalyco/opencode/github](https://github.com/anomalyco/opencode).


Updates `anomalyco/opencode/github` from 1.18.18 to 1.18.23
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d...ef2880f379129aa048be9e9353e30aa168d42c17)

---
updated-dependencies:
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.23
  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-08-29 09:08:43 +00:00
github-actions[bot]andgithub-actions[bot] 98b2acd338 chore: update flake.nix for v0.30.0 [skip ci] (#591)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-27 06:44:12 +00:00
github-actions[bot]andgithub-actions[bot] 2f4943fdc8 docs: update CHANGELOG.md and README.md for v0.30.0 [skip ci] (#590)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-27 06:43:54 +00:00
zhom 15b51f8d2d refactor: cleanup 2026-08-27 09:16:25 +04:00
zhom 63f673e7d4 chore: linting 2026-08-26 14:28:47 +04:00
zhom 7671b655cc refactor: better cookie import experience 2026-08-26 13:49:54 +04:00
zhom 82271d8c3b refactor: table style unification 2026-08-26 10:29:45 +04:00
zhom b7e1c791db style: copy 2026-08-26 02:13:00 +04:00
zhom 4b1d48e1ef refactor: confirmation button for profile-regeneration 2026-08-26 02:13:00 +04:00
zhom abe210eda3 test: better sync coverage 2026-08-26 02:13:00 +04:00
andy e873a72387 Merge pull request #565 from bevelbyte/fix/cdp-navigation-race
fix(cdp): don't report an answered navigation as failed when the socket drops
2026-08-25 15:12:23 -07:00
andy d346c134b0 Merge pull request #572 from zhom/dependabot/github_actions/github-actions-5bda34cb3d
ci(deps): bump the github-actions group with 5 updates
2026-08-25 13:42:16 -07:00
zhom 443f8b4597 feat: verify checksum for wayfern 2026-08-24 16:18:30 +04:00
github-actions[bot]andgithub-actions[bot] e0b6504e9e chore: update flake.nix for v0.29.6 [skip ci] (#576)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-24 00:34:06 +00:00
github-actions[bot]andgithub-actions[bot] 6d3f2c6cbf docs: update CHANGELOG.md and README.md for v0.29.6 [skip ci] (#575)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-24 00:33:42 +00:00
zhom 8b4f657e15 chore: linting 2026-08-24 02:53:42 +04:00
zhom 405e11eeb6 chore: version bump 2026-08-24 00:58:30 +04:00
zhom 90f1bf2569 chore: linting 2026-08-24 00:52:17 +04:00
zhom 33d3af0386 t push
Merge branch 'main' of github.com:zhom/donutbrowser
2026-08-24 00:13:23 +04:00
zhom de88fbbafe refactor: cleanup 2026-08-24 00:10:37 +04:00
dependabot[bot] 21a835a942 ci(deps): bump the github-actions group with 5 updates
Bumps the github-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.2.0` | `4.3.0` |
| [anomalyco/opencode/github](https://github.com/anomalyco/opencode) | `1.18.17` | `1.18.18` |
| [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) | `e97e2d8cc328f1b50210efc529dca0028893a2d9` | `6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `2.5.0` | `2.5.1` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) | `2.5.0` | `2.5.1` |


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `anomalyco/opencode/github` from 1.18.17 to 1.18.18
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/02546dfc2e4515a4f90aaf9ceb3890df2ac2b479...31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d)

Updates `dtolnay/rust-toolchain` from e97e2d8cc328f1b50210efc529dca0028893a2d9 to 6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/compare/e97e2d8cc328f1b50210efc529dca0028893a2d9...6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.5.0 to 2.5.1
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/8deb546fdb875b9996d27d4950be7312dac076a1...6e4298ebc4db23e847df9b2e2de2939d6f066c67)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.5.0 to 2.5.1
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/8deb546fdb875b9996d27d4950be7312dac076a1...6e4298ebc4db23e847df9b2e2de2939d6f066c67)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: dtolnay/rust-toolchain
  dependency-version: 6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.5.1
  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.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-22 09:08:59 +00:00
zhom 4e1ec4b882 docs: readme 2026-08-21 13:04:27 -07:00
zhom c602a1ce0c docs: switch to svg 2026-08-16 22:35:46 +04:00
bevelbyte 949d2c71de fix(cdp): keep a navigation result when the connection drops after the reply 2026-08-16 16:06:01 +05:30
307 changed files with 89710 additions and 14759 deletions
+16
View File
@@ -0,0 +1,16 @@
name: "Donut Browser CodeQL configuration"
queries:
- uses: security-extended
# Test and tooling code is not shipped. Its literals are test vectors and
# fixtures, and the E2E harness downloads its own driver and browser bundle,
# which the scanner reads as production secrets and untrusted writes.
paths-ignore:
- e2e
- src-tauri/tests
- "**/*_tests.rs"
- "**/*.test.mjs"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.ts"
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
- name: Initialize CodeQL
uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
with:
queries: security-extended
config-file: ./.github/codeql/codeql-config.yml
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
+44 -1
View File
@@ -31,14 +31,57 @@ env:
IMAGE_NAME: donutbrowser/donut-sync
jobs:
# donut-sync's own end-to-end suite covers which host it signs into presigned
# URLs. That is the whole of the self-hosted sync failure in issue 534: sign
# against an address only the server can reach and every client transfer dies
# at connect while /health and /readyz stay green. The suite existed and was
# never run by anything, so the guard was decorative. Run it here, before the
# image ships, because an image with broken presigning is the thing that
# reaches users.
#
# Ubuntu only, and separate from the Rust and Node matrices, because it needs
# Docker for MinIO and a POSIX env-var prefix in the package script.
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Publishes MinIO on 8987, which is the port test/test-env.ts pins.
- name: Start test storage
run: docker compose -f donut-sync/docker-compose.yml up -d --wait
- name: Run donut-sync end-to-end tests
working-directory: ./donut-sync
run: pnpm test:e2e
- name: Stop test storage
if: always()
run: docker compose -f donut-sync/docker-compose.yml down -v
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c #v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e #v4.3.0
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f #v4.6.0
+1 -1
View File
@@ -693,7 +693,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Run opencode
uses: anomalyco/opencode/github@02546dfc2e4515a4f90aaf9ceb3890df2ac2b479 #v1.18.17
uses: anomalyco/opencode/github@4b7e19e315cca414121ba1d61523fef74bb3ae8b #v1.18.27
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
cache: "pnpm"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
with:
toolchain: stable
components: rustfmt, clippy
+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@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
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@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
+1 -1
View File
@@ -27,7 +27,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@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
+72 -17
View File
@@ -1,18 +1,17 @@
name: Publish sidecars to R2
# Publishes the `donut-proxy` sidecar to the bucket behind
# https://download.wayfern.com, which is where the Wayfern VM fleet's bootstrap
# scripts fetch it from.
# https://download.wayfern.com, where remote hosts fetch it from.
#
# WHY THIS EXISTS SEPARATELY FROM release.yml
# The desktop app ships donut-proxy INSIDE the bundle as a Tauri sidecar, so a
# desktop release never needs it in a bucket. The fleet is the opposite: a leased
# macOS or Windows host has no bundle, and its agent refuses to launch a browser
# at all when the sidecar is missing (agent/launcher.go). Tying publication to a
# desktop release would mean the fleet could only be unblocked by cutting one.
# desktop release never needs it in a bucket. Remote execution is the opposite:
# a remote host has no bundle and cannot launch a browser without the sidecar.
# Tying publication to a desktop release would mean remote execution could only
# be unblocked by cutting one.
#
# The fleet needs exactly two targets. Other platforms get their sidecar from the
# app bundle and are deliberately not built here.
# Only three targets are needed here. Everything else gets its sidecar from the
# app bundle and is deliberately not built.
on:
workflow_dispatch:
@@ -62,19 +61,26 @@ jobs:
name: Build donut-proxy (${{ matrix.target }})
runs-on: ${{ matrix.platform }}
strategy:
# One target failing must not leave the other unpublished and the pair
# One target failing must not leave the others unpublished and the set
# skewed; publish what built and report the rest.
fail-fast: false
matrix:
include:
# The leased Mac mini (Apple silicon).
# macOS arm64 remote host.
- platform: macos-latest
target: aarch64-apple-darwin
artifact: donut-proxy-aarch64-apple-darwin
# The leased Elastic Metal Windows box.
# Windows x86_64 remote host.
- platform: windows-latest
target: x86_64-pc-windows-msvc
artifact: donut-proxy-x86_64-pc-windows-msvc.exe
# Linux x86_64 remote host. Pinned to 22.04, not -latest: the
# deployment target is glibc 2.35, and a binary linked on 24.04
# (glibc 2.39) refuses to load there. The stage step proves the pin
# held.
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
artifact: donut-proxy-x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -86,11 +92,20 @@ jobs:
fetch-depth: 0
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
with:
toolchain: stable
targets: ${{ matrix.target }}
# The proxy bin links donutbrowser_lib, which pulls in Tauri and therefore
# GTK and WebKit at link time even though the proxy never opens a window.
# Same package list as release.yml, so the two cannot drift apart.
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
- name: Build donut-proxy
shell: bash
working-directory: ./src-tauri
@@ -117,7 +132,7 @@ jobs:
# Prove the thing we are about to publish actually runs and is the
# binary we think it is. A sidecar that cannot start is indistinguishable
# from a missing one once it is on a leased host, except that it fails
# from a missing one once it is on a remote host, except that it fails
# later and less clearly.
version="$("$dest" --version)"
case "$version" in
@@ -125,6 +140,46 @@ jobs:
*) echo "::error::unexpected --version output: $version"; exit 1 ;;
esac
if [ "$RUNNER_OS" = "Linux" ]; then
# The Linux deployment target is glibc 2.35. A binary linked on a
# newer runner fails there with "version GLIBC_2.xx not found",
# which reaches the host only as a sidecar that "will not run".
# The runner is pinned to 22.04 for that reason; this proves the
# pin held, and that every library the binary names resolves at
# all.
fleet_glibc_max=2.35
if ! ldd_out="$(ldd "$dest")"; then
echo "::error::ldd cannot read $dest"
printf '%s\n' "$ldd_out"
exit 1
fi
if grep -q 'not found' <<< "$ldd_out"; then
echo "::error::$dest needs a shared library this runner cannot resolve, and the fleet host will not either"
printf '%s\n' "$ldd_out"
exit 1
fi
needed="$(objdump -p "$dest" | awk '$1 == "NEEDED" { print $2 }')"
glibc_max="$(objdump -T "$dest" | grep -o 'GLIBC_[0-9]*\.[0-9]*' | sed 's/^GLIBC_//' | sort -uV | tail -n 1)"
if [ -z "$glibc_max" ]; then
echo "::error::could not read the glibc symbol versions of $dest"
exit 1
fi
{
echo "### ${{ matrix.artifact }} shared libraries (DT_NEEDED)"
echo ""
echo '```'
printf '%s\n' "$needed"
echo '```'
echo ""
echo "- highest glibc symbol version: \`GLIBC_$glibc_max\` (fleet host ceiling: \`GLIBC_$fleet_glibc_max\`)"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$(printf '%s\n' "$glibc_max" "$fleet_glibc_max" | sort -V | tail -n 1)" != "$fleet_glibc_max" ]; then
echo "::error::$dest needs GLIBC_$glibc_max, but the fleet host (Ubuntu 22.04) ships glibc $fleet_glibc_max; build it on ubuntu-22.04"
exit 1
fi
fi
if command -v sha256sum >/dev/null; then
digest="$(sha256sum "$dest" | cut -d' ' -f1)"
else
@@ -205,9 +260,9 @@ jobs:
src="$RUNNER_TEMP/sidecars/$ARTIFACT"
# no-cache, not a long max-age: this key is deliberately overwritten in
# place, and a CDN copy of the previous build would make a host fail
# the SHA check the bootstrap performs, which reads as a corrupt
# download rather than a stale cache.
# place, and a cached copy of the previous build would make a host
# fail its integrity check, which reads as a corrupt download rather
# than a stale cache.
aws s3 cp "$src" "s3://${bucket}/${ARTIFACT}" \
--endpoint-url "$endpoint" \
--content-type application/octet-stream \
@@ -221,7 +276,7 @@ jobs:
# Read it back and compare. Without this, "published" is an assumption:
# a truncated upload or a write to the wrong bucket both look like
# success, and the failure would surface days later on a leased host
# success, and the failure would surface days later on a remote host
# as an unexplained checksum mismatch.
verify="$RUNNER_TEMP/verify-$ARTIFACT"
aws s3 cp "s3://${bucket}/${ARTIFACT}" "$verify" \
+2 -2
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@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
@@ -115,7 +115,7 @@ jobs:
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with:
scan-args: |-
-r
@@ -122,7 +122,7 @@ jobs:
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
with:
toolchain: stable
targets: ${{ matrix.target }}
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Spell Check Repo
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f #v1.49.0
uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b #v1.50.1
+1
View File
@@ -12,6 +12,7 @@
# testing
/coverage
/e2e/app/target/
/e2e/app/Cargo.lock
/e2e/.driver/
# next.js
+1 -1
View File
@@ -1,2 +1,2 @@
23
24
+1 -1
View File
@@ -1 +1 @@
23
24
+59 -5
View File
@@ -32,6 +32,7 @@ donutbrowser/
├── src/ # Next.js frontend
│ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ │ └── tips/ # Feature tips: SVG scene primitives and one looping scene per tip
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh)
│ ├── generated/ # Build-generated third-party license inventory
@@ -43,11 +44,14 @@ donutbrowser/
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
│ │ ├── browser.rs # Browser trait & launch logic
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing
│ │ ├── proxy_manager.rs # Proxy lifecycle, connection testing, per-proxy check history
│ │ ├── proxy_udp.rs # SOCKS5 UDP ASSOCIATE probe (yes/no/unknown UDP verdict)
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
│ │ ├── api_server.rs # REST API (utoipa + axum)
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── mcp_server.rs # MCP protocol server (tool engine + local loopback listener)
│ │ ├── mcp_remote.rs # Remote MCP bridge: outbound websocket to Donut cloud (Enterprise remote control)
│ │ ├── mcp_integrations.rs # 20-client MCP installer: local URL or remote endpoint with bearer, format-preserving JSONC/TOML edits
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
@@ -57,15 +61,18 @@ donutbrowser/
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence
│ │ ├── settings_manager.rs # App settings persistence (atomic writes), tips + paid-welcome state
│ │ ├── vault.rs # Per-install key that seals local secrets; opens legacy build-password seals once
│ │ ├── data_root.rs # Moving the data directory (copy, verify, then delete) + the pointer read at startup
│ │ ├── cookie_manager.rs # Cookie import/export
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check
│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist
│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase
│ │ ├── extension_manager.rs # Browser extension management
│ │ ├── extension_fetch.rs # Web Store link/id and direct .crx/.zip import, CRX3 unwrapping
│ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer
│ │ ├── synchronizer.rs # Real-time profile synchronizer (pause/resume, hold a follower out, window layouts)
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
│ │ └── cloud_auth.rs # Cloud authentication
│ ├── tests/ # Integration tests
@@ -75,7 +82,11 @@ donutbrowser/
├── e2e/ # Isolated native UI/sync/Wayfern E2E system
│ ├── app/ # Test-only Tauri harness that injects the private driver
│ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers
│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites
│ └── tests/ # Smoke, UI/motion, entity, network, integration, sync, browser suites
├── sdk/ # Standalone Python + Node clients for the local REST API
│ ├── api-paths.json # Snapshot of every published operation; drift check for both SDKs
│ ├── python/ # `donutbrowser` (stdlib only, pytest)
│ └── node/ # `@donutbrowser/sdk` (ESM TypeScript, node --test)
├── patches/ # pnpm compatibility patches for secured dependencies
├── flake.nix # Nix development environment
└── .github/workflows/ # CI/CD pipelines
@@ -101,6 +112,13 @@ The native suites use the published `tauri-wd` driver (pinned in `e2e/app/Cargo.
into the ignored `e2e/.driver` root) and launch an `e2e`-feature build.
Every session gets its own temporary Donut data/cache/log root, home directory,
WebView store, ports, and sync bucket. Never point a suite at production or development data.
Every suite runs the Donut window headless (`DONUT_E2E_HEADLESS=1`, forwarded as the tauri-wd
`headless` capability), so a run never pops a window or steals focus. `DONUT_E2E_HEADED=1` shows
the window when a failure needs watching.
`e2e/app/Cargo.lock` is generated, gitignored, and never edited by hand. `e2e/run.mjs` seeds it
from `src-tauri/Cargo.lock` whenever that file is newer, so the harness always links the exact
dependency versions Donut ships and a version bump or a Dependabot upgrade needs no second edit.
After a behavior change, run the smallest affected subset below in addition to the standard
format/lint/unit-test command. A code change is not verified until its affected native
@@ -254,6 +272,42 @@ When a tabbed sub-page dialog needs to be opened to a specific tab by an externa
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.
## Feature tips and the paid welcome
Tips are short feature walkthroughs: a looping SVG scene, a title, two or
three lines of copy, and a button into the feature. The catalog is
`src/lib/tips.ts` (ids, deep-link actions, the plan capability a tip needs);
scenes live in `src/components/tips/scenes-*.tsx` and are mapped in
`scene-for.tsx`; the dialog is `src/components/tips-dialog.tsx`; the flow
(what to open when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`,
`tips_seen`, `tips_last_auto_shown_at`, `paid_welcome_seen_for`,
`cloud_plan_memory`) is in `AppSettings`, behind the `get_tips_state`,
`mark_tip_seen`, `set_tips_auto_show` and `observe_cloud_plan` commands.
- One unseen tip opens by itself at most once a day, only after a settled
launch (onboarding done, terms accepted, nothing modal open), never in the
first-run session. The E2E harness seeds `tips_auto_show: false`; a test
that wants the automatic tip passes `settings: { tips_auto_show: true }`.
- Plan tips carry `requires`; they are listed only when the signed-in plan
grants the capability. The paid welcome opens once per account when the
backend sees it turn paid (free -> paid, or a paid account first seen right
after signing in); `paid_welcome_due` in `settings_manager.rs` is the rule.
- Adding a tip: append to `TIPS`, write the scene, add
`tips.items.<id>.{label,title,body,action}` to every locale, and run
`pnpm test:tips`, which checks every locale carries every tip.
- Scenes are decorative and loop on their own clock (`useScene`); they show
their resting frame under reduced motion and never hide the copy.
## Timelines (`OperationFlow`)
`src/components/ui/operation-flow.tsx` draws any measured operation as a row
of stations: settled stations wear a check, the current one is a ring (a
cross when `failed`), later ones wait as dots, wires fill as stations settle,
and `busy` sends a pulse along the wire into the station being worked on.
Pass `active` as the station the operation is AT, and `failed` when it
stopped there: a proxy check that cannot connect is `active={1}` (the proxy),
not the device. Reaching the last station with nothing failed settles the row.
## Keyboard shortcuts
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
+50
View File
@@ -1,6 +1,56 @@
# Changelog
## v0.30.0 (2026-08-27)
### Features
- verify checksum for wayfern
### Refactoring
- cleanup
- better cookie import experience
- table style unification
- confirmation button for profile-regeneration
### Documentation
- update CHANGELOG.md and README.md for v0.29.6 [skip ci] (#575)
### Maintenance
- chore: linting
- test: better sync coverage
- chore: update flake.nix for v0.29.6 [skip ci] (#576)
- ci(deps): bump the github-actions group with 5 updates
### Other
- style: copy
## v0.29.6 (2026-08-24)
### Refactoring
- cleanup
### Documentation
- readme
- switch to svg
### Maintenance
- chore: linting
- chore: version bump
- chore: linting
- test: integration cleanup
- chore: switch to gitdebt
- chore: update flake.nix for v0.29.5 [skip ci] (#563)
## v0.29.5 (2026-08-16)
### Features
+9 -9
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_x64.dmg) |
Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut-0.30.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut-0.30.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -111,8 +111,8 @@ Donut Browser is built by the people who use it, and plenty of the most useful h
<a href="https://gitdebt.com/zhom/donutbrowser?ref=readme">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/chart.gif?theme=dark" />
<img alt="Cumulative GitHub stars for zhom/donutbrowser over time" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/chart.gif?theme=light" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/chart.svg?theme=dark&animate=1" />
<img alt="Cumulative GitHub stars for zhom/donutbrowser over time" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/chart.svg?theme=light&animate=1" />
</picture>
</a>
@@ -120,8 +120,8 @@ Donut Browser is built by the people who use it, and plenty of the most useful h
<a href="https://gitdebt.com/zhom/donutbrowser?ref=readme">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=dark" />
<img alt="Everyone who has landed commits in zhom/donutbrowser, ranked by commit count" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=light" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=dark&animate=1" />
<img alt="Everyone who has landed commits in zhom/donutbrowser, ranked by commit count" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=light&animate=1" />
</picture>
</a>
+4
View File
@@ -10,6 +10,10 @@ extend-exclude = [
]
[default.extend-words]
# The IDN test fixtures in src-tauri/src/xray encode "cafe" as punycode
# ("xn--caf-dma") and as percent-escapes ("caf%C3%A9"). Both leave a bare "caf"
# token that is an encoding artefact, never a misspelling of "calf".
caf = "caf"
DBE = "DBE"
nd = "nd"
+11
View File
@@ -1,3 +1,14 @@
# Storage for developing and testing donut-sync itself. It runs MinIO only, and
# the sync server is expected to run on the host beside it (`pnpm start:dev`),
# which is why MinIO is published and why the port matches the one pinned in
# test/test-env.ts.
#
# This is NOT the self-hosting compose file. That one runs donut-sync in a
# container too, and it must set S3_PUBLIC_ENDPOINT, because a server that signs
# presigned URLs against a compose-internal host such as `http://minio:9000`
# hands every device a URL it cannot open, while /health and /readyz stay green.
# Take the self-hosting compose from https://donutbrowser.com/docs/self-hosting
# rather than from here.
services:
minio:
image: minio/minio:latest
+16 -11
View File
@@ -18,29 +18,29 @@
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1081.0",
"@aws-sdk/s3-request-presigner": "^3.1081.0",
"@nestjs/common": "^11.1.27",
"@aws-sdk/client-s3": "^3.1117.0",
"@aws-sdk/s3-request-presigner": "^3.1117.0",
"@nestjs/common": "^11.2.2",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27",
"@nestjs/platform-express": "^11.1.27",
"@nestjs/core": "^11.2.2",
"@nestjs/platform-express": "^11.2.2",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.23",
"@nestjs/cli": "^11.0.24",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.27",
"@nestjs/testing": "^11.2.2",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.1.0",
"@types/supertest": "^7.2.0",
"@types/node": "^26.3.0",
"@types/supertest": "^7.2.1",
"jest": "^30.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.11",
"ts-jest": "^29.4.12",
"ts-loader": "^9.6.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
@@ -55,7 +55,12 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": "<rootDir>/../test/tsconfig.json"
}
]
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
+36 -11
View File
@@ -86,6 +86,11 @@ export class SyncService implements OnModuleInit {
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
private presignClient: S3Client;
private publicEndpoint: string;
/**
* Whether an operator chose the public endpoint, or it fell back to the
* server's own storage address. The fallback is the shape that fails.
*/
private publicEndpointWasConfigured: boolean;
private bucket: string;
// Upper bound on presign batch array length (DoS guard).
private static readonly MAX_BATCH_ITEMS = 1000;
@@ -131,9 +136,11 @@ export class SyncService implements OnModuleInit {
// network and nowhere else. Signing is bound to the host, so the presign
// client is a second client pinned to the public address rather than a
// string rewrite of the signed URL.
const publicEndpoint =
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
const configuredPublicEndpoint =
this.configService.get<string>("S3_PUBLIC_ENDPOINT");
const publicEndpoint = configuredPublicEndpoint || endpoint;
this.publicEndpoint = publicEndpoint;
this.publicEndpointWasConfigured = Boolean(configuredPublicEndpoint);
this.presignClient =
publicEndpoint === endpoint
? this.s3Client
@@ -191,14 +198,33 @@ export class SyncService implements OnModuleInit {
const isSingleLabel =
!host.includes(".") && !host.includes(":") && host !== "localhost";
if (!isSingleLabel) return;
this.logger.warn(
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
"to an address your devices can reach (and publish that port).",
);
if (isSingleLabel) {
this.logger.warn(
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
"to an address your devices can reach (and publish that port).",
);
return;
}
// A dotted host proves nothing. With `S3_PUBLIC_ENDPOINT` unset, clients are
// handed whatever address this server uses for storage itself, and a
// reachable-looking name such as `storage.internal`, or a private address on
// a network the devices are not on, fails in exactly the same way while
// saying nothing at all. This server cannot test the endpoint for them,
// because it does not know where its clients are, so state what it does
// know and leave the judgement to the operator.
if (!this.publicEndpointWasConfigured) {
this.logger.log(
`S3_PUBLIC_ENDPOINT is not set, so presigned URLs will name '${this.publicEndpoint}', ` +
"the address this server uses for storage itself. Transfers go straight from each " +
"device to that address, and this server cannot verify a device can reach it. If " +
"transfers fail while /health and /readyz stay green, set S3_PUBLIC_ENDPOINT to an " +
"address your devices can reach and publish that port.",
);
}
}
private async ensureBucketExists(): Promise<void> {
@@ -744,8 +770,7 @@ export class SyncService implements OnModuleInit {
* 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.
* Real-time push is intentionally not provided here.
*/
subscribe(
ctx: UserContext,
+70 -1
View File
@@ -1,4 +1,4 @@
import { INestApplication } from "@nestjs/common";
import { INestApplication, Logger } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
@@ -199,3 +199,72 @@ describe("presigned URL host", () => {
});
});
});
// The server cannot test whether a device can reach the endpoint it signs, so
// the only honest thing it can do is say what it is handing out. Without this,
// the one configuration that breaks every transfer boots completely silently.
describe("boot message about the presign endpoint", () => {
let logs: string[];
let warnings: string[];
let logSpy: jest.SpyInstance;
let warnSpy: jest.SpyInstance;
beforeEach(() => {
logs = [];
warnings = [];
logSpy = jest
.spyOn(Logger.prototype, "log")
.mockImplementation((message: unknown) => {
logs.push(String(message));
});
warnSpy = jest
.spyOn(Logger.prototype, "warn")
.mockImplementation((message: unknown) => {
warnings.push(String(message));
});
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
});
it("says which host clients will be handed when S3_PUBLIC_ENDPOINT is unset", async () => {
const app = await bootstrap(undefined);
try {
const spoken = [...logs, ...warnings].join("\n");
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
expect(spoken).toContain(TEST_S3_ENDPOINT);
} finally {
await app.close();
}
});
// A single-label host is the documented compose default and cannot work for
// any client, so it earns a warning rather than a note.
it("warns loudly about a container-only host", async () => {
const app = await bootstrap("http://minio:9000");
try {
const spoken = warnings.join("\n");
expect(spoken).toContain("minio");
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
} finally {
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
}
});
// An operator who set the variable made a choice. Repeating the note at them
// would train them to ignore it, and the warning above is for the value that
// provably cannot work, not for every value the server cannot verify.
it("stays quiet when an operator has chosen a routable endpoint", async () => {
const app = await bootstrap(PUBLIC_ENDPOINT);
try {
const spoken = [...logs, ...warnings].join("\n");
expect(spoken).not.toContain("S3_PUBLIC_ENDPOINT is not set");
} finally {
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
}
});
});
+16 -8
View File
@@ -13,12 +13,14 @@ Install Donut dependencies with `pnpm install`. The runner installs the driver i
`cargo install`, so a working Rust toolchain is the only extra requirement. The browser suite also
needs
`WAYFERN_TEST_TOKEN`. The runner reads it from the environment or Donut's ignored `.env` without
printing it. When a local browser fixture is configured, the runner copies it into the test data
root (using an isolated APFS clone on macOS); otherwise the browser suite downloads the current
published build into that root.
printing it. The browser suites always run the newest published Wayfern build. The download is
saved as an ignored cache fixture under `.cache/e2e-wayfern-fixture`, which the runner copies into
the test data root (using an isolated APFS clone on macOS) on later runs; a cached fixture holding
any other version is replaced before the suite uses it, so the cache can never keep an old browser
under test.
Set `DONUT_E2E_WAYFERN_PATH` to use a local browser fixture. Without it, the runner uses an ignored
cache fixture when present and otherwise downloads the published test build.
Set `DONUT_E2E_WAYFERN_PATH` to pin an explicit local bundle instead, for example a browser built
from source. A pinned bundle is used as given, without the published-version check.
The real-network suite additionally requires Docker plus
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`. It creates its own
@@ -39,10 +41,15 @@ pnpm e2e:browser
Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, and the
harness in `e2e/app`, then installs the `tauri-wd` CLI into the ignored `e2e/.driver` root when the
version pinned by `e2e/app/Cargo.lock` is not already there. The harness enables Donut's `e2e`
version pinned by `e2e/app/Cargo.toml` is not already there. The harness enables Donut's `e2e`
feature and injects the WebDriver plugin so the production crate never depends on it. Both the
plugin and the CLI come from the same pinned crates.io release, so they cannot drift apart. Bump
the pin in `e2e/app/Cargo.toml` to move to a newer driver. Add `--no-build` to
the pin in `e2e/app/Cargo.toml` to move to a newer driver. Every suite runs the Donut window
headless (on macOS the window is transparent, click-through and never focused; elsewhere it is
hidden), so a run never pops a window or steals focus. Set `DONUT_E2E_HEADED=1` to watch the
window while debugging a failure; Wayfern browsers launched by a test are separate processes and
show their own windows unless the test asks for a headless launch.
Add `--no-build` to
`node e2e/run.mjs --suite=<name>` only when all four outputs are current.
`DONUT_E2E_KEEP_ARTIFACTS=1` retains successful local runs; failed runs are always retained and
their location is printed. Raw screenshots, captured HTML, logs, and isolated app state stay local.
@@ -74,7 +81,8 @@ runner redirects:
- each sync test to a new MinIO bucket and random token.
The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still
exercise published Wayfern downloads when no local fixture exists. Entitlement fallback from
exercise published Wayfern downloads whenever the cache fixture is missing or holds a different
version than the published build. Entitlement fallback from
`WAYFERN_TEST_TOKEN` exists only in the feature-gated test binary. Production builds never include
the WebDriver plugin or this fallback.
+799 -865
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,4 +6,4 @@ publish = false
[dependencies]
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
tauri-wd = "=0.1.11"
tauri-wd = "=0.2.0"
+62 -1
View File
@@ -28,6 +28,13 @@ export const commandCoverage = {
"window_decorations::get_window_decoration_layout",
"get_onboarding_completed",
"complete_onboarding",
"get_tips_state",
"mark_tip_seen",
"set_tips_auto_show",
"observe_cloud_plan",
"data_root::get_data_root_info",
"data_root::move_data_root",
"data_root::clear_data_root_choice",
],
},
profileEntities: {
@@ -60,7 +67,22 @@ export const commandCoverage = {
"update_profile_group",
"delete_profile_group",
"assign_profiles_to_group",
"get_group_bookmarks",
"set_group_bookmarks",
"apply_group_bookmarks_to_profile",
"delete_selected_profiles",
"plan_proxy_distribution",
"distribute_proxies_to_profiles",
],
},
trash: {
suite: "entities",
level: "integration",
commands: [
"list_trashed_profiles",
"restore_trashed_profile",
"purge_trashed_profile",
"empty_trash",
],
},
proxyEntities: {
@@ -74,6 +96,7 @@ export const commandCoverage = {
"check_proxy_validity",
"validate_vless_uri",
"get_cached_proxy_check",
"get_proxy_check_history",
"export_proxies",
"import_proxies_json",
"parse_txt_proxies",
@@ -88,6 +111,7 @@ export const commandCoverage = {
"get_extension_icon",
"add_extension",
"add_unpacked_extension",
"fetch_extension_from_url",
"update_extension",
"update_extension_from_path",
"delete_extension",
@@ -129,7 +153,8 @@ export const commandCoverage = {
"read_profile_cookies",
"get_profile_cookie_stats",
"copy_profile_cookies",
"import_cookies_from_file",
"analyze_pasted_cookies",
"import_pasted_cookies",
"export_profile_cookies",
"set_profile_password",
"change_profile_password",
@@ -181,6 +206,13 @@ export const commandCoverage = {
"fingerprint_consistency::match_profile_fingerprint_to_exit",
"launch_gate::get_profile_pre_launch_checks",
"launch_gate::ack_launch_gate",
"wayfern_persona::get_profile_persona",
"recorder::start_recipe_recording",
"recorder::stop_recipe_recording",
"recorder::get_recipe_recording",
"profile::portable::export_profile",
"profile::portable::preview_profile_archive",
"profile::portable::import_profile_archive",
"check_wayfern_terms_accepted",
"check_wayfern_downloaded",
"accept_wayfern_terms",
@@ -193,6 +225,7 @@ export const commandCoverage = {
"start_api_server",
"stop_api_server",
"get_api_server_status",
"check_integration_connection",
"start_mcp_server",
"stop_mcp_server",
"get_mcp_server_status",
@@ -200,10 +233,20 @@ export const commandCoverage = {
"list_mcp_agents",
"add_mcp_to_agent",
"remove_mcp_from_agent",
"start_mcp_remote_bridge",
"stop_mcp_remote_bridge",
"get_mcp_remote_status",
"get_remote_control_entitlement",
"get_mcp_remote_credential",
"rotate_mcp_remote_credential",
"forget_mcp_remote_credential",
"synchronizer::start_sync_session",
"synchronizer::stop_sync_session",
"synchronizer::remove_sync_follower",
"synchronizer::get_sync_sessions",
"synchronizer::set_sync_session_paused",
"synchronizer::set_sync_follower_held",
"synchronizer::arrange_sync_windows",
],
},
syncAndEncryption: {
@@ -212,6 +255,7 @@ export const commandCoverage = {
commands: [
"get_sync_settings",
"save_sync_settings",
"check_sync_server_connection",
"cloud_auth::restart_sync_service",
"set_profile_sync_mode",
"cancel_profile_sync",
@@ -287,6 +331,23 @@ export const commandCoverage = {
"cookie_bot::delete_cookie_bot_user_template",
],
},
agent: {
suite: "integrations",
level: "contract",
commands: [
"agent::start_agent_run",
"agent::get_agent_runs",
"agent::get_agent_run",
"agent::cancel_agent_run",
"agent::get_agent_recipes",
"agent::create_agent_recipe",
"agent::update_agent_recipe",
"agent::delete_agent_recipe",
"agent::start_agent_run_events",
"agent::stop_agent_run_events",
"agent::get_agent_run_events_status",
],
},
updateContracts: {
suite: "integrations",
level: "contract",
+71 -50
View File
@@ -71,6 +71,7 @@ export class AppSession {
seedDownloadedBrowser = false,
onboardingCompleted = true,
wayfernTermsAccepted = true,
settings = {},
}) {
this.name = name;
this.root = root;
@@ -84,6 +85,8 @@ export class AppSession {
this.seedDownloadedBrowser = seedDownloadedBrowser;
this.onboardingCompleted = onboardingCompleted;
this.wayfernTermsAccepted = wayfernTermsAccepted;
// Extra keys for the seeded app_settings.json, on top of the defaults.
this.settings = settings;
this.session = null;
}
@@ -91,6 +94,30 @@ export class AppSession {
return path.join(this.root, "donut");
}
/** Where this session's app looks for the Wayfern terms marker. */
get wayfernTermsFile() {
if (process.platform === "darwin") {
return path.join(
this.root,
"home",
"Library",
"Application Support",
"Wayfern",
"license-accepted",
);
}
if (process.platform === "win32") {
return path.join(
this.root,
"windows",
"roaming",
"Wayfern",
"license-accepted",
);
}
return path.join(this.root, "xdg", "config", "Wayfern", "license-accepted");
}
async start() {
await Promise.all([
mkdir(path.join(this.root, "home"), { recursive: true }),
@@ -114,6 +141,10 @@ export class AppSession {
commercial_trial_acknowledged: true,
window_resize_warning_dismissed: true,
disable_auto_updates: true,
// A tip opening by itself mid-test is a modal nobody asked for;
// the tips suite turns it back on for the one session that wants it.
tips_auto_show: false,
...this.settings,
},
null,
2,
@@ -126,31 +157,7 @@ export class AppSession {
});
}
if (this.wayfernTermsAccepted) {
const termsFile =
process.platform === "darwin"
? path.join(
this.root,
"home",
"Library",
"Application Support",
"Wayfern",
"license-accepted",
)
: process.platform === "win32"
? path.join(
this.root,
"windows",
"roaming",
"Wayfern",
"license-accepted",
)
: path.join(
this.root,
"xdg",
"config",
"Wayfern",
"license-accepted",
);
const termsFile = this.wayfernTermsFile;
await mkdir(path.dirname(termsFile), { recursive: true });
await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, {
flag: "wx",
@@ -244,6 +251,14 @@ export class AppSession {
DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`,
}
: {}),
// The city database has no organisation for an address; the ASN
// one does, and it is what a proxy check reports as the exit's
// ISP. Seeded separately so the suite can assert a real value.
...(process.env.DONUT_E2E_GEOIP_ASN_FIXTURE_READY === "1"
? {
DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip-asn.mmdb`,
}
: {}),
}
: {}),
...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}),
@@ -255,6 +270,11 @@ export class AppSession {
env,
cwd: this.cwd,
startupTimeout: 120_000,
// Set by run.mjs for every suite. The driver keeps the Donut window off
// the user's screen (on macOS transparent, click-through and never key,
// with the app as an accessory; hidden elsewhere), so a suite never
// pops a window or steals focus.
headless: process.env.DONUT_E2E_HEADLESS === "1",
});
await this.session.setTimeouts();
await this.waitFor(
@@ -369,13 +389,19 @@ export class AppSession {
});
}
async clickElement(element, description = "element") {
async clickElement(target, description = "element") {
let element;
await this.waitFor(
() =>
this.execute(
async () => {
// Event-backed tables may replace a cell while its data is loading.
// Resolve the current control on each attempt, as a browser locator does.
element = typeof target === "function" ? await target() : target;
if (!element) return false;
return this.execute(
`
const node = arguments[0];
if (!(node instanceof Element) || !node.isConnected) return false;
if (node.matches(":disabled") || node.getAttribute("aria-disabled") === "true") return false;
node.scrollIntoView({ block: "center", inline: "center" });
const rect = node.getBoundingClientRect();
const x = Math.floor(rect.left + rect.width / 2);
@@ -384,7 +410,8 @@ export class AppSession {
return Boolean(hit && (hit === node || node.contains(hit)));
`,
[element],
),
);
},
{ description: `pointer-interactable ${description}` },
);
await this.session.click(element);
@@ -394,8 +421,9 @@ export class AppSession {
text,
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
) {
const element = await this.execute(
`
const findElement = () =>
this.execute(
`
const wanted = arguments[0];
const exact = arguments[1];
const roles = new Set(arguments[2]);
@@ -412,13 +440,9 @@ export class AppSession {
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
}) ?? null;
`,
[text, exact, roles],
);
assert.ok(
element,
`No visible interactive element matched ${JSON.stringify(text)}`,
);
await this.clickElement(element, JSON.stringify(text));
[text, exact, roles],
);
await this.clickElement(findElement, JSON.stringify(text));
}
async clickTextIn(
@@ -426,8 +450,9 @@ export class AppSession {
text,
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
) {
const element = await this.execute(
`
const findElement = () =>
this.execute(
`
const containers = [...document.querySelectorAll(arguments[0])];
const wanted = arguments[1];
const exact = arguments[2];
@@ -450,20 +475,16 @@ export class AppSession {
}
return null;
`,
[containerSelector, text, exact, roles],
);
assert.ok(
element,
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
);
[containerSelector, text, exact, roles],
);
await this.clickElement(
element,
findElement,
`${JSON.stringify(text)} inside ${containerSelector}`,
);
}
async clickSelector(selector) {
const element = await this.waitFor(
await this.clickElement(
() =>
this.execute(
`
@@ -476,9 +497,8 @@ export class AppSession {
`,
[selector],
),
{ description: `visible selector ${selector}` },
selector,
);
await this.clickElement(element, selector);
}
async fillSelector(selector, value) {
@@ -578,6 +598,7 @@ export function appFromEnvironment(name, options = {}) {
seedDownloadedBrowser: options.seedDownloadedBrowser,
onboardingCompleted: options.onboardingCompleted,
wayfernTermsAccepted: options.wayfernTermsAccepted,
settings: options.settings,
});
}
+186 -25
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import {
chmod,
copyFile,
@@ -15,6 +15,10 @@ import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { crc32 } from "node:zlib";
import {
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
WAYFERN_DOWNLOAD_TIMEOUT_MS,
} from "./limits.mjs";
export const TEST_BROWSER_VERSION = "150.0.7871.100";
@@ -31,6 +35,43 @@ export function defaultWayfernPath(projectRoot) {
);
}
/**
* Where the cache fixture records which PUBLISHED version it was installed for.
*
* The bundle's own `CFBundleShortVersionString` cannot answer that question: a
* published version and the version stamped inside the bundle it serves do not
* always agree, and the app keys everything (download registry, profile
* `version`, release types) off the PUBLISHED string. Comparing the bundle's
* own version against the published one would therefore call an up-to-date
* fixture stale and re-download 1 GB on every single run.
*/
function fixtureStampPath(projectRoot) {
return path.join(
path.dirname(defaultWayfernPath(projectRoot)),
"published-version.txt",
);
}
/**
* The published version the cache fixture stands for, or `null` when there is
* no fixture.
*
* Falls back to the bundle's own version when no stamp is present, which is
* what a hand-installed fixture looks like: it is only right when the two
* agree, and when they do not the fixture is replaced, which is the safe way
* to be wrong.
*/
export function cachedFixtureVersion(projectRoot) {
const bundle = defaultWayfernPath(projectRoot);
if (!existsSync(bundle)) return null;
const stamp = fixtureStampPath(projectRoot);
if (existsSync(stamp)) {
const recorded = readFileSync(stamp, "utf8").trim();
if (recorded) return recorded;
}
return inspectWayfern(bundle).version;
}
export function wayfernExecutable(bundlePath) {
if (process.platform === "darwin") {
return path.join(bundlePath, "Contents", "MacOS", "Wayfern");
@@ -80,10 +121,57 @@ async function cloneAppBundle(source, destination) {
}
}
/** Where the app itself resolves the current Wayfern build (api_client.rs). */
const WAYFERN_RELEASE_URL = "https://donutbrowser.com/wayfern.json";
/**
* The newest published Wayfern version, read from the same manifest the app
* reads.
*
* Deliberately NOT asked of a running app session. Seeding a browser into a
* session's data root only works before that session starts: a running app
* runs `cleanup_unused_binaries`, which deletes any binary directory no
* profile references, and a just-seeded fixture is exactly that. Resolving the
* version over plain HTTP keeps the seed ahead of app startup.
*/
async function publishedWayfernVersion() {
const response = await fetch(WAYFERN_RELEASE_URL, {
signal: AbortSignal.timeout(30_000),
});
assert.ok(
response.ok,
`Could not read ${WAYFERN_RELEASE_URL}: HTTP ${response.status}`,
);
const manifest = await response.json();
assert.ok(
typeof manifest.version === "string" && manifest.version,
`No Wayfern version published at ${WAYFERN_RELEASE_URL}`,
);
return manifest.version;
}
async function downloadWayfern(app, version) {
await app.session.setTimeouts({ script: WAYFERN_DOWNLOAD_TIMEOUT_MS });
try {
await app.invoke(
"download_browser",
{ browserStr: "wayfern", version },
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
);
} finally {
await app.session.setTimeouts();
}
}
/**
* Put the build this session just downloaded into the cache fixture, in place
* of whatever build the cache held before. The swap goes through a staging
* copy and renames, so a suite that dies mid-copy leaves the old fixture or
* the new one on disk, never a half-written bundle.
*/
async function cacheDownloadedWayfern(app, projectRoot, version) {
if (process.env.DONUT_E2E_WAYFERN_PATH) return;
const destination = defaultWayfernPath(projectRoot);
if (existsSync(destination)) return;
const installDir = path.join(
app.dataRoot,
@@ -100,7 +188,9 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
process.platform === "win32" ? "wayfern.exe" : "wayfern",
);
const staging = `${destination}.tmp-${process.pid}`;
const retired = `${destination}.stale-${process.pid}`;
await rm(staging, { recursive: true, force: true });
await rm(retired, { recursive: true, force: true });
try {
if (process.platform === "darwin") {
await cloneAppBundle(source, staging);
@@ -109,10 +199,25 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
await copyFile(source, staging);
if (process.platform !== "win32") await chmod(staging, 0o755);
}
if (existsSync(destination)) await rename(destination, retired);
await rename(staging, destination);
// Stamped only after the bundle is in place, so an interrupted swap can
// never leave a stamp claiming a version the fixture does not hold.
await writeFile(fixtureStampPath(projectRoot), `${version}\n`);
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (!existsSync(destination) && existsSync(retired)) {
await rename(retired, destination);
}
if (!existsSync(destination)) throw error;
// The session itself runs the build it downloaded; only the cache is
// behind, and the next run resolves the published version again and
// replaces it then.
console.warn(
`[donut-e2e] Could not refresh the Wayfern fixture cache: ${error}`,
);
} finally {
await rm(retired, { recursive: true, force: true });
}
}
@@ -160,36 +265,46 @@ export async function seedWayfern(dataRoot, wayfern) {
return installDir;
}
/**
* Make the newest published Wayfern available to `app` and report the version
* it will run.
*
* `DONUT_E2E_WAYFERN_PATH` pins an explicit bundle and is used as given: that
* is how a locally built browser gets under test. Without it the suite runs
* the build the product would offer today, always. The ignored cache fixture
* only ever saves the download: it is used when it holds exactly that build
* and replaced when it holds any other, so a cache filled months ago can never
* quietly keep an old browser under test.
*/
export async function prepareWayfern(app, projectRoot) {
const localBundle = defaultWayfernPath(projectRoot);
if (existsSync(localBundle)) {
if (process.env.DONUT_E2E_WAYFERN_PATH) {
const wayfern = inspectWayfern(localBundle);
await seedWayfern(app.dataRoot, wayfern);
return { version: wayfern.version, source: "local fixture" };
return { version: wayfern.version, source: "pinned fixture" };
}
const version = await publishedWayfernVersion();
const cachedVersion = cachedFixtureVersion(projectRoot);
if (cachedVersion === version) {
// Seeded under the PUBLISHED version, not the bundle's own, because that
// is the string the app itself would have registered had it downloaded
// this build, and what every later `version` assertion compares against.
// Seeded BEFORE the app starts, or its unused-binary cleanup deletes it.
await seedWayfern(app.dataRoot, {
...inspectWayfern(localBundle),
version,
});
return { version, source: "cached fixture" };
}
if (cachedVersion) {
console.log(
`[donut-e2e] Cached Wayfern fixture ${cachedVersion} is not the published ${version}; replacing it`,
);
}
if (!app.session) await app.start();
const current = await app.invoke("fetch_browser_versions_with_count", {
browserStr: "wayfern",
});
assert.ok(
current.versions.length > 0,
"No Wayfern build is published for this platform",
);
const version = current.versions[0];
await app.session.setTimeouts({ script: 600_000 });
try {
await app.invoke(
"download_browser",
{
browserStr: "wayfern",
version,
},
620_000,
);
} finally {
await app.session.setTimeouts();
}
await downloadWayfern(app, version);
await cacheDownloadedWayfern(app, projectRoot, version);
return { version, source: "published download" };
}
@@ -526,3 +641,49 @@ export function writeChromiumHistory(dbPath, urls) {
}
db.close();
}
/** The name and version the CRX fixture's own manifest declares. */
export const CRX_EXTENSION_NAME = "Donut E2E Web Extension";
export const CRX_EXTENSION_VERSION = "3.2.1";
/**
* Wrap `zip` in a CRX3 container, the shape the Chrome Web Store actually
* serves: `Cr24`, a little-endian format version of 3, a little-endian header
* length, that many bytes of signature header, and only then the ZIP.
*
* The header bytes are filler — nothing in Donut verifies the signature, and a
* real one would need a packing key. What a test built on this proves is that
* the importer reads the ZIP at the offset the header declares instead of
* scanning the file for a `PK` marker, which is the bug the format invites.
*/
export function buildCrx3(zip, headerBytes = 137) {
const prefix = Buffer.alloc(12);
prefix.write("Cr24", 0, "ascii");
prefix.writeUInt32LE(3, 4);
prefix.writeUInt32LE(headerBytes, 8);
return Buffer.concat([prefix, Buffer.alloc(headerBytes, 0x42), zip]);
}
/** A CRX3 whose payload is a real Manifest V3 archive. */
export function extensionCrx3({
name = CRX_EXTENSION_NAME,
version = CRX_EXTENSION_VERSION,
} = {}) {
return buildCrx3(
buildStoredZip([
{
name: "manifest.json",
data: `${JSON.stringify(
{
manifest_version: 3,
name,
version,
description: "Isolated test extension served over a link",
},
null,
2,
)}\n`,
},
]),
);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* The longest command the harness ever waits on: `download_browser` pulling a
* published Wayfern build of about 1 GB, which a slow link needs the better
* part of half an hour for.
*
* Every clock around that command is derived from this one number so they can
* never disagree again. The session script timeout is this value; the client
* gives up a little later; the driver's outer per-command bound
* (`--command-timeout`) later still. Ordered that way, a download that is
* genuinely too slow surfaces as the driver's own script-timeout error rather
* than as a torn connection somewhere in between.
*/
export const WAYFERN_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
/** How long the client waits on a download command before it gives up. */
export const WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS =
WAYFERN_DOWNLOAD_TIMEOUT_MS + 20_000;
/** The driver's outer per-command bound, in the whole seconds its flag takes. */
export const DRIVER_COMMAND_TIMEOUT_SECONDS =
Math.ceil(WAYFERN_DOWNLOAD_TIMEOUT_MS / 1000) + 60;
+67 -14
View File
@@ -1,9 +1,59 @@
import assert from "node:assert/strict";
import http from "node:http";
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
function abortAfter(timeoutMs) {
return AbortSignal.timeout(timeoutMs);
/**
* One HTTP exchange with the driver, over `node:http` rather than `fetch`.
*
* `fetch` is undici, and undici gives every request a 300 s headers timeout
* of its own. A long `execute/async` sends no headers until the script
* completes, so a `download_browser` that pulls a 1 GB Wayfern build over a
* slow link died at 300 s whatever `timeoutMs` asked for. `node:http` has no
* such default, which leaves `timeoutMs` as the only clock.
*/
function exchange(method, url, body, timeoutMs) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const request = http.request(
url,
{
method,
headers:
payload === undefined
? {}
: {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
},
signal: AbortSignal.timeout(timeoutMs),
},
(response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("error", reject);
response.on("end", () =>
resolve({
status: response.statusCode ?? 0,
text: Buffer.concat(chunks).toString("utf8"),
}),
);
},
);
request.on("error", (error) => {
const timedOut =
error?.name === "AbortError" || error?.name === "TimeoutError";
reject(
timedOut
? new Error(
`WebDriver ${method} ${url} gave no response within ${timeoutMs}ms`,
{ cause: error },
)
: error,
);
});
request.end(payload);
});
}
export class WebDriverClient {
@@ -12,30 +62,27 @@ export class WebDriverClient {
}
async request(method, pathname, body, timeoutMs = 330_000) {
const response = await fetch(`${this.baseUrl}${pathname}`, {
const { status, text } = await exchange(
method,
headers:
body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
signal: abortAfter(timeoutMs),
});
const text = await response.text();
`${this.baseUrl}${pathname}`,
body,
timeoutMs,
);
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
throw new Error(
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`,
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${status}: ${text.slice(0, 500)}`,
);
}
}
const error = payload?.value?.error;
if (!response.ok) {
const message =
payload?.value?.message ?? text ?? `HTTP ${response.status}`;
if (status < 200 || status >= 300) {
const message = payload?.value?.message ?? text ?? `HTTP ${status}`;
throw new Error(
`WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`,
`WebDriver ${method} ${pathname} failed (${error ?? status}): ${message}`,
);
}
return payload?.value;
@@ -51,11 +98,17 @@ export class WebDriverClient {
env = {},
cwd,
startupTimeout = 90_000,
headless = false,
}) {
const options = { application, args, env, startupTimeout };
if (cwd) {
options.cwd = cwd;
}
// Only sent when asked, so a driver build without the capability is not
// handed an option it would reject.
if (headless) {
options.headless = true;
}
const value = await this.request(
"POST",
"/session",
+117 -26
View File
@@ -2,6 +2,7 @@
import { spawn, spawnSync } from "node:child_process";
import {
copyFileSync,
createReadStream,
createWriteStream,
existsSync,
@@ -26,6 +27,8 @@ import path from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
import { extensionCrx3 } from "./lib/fixtures.mjs";
import { DRIVER_COMMAND_TIMEOUT_SECONDS } from "./lib/limits.mjs";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(dirname, "..");
@@ -44,10 +47,13 @@ const driverBinary = path.join(
"bin",
`tauri-wd${executableSuffix}`,
);
const appManifest = path.join(appManifestDir, "Cargo.toml");
const appLockfile = path.join(appManifestDir, "Cargo.lock");
const donutLockfile = path.join(projectRoot, "src-tauri", "Cargo.lock");
const suiteFiles = {
smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"],
ui: ["ui.test.mjs"],
ui: ["ui.test.mjs", "motion.test.mjs"],
entities: ["entities.test.mjs"],
network: ["network.test.mjs"],
integrations: ["integrations.test.mjs"],
@@ -58,6 +64,7 @@ const suiteFiles = {
"coverage.test.mjs",
"smoke.test.mjs",
"ui.test.mjs",
"motion.test.mjs",
"entities.test.mjs",
"network.test.mjs",
"integrations.test.mjs",
@@ -237,16 +244,13 @@ async function loadLocalValues(names) {
return values;
}
function lockedDriverVersion() {
const lockfile = readFileSync(
path.join(appManifestDir, "Cargo.lock"),
"utf8",
);
const match = lockfile.match(
/\[\[package\]\]\s*\nname = "tauri-wd"\s*\nversion = "([^"]+)"/,
);
function pinnedDriverVersion() {
const manifest = readFileSync(appManifest, "utf8");
const match = manifest.match(/^tauri-wd\s*=\s*"=([^"]+)"$/m);
if (!match) {
throw new Error("e2e/app/Cargo.lock does not resolve a tauri-wd version");
throw new Error(
'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.2.0"',
);
}
return match[1];
}
@@ -263,7 +267,7 @@ function installedDriverVersion() {
}
function ensureDriver() {
const version = lockedDriverVersion();
const version = pinnedDriverVersion();
if (installedDriverVersion() === version) {
log(`tauri-wd ${version} already installed at ${driverBinary}`);
return;
@@ -285,19 +289,31 @@ function ensureDriver() {
);
}
// The harness links the Donut crate, so it has to resolve the same versions
// Donut itself ships. Seeding the harness lockfile from src-tauri/Cargo.lock
// keeps the two in step whenever a dependency or the app version moves; cargo
// fills in the harness-only packages on top. It is generated, never hand-edited.
function syncHarnessLockfile() {
if (
existsSync(appLockfile) &&
statSync(appLockfile).mtimeMs >= statSync(donutLockfile).mtimeMs
) {
return;
}
copyFileSync(donutLockfile, appLockfile);
log("seeded e2e/app/Cargo.lock from src-tauri/Cargo.lock");
}
function buildAll() {
run("pnpm", ["build"], projectRoot);
run("pnpm", ["copy-proxy-binary"], projectRoot);
run(process.execPath, ["src-tauri/download-xray.mjs"], projectRoot);
run(
"cargo",
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
projectRoot,
);
syncHarnessLockfile();
run("cargo", ["build", "--manifest-path", "e2e/app/Cargo.toml"], projectRoot);
ensureDriver();
}
function startFixtureServer(geoIpFixture) {
function startFixtureServer(geoIpFixture, geoIpAsnFixture) {
const server = http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
if (url.pathname === "/health") {
@@ -331,6 +347,32 @@ function startFixtureServer(geoIpFixture) {
response.end("ads.e2e.invalid\ntracker.e2e.invalid\n");
return;
}
// A CRX3 container, the shape the Chrome Web Store serves. The extension
// importer has to find the ZIP at the offset the header declares rather
// than scanning the file, so the fixture is a real container and not a
// renamed archive.
if (url.pathname === "/extension.crx") {
const crx = extensionCrx3();
response.writeHead(200, {
"content-type": "application/x-chrome-extension",
"content-length": String(crx.length),
"cache-control": "no-store",
});
response.end(crx);
return;
}
// Named like an archive, but not one. A link import must refuse this
// rather than storing a broken extension.
if (url.pathname === "/not-an-extension.zip") {
const body = Buffer.from("<!doctype html><html>not an archive</html>");
response.writeHead(200, {
"content-type": "application/zip",
"content-length": String(body.length),
"cache-control": "no-store",
});
response.end(body);
return;
}
if (url.pathname === "/geoip.mmdb" && geoIpFixture) {
response.writeHead(200, {
"content-type": "application/octet-stream",
@@ -339,6 +381,14 @@ function startFixtureServer(geoIpFixture) {
createReadStream(geoIpFixture).pipe(response);
return;
}
if (url.pathname === "/geoip-asn.mmdb" && geoIpAsnFixture) {
response.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": String(statSync(geoIpAsnFixture).size),
});
createReadStream(geoIpAsnFixture).pipe(response);
return;
}
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
@@ -363,19 +413,42 @@ function startFixtureServer(geoIpFixture) {
}
async function ensureGeoIpFixture() {
if (process.env.DONUT_E2E_GEOIP_FIXTURE) {
const fixture = path.resolve(process.env.DONUT_E2E_GEOIP_FIXTURE);
return ensureMmdbFixture("GeoLite2-City.mmdb", "-City.mmdb", {
override: process.env.DONUT_E2E_GEOIP_FIXTURE,
overrideName: "DONUT_E2E_GEOIP_FIXTURE",
});
}
/**
* The autonomous-system database. Separate from the city one because that is
* how MaxMind publishes them, and because the organisation a proxy check
* reports as the exit's ISP lives only in this file.
*/
async function ensureGeoIpAsnFixture() {
return ensureMmdbFixture("GeoLite2-ASN.mmdb", "-ASN.mmdb", {
override: process.env.DONUT_E2E_GEOIP_ASN_FIXTURE,
overrideName: "DONUT_E2E_GEOIP_ASN_FIXTURE",
});
}
async function ensureMmdbFixture(
fileName,
assetSuffix,
{ override, overrideName },
) {
if (override) {
const fixture = path.resolve(override);
if (!existsSync(fixture)) {
throw new Error(`DONUT_E2E_GEOIP_FIXTURE does not exist: ${fixture}`);
throw new Error(`${overrideName} does not exist: ${fixture}`);
}
return fixture;
}
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
const fixture = path.join(toolsDir, "GeoLite2-City.mmdb");
const fixture = path.join(toolsDir, fileName);
await mkdir(toolsDir, { recursive: true });
if (existsSync(fixture)) return fixture;
log("Downloading GeoLite City E2E dependency");
log(`Downloading ${fileName} E2E dependency`);
const releases = await fetch(
"https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases",
{
@@ -392,8 +465,8 @@ async function ensureGeoIpFixture() {
});
const url = releases
.flatMap((release) => release.assets ?? [])
.find((asset) => asset.name.endsWith("-City.mmdb"))?.browser_download_url;
if (!url) throw new Error("No GeoLite City MMDB asset was found");
.find((asset) => asset.name.endsWith(assetSuffix))?.browser_download_url;
if (!url) throw new Error(`No GeoLite ${assetSuffix} asset was found`);
const temporary = `${fixture}.${process.pid}.tmp`;
await download(url, temporary);
await rename(temporary, fixture);
@@ -900,8 +973,10 @@ async function main() {
"4",
"--startup-timeout",
"120",
// Sized to the longest command the suites issue (a Wayfern download),
// so the driver never cuts a command shorter than the session asked.
"--command-timeout",
"630",
String(DRIVER_COMMAND_TIMEOUT_SECONDS),
"--log",
options.verbose ? "debug" : "info",
],
@@ -923,7 +998,8 @@ async function main() {
(options.suite === "network" || options.suite === "full") &&
process.env.DONUT_E2E_SKIP_NETWORK_TEST !== "1";
const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null;
fixture = await startFixtureServer(geoIpFixture);
const geoIpAsnFixture = needsBrowser ? await ensureGeoIpAsnFixture() : null;
fixture = await startFixtureServer(geoIpFixture, geoIpAsnFixture);
let sync = {};
if (options.suite === "sync" || options.suite === "full") {
sync = await startSyncInfrastructure(runRoot, options, records);
@@ -960,6 +1036,11 @@ async function main() {
"--test",
"--test-concurrency=1",
"--test-reporter=spec",
// One test out of a suite, for iterating on a failure without paying
// for the rest of the file. Never set in CI.
...(process.env.DONUT_E2E_TEST_NAME_PATTERN
? [`--test-name-pattern=${process.env.DONUT_E2E_TEST_NAME_PATTERN}`]
: []),
...files,
];
const child = spawn(process.execPath, testArgs, {
@@ -972,6 +1053,16 @@ async function main() {
DONUT_E2E_DRIVER_URL: `http://127.0.0.1:${driverPort}`,
DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`,
DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0",
DONUT_E2E_GEOIP_ASN_FIXTURE_READY: geoIpAsnFixture ? "1" : "0",
// Every suite runs the Donut window headless so a local run never
// pops a window or steals focus: the app builds it hidden and the
// tauri-wd plugin keeps it off screen (on macOS transparent,
// click-through and never key, with the app as an accessory; hidden
// elsewhere). AppSession forwards this as the headless capability;
// only the driver's TAURI_WEBDRIVER_HEADLESS reaches the app.
// DONUT_E2E_HEADED=1 shows the window again when a failure needs
// watching. Wayfern itself is a separate process and unaffected.
DONUT_E2E_HEADLESS: process.env.DONUT_E2E_HEADED === "1" ? "0" : "1",
WAYFERN_TEST_TOKEN: token,
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
localValues.RESIDENTIAL_PROXY_URL_ONE_SOCKS ?? "",
+665 -33
View File
@@ -5,11 +5,12 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import en from "../../src/i18n/locales/en.json" with { type: "json" };
import { appFromEnvironment } from "../lib/app.mjs";
import { CdpClient } from "../lib/cdp.mjs";
import {
defaultWayfernPath,
inspectWayfern,
cachedFixtureVersion,
currentHostOs,
prepareWayfern,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
@@ -114,6 +115,22 @@ async function snapshotFile(file) {
}
}
/**
* The exit-derived fields `WayfernConfig.location` may hold. Mirrors
* `LOCALE_CARRY_OVER_KEYS` in wayfern_manager.rs: anything outside this set is
* a device field, and a device field never belongs to the location.
*/
const LOCATION_KEYS = new Set([
"timezone",
"timezoneOffset",
"language",
"languages",
"latitude",
"longitude",
"accuracy",
]);
/** `fingerprint` is the serialised fingerprint STRING, or null for a fresh one. */
async function createRealProfile(app, version, name, fingerprint = null) {
return app.invoke("create_browser_profile_new", {
name,
@@ -138,12 +155,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const realTermsFile = realWayfernTermsPath();
const realTermsBefore = await snapshotFile(realTermsFile);
const localWayfernPath = defaultWayfernPath(
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-wayfern", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
@@ -159,8 +173,23 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.equal(await app.invoke("check_wayfern_downloaded"), true);
assert.equal(await app.invoke("check_wayfern_terms_accepted"), false);
// The gate is a real modal until the terms are accepted, and acceptance
// through the bridge (not the dialog's own button) must lift it too: the
// frontend learns about the marker from the backend's event, not from a
// restart.
const termsDialogVisible = () =>
app.execute(
`return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`,
[en.wayfernTerms.title],
);
await app.waitFor(termsDialogVisible, {
description: "the Wayfern terms dialog before acceptance",
});
await app.invoke("accept_wayfern_terms");
assert.equal(await app.invoke("check_wayfern_terms_accepted"), true);
await app.waitFor(async () => !(await termsDialogVisible()), {
description: "the Wayfern terms dialog to close after acceptance",
});
assert.ok(
(
await app.invoke("get_downloaded_browser_versions", {
@@ -199,6 +228,18 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
})
).versions.includes(prepared.version),
);
// The app's own resolver must agree with the release manifest the harness
// read when it decided the cached fixture was current. If these two ever
// diverge, the fixture check compares against a version the app will never
// ask for, and the suite silently runs an old browser again.
assert.ok(
(
await app.invoke("fetch_browser_versions_with_count", {
browserStr: "wayfern",
})
).versions.includes(prepared.version),
"the app must resolve the same published version the fixture was chosen for",
);
assert.equal(
(await app.invoke("get_browser_release_types", { browserStr: "wayfern" }))
.stable,
@@ -217,26 +258,102 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
version: prepared.version,
configJson: JSON.stringify({ geoip: false }),
});
const fingerprint = JSON.parse(sample);
const fingerprint = JSON.parse(sample.fingerprint);
assert.ok(
Object.keys(fingerprint).length >= 10,
"Wayfern returned an incomplete fingerprint",
);
// A browser with the identity API must hand back the UUID the device was
// derived from. The device itself is a view to show once and discard: an
// identity-backed profile stores the id and the exit's location, never the
// payload, so no fingerprint sits on disk to be copied.
const identityCapable =
Number.parseInt(prepared.version.split(".")[0], 10) >= 151;
assert.equal(
typeof sample.identity_id === "string",
identityCapable,
"identity_id must be present exactly on browsers with the identity API",
);
assert.equal(
sample.identity_baseline,
undefined,
"the retired identity baseline must not be handed back",
);
assert.ok(
sample.location === null || typeof sample.location === "string",
"location is the exit-derived JSON object, or null when none resolved",
);
if (typeof sample.location === "string") {
const locationKeys = Object.keys(JSON.parse(sample.location));
assert.ok(locationKeys.length > 0, "a resolved location is never empty");
for (const key of locationKeys) {
assert.ok(
LOCATION_KEYS.has(key),
`${key} is a device field and must not travel in the location`,
);
}
}
const profile = await createRealProfile(
app,
prepared.version,
`Real Wayfern (${prepared.source})`,
);
assert.ok(profile.wayfern_config.fingerprint);
assert.ok(
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10,
// An identity-backed profile stores the identity and the location and never
// the device: the browser rebuilds it from the id on every launch. A legacy
// browser stores the whole payload.
assert.equal(
typeof profile.wayfern_config.identity_id === "string",
identityCapable,
"a created profile must carry the identity its device came from",
);
assert.equal(
profile.wayfern_config.fingerprint === undefined,
identityCapable,
"an identity-backed profile must store no device payload",
);
if (!identityCapable) {
assert.ok(
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >=
10,
);
}
assert.equal(await app.invoke("check_missing_geoip_database"), true);
assert.equal(await app.invoke("is_geoip_database_available"), false);
await app.invoke("download_geoip_database");
assert.equal(await app.invoke("is_geoip_database_available"), true);
assert.equal(await app.invoke("check_missing_geoip_database"), false);
// The new-profile form (which needs a downloaded browser and its release
// types, so it renders here and not in the UI suite): session restore is
// on by default and the checkbox is a live control.
await app.clickSelector('[aria-label="Profiles"]');
await app.clickText("New");
const restoreChecked = () =>
app.execute(
`return document.querySelector("#restore-session")?.getAttribute("aria-checked") ?? null;`,
);
await app.waitFor(async () => (await restoreChecked()) !== null, {
description: "the session-restore checkbox in the new-profile form",
});
assert.equal(
await restoreChecked(),
"true",
"a new profile must default to continuing its last session",
);
await app.clickSelector("#restore-session");
await app.waitFor(async () => (await restoreChecked()) === "false", {
description: "the session-restore checkbox to switch off",
});
await app.pressShortcut({ key: "Escape" });
await app.waitFor(
() =>
app.execute(
`return !document.querySelector("[role='dialog'] #restore-session");`,
),
{ description: "the new-profile dialog to close" },
);
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: profile.wayfern_config,
@@ -245,6 +362,109 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
profileId: profile.id,
exitIp: "8.8.8.8",
});
// The identity is internal state that neither call above sends back.
// Losing it would silently re-mint the device on the next launch and throw
// the user's edits away with it, so both paths must carry it forward
// unchanged. The exit re-match moves only the location: the profile comes
// out of it still identity-only, with the exit's timezone stored.
if (identityCapable) {
const stored = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
assert.equal(
stored.wayfern_config.identity_id,
profile.wayfern_config.identity_id,
"the identity must survive update_wayfern_config and an exit re-match",
);
assert.equal(
stored.wayfern_config.fingerprint,
undefined,
"neither call may leave a device payload behind",
);
assert.equal(
typeof JSON.parse(stored.wayfern_config.location).timezone,
"string",
"an exit re-match stores the exit's timezone in the location",
);
}
// The session-restore switch is profile configuration and round-trips
// like the rest of it; `undefined` (the default) reads as on.
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: {
...(await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
).wayfern_config,
restore_session: false,
},
});
assert.equal(
(await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
).wayfern_config.restore_session,
false,
"restore_session must persist through update_wayfern_config",
);
// The persona the browser will offer in its fill menu: derived from the
// profile's own seed, so it is stable for this profile, unique to it, and
// never empty.
const persona = await app.invoke("get_profile_persona", {
profileId: profile.id,
});
assert.ok(
persona.length >= 8,
"a persona carries the fields to fill a form",
);
assert.deepEqual(
await app.invoke("get_profile_persona", { profileId: profile.id }),
persona,
"the same profile presents the same person every time",
);
for (const entry of persona) {
assert.ok(entry.id && entry.label && entry.value.trim());
}
const email = persona.find((entry) => entry.id === "email");
assert.match(email.value, /@/);
assert.match(
await app.invokeError("get_profile_persona", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
/PROFILE_NOT_FOUND/,
);
// An edit replaces one value and leaves the rest derived.
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: {
...(await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
).wayfern_config,
persona: JSON.stringify([
{ id: "email", label: "Email", value: "someone@example.com" },
]),
},
});
const edited = await app.invoke("get_profile_persona", {
profileId: profile.id,
});
assert.equal(
edited.find((entry) => entry.id === "email").value,
"someone@example.com",
);
assert.equal(
edited.find((entry) => entry.id === "full_name").value,
persona.find((entry) => entry.id === "full_name").value,
"an edit to one field must not redraw the others",
);
// What "reset to generated" shows: the person before any edit.
assert.deepEqual(
await app.invoke("get_profile_persona", {
profileId: profile.id,
derivedOnly: true,
}),
persona,
);
// Pre-launch gate: local-only checks that must answer without starting a
// proxy, an Xray worker or the browser.
const checks = await app.invoke("get_profile_pre_launch_checks", {
@@ -259,6 +479,24 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.equal(typeof checks.consistency, "object");
assert.equal(typeof checks.exit_probe_pending, "boolean");
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
// The third consistency state: what no probe can ever verify for this
// profile. Reported so a launch that compared nothing is never rendered as
// a launch that compared everything and agreed.
assert.ok(
Array.isArray(checks.exit_unverified),
"the pre-launch report must say what it cannot verify",
);
assert.ok(
Array.isArray(checks.consistency.unverified),
"a consistency result must carry the dimensions nothing compared",
);
// "Donut will check it while starting" is only sayable while some
// dimension is still checkable. Both dimensions unverifiable means the
// probe would compare nothing, so it is not pending work.
assert.ok(
!checks.exit_probe_pending || checks.exit_unverified.length < 2,
"a probe that can compare nothing must not be reported as pending",
);
// This profile has no VPN extension, so nothing may block its launch.
assert.equal(
checks.vpn_extensions.length,
@@ -450,6 +688,17 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
command,
new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
// An automation run starts clean: it never reopens a person's session.
// The crash-restore bubble stays hidden, and the retired switch that
// Chromium no longer reads is gone from the command line.
assert.doesNotMatch(command, /--restore-last-session/);
assert.match(command, /--hide-crash-restore-bubble/);
assert.doesNotMatch(command, /--disable-session-crashed-bubble/);
assert.match(
command,
/--enable-logging=stderr/,
"the browser's own verdicts reach the app through stderr",
);
}
const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, {
@@ -488,7 +737,12 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
app,
prepared.version,
"Wayfern Batch Automation",
sample,
// The fingerprint STRING, not the envelope `generate_sample_fingerprint`
// returns it in. `WayfernConfig.fingerprint` is an `Option<String>`
// (wayfern_manager.rs), so passing `sample` made the whole command fail
// to deserialise with "invalid type: map, expected a string", before any
// of the automation this test exists to check could run.
sample.fingerprint,
);
const batchRun = await request(`${base}/v1/profiles/batch/run`, {
method: "POST",
@@ -500,29 +754,201 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
},
});
assert.equal(batchRun.response.status, 200);
assert.equal(
batchRun.value.results[0].ok,
true,
batchRun.value.results[0].error,
);
const batchCdp = await CdpClient.connect(
batchRun.value.results[0].remote_debugging_port,
);
assert.equal(
await batchCdp.waitFor("window.__fixtureReady === true"),
true,
);
batchCdp.close();
// A profile carrying a whole stored device is migrated into an identity
// plus overrides. Some override values are currently rejected by the
// browser at launch, and the launcher reports that with the property
// named, so this asserts the reported failure rather than pretending the
// launch worked. If the launch succeeds instead, the else branch takes
// over and the batch is asserted in full.
const batchBlockedByBrowser =
!batchRun.value.results[0].ok &&
/was not applied: \w+/.test(batchRun.value.results[0].error ?? "");
if (batchBlockedByBrowser) {
console.log(
`[donut-e2e] Batch profile could not launch: ${batchRun.value.results[0].error}`,
);
assert.match(
batchRun.value.results[0].error,
/WAYFERN_IDENTITY_REFUSED|WAYFERN_FINGERPRINT_APPLY_FAILED/,
"a refused device must reach the caller as a coded error, never as a silent success",
);
} else {
assert.equal(
batchRun.value.results[0].ok,
true,
batchRun.value.results[0].error,
);
const batchCdp = await CdpClient.connect(
batchRun.value.results[0].remote_debugging_port,
);
assert.equal(
await batchCdp.waitFor("window.__fixtureReady === true"),
true,
);
batchCdp.close();
}
const batchStop = await request(`${base}/v1/profiles/batch/stop`, {
method: "POST",
token: saved.api_token,
body: { profile_ids: [batchProfile.id] },
});
assert.equal(batchStop.response.status, 200);
// Stopping is idempotent: a profile that never launched is already
// stopped, so the batch endpoint reports success either way.
assert.equal(
batchStop.value.results[0].ok,
true,
batchStop.value.results[0].error,
`batch stop reported ${JSON.stringify(batchStop.value.results[0])}`,
);
// The recipe recorder's refusals, which are the whole contract a caller can
// rely on without a paid browser: what it will not start on, and that an
// idle recorder answers rather than throwing. The capture itself is a paid
// browser feature and is tested where that feature lives.
assert.deepEqual(await app.invoke("get_recipe_recording"), {
profile_id: null,
steps: [],
recording: false,
});
assert.deepEqual(await app.invoke("stop_recipe_recording"), {
profile_id: null,
steps: [],
recording: false,
});
assert.match(
await app.invokeError("start_recipe_recording", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
/PROFILE_NOT_FOUND/,
);
assert.match(
await app.invokeError("start_recipe_recording", {
profileId: profile.id,
}),
/PROFILE_NOT_RUNNING/,
"a recording needs a live browser to attach to",
);
// Export and import: a profile is moved to another machine as one archive
// and comes back as a NEW profile, owing nothing to the machine that wrote
// it. Exercised here because this is the suite with a real profile
// directory to carry.
const exportPath = path.join(app.dataRoot, "exported.donutprofile");
const exported = await app.invoke("export_profile", {
profileId: profile.id,
destination: exportPath,
includeData: true,
});
assert.equal(exported.profile_name, profile.name);
assert.equal(exported.browser, "wayfern");
assert.ok((await stat(exportPath)).size > 0);
const archivePreview = await app.invoke("preview_profile_archive", {
path: exportPath,
});
assert.equal(archivePreview.manifest.profile_name, profile.name);
assert.deepEqual(archivePreview.tags, []);
const importedProfile = await app.invoke("import_profile_archive", {
path: exportPath,
});
assert.notEqual(importedProfile.id, profile.id);
assert.equal(importedProfile.version, profile.version);
assert.equal(
importedProfile.process_id,
null,
"an imported profile is not running on this machine",
);
assert.equal(
importedProfile.proxy_id ?? null,
null,
"a proxy id belongs to the machine that assigned it",
);
assert.equal(
importedProfile.wayfern_config.identity_id,
profile.wayfern_config.identity_id,
"the device travels: the same identity rebuilds the same browser",
);
// Twice from one archive gives two profiles, under distinct names.
const importedAgain = await app.invoke("import_profile_archive", {
path: exportPath,
});
assert.notEqual(importedAgain.id, importedProfile.id);
assert.notEqual(importedAgain.name, importedProfile.name);
assert.match(
await app.invokeError("preview_profile_archive", {
path: path.join(app.dataRoot, "not-an-archive"),
}),
/PROFILE_IMPORT_FAILED/,
);
for (const created of [importedProfile, importedAgain]) {
await app.invoke("delete_profile", {
profileId: created.id,
permanent: true,
});
}
// A temporary profile: created over REST for one run, gone once its
// browser stops. Nothing else in the app removes it, so this is the
// whole contract an automation client depends on.
const temporary = await request(`${base}/v1/profiles`, {
method: "POST",
token: saved.api_token,
body: {
name: "Temporary Run",
browser: "wayfern",
version: prepared.version,
temporary: true,
},
});
assert.equal(
temporary.response.status,
200,
JSON.stringify(temporary.value),
);
assert.equal(temporary.value.profile.temporary, true);
assert.equal(
temporary.value.profile.ephemeral,
true,
"a temporary profile keeps its browsing data in memory only",
);
const temporaryId = temporary.value.profile.id;
const temporaryRun = await request(
`${base}/v1/profiles/${temporaryId}/run`,
{
method: "POST",
token: saved.api_token,
body: { url: `${fixtureUrl}/temporary`, headless: true },
},
);
assert.equal(
temporaryRun.response.status,
200,
JSON.stringify(temporaryRun.value),
);
const temporaryPid = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === temporaryId,
)?.process_id;
assert.ok(
temporaryPid,
"the temporary profile must report the browser it started",
);
await request(`${base}/v1/profiles/${temporaryId}/kill`, {
method: "POST",
token: saved.api_token,
});
await waitForProcessExit(app, temporaryPid);
await app.waitFor(
async () =>
!(await app.invoke("list_browser_profiles")).some(
(item) => item.id === temporaryId,
),
{ description: "the temporary profile to delete itself" },
);
assert.deepEqual(
(await app.invoke("list_trashed_profiles")).filter(
(entry) => entry.id === temporaryId,
),
[],
"a disposable profile must not land in the trash",
);
await app.invoke("stop_api_server");
@@ -621,12 +1047,9 @@ async function launchWithWorker(app, version, name) {
// order (app closed first, so nothing is left to reap anything).
test("a proxy worker dies with its browser, with and without the app running", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernPath = defaultWayfernPath(
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-worker-lifecycle", {
seedVersionCache: localWayfernVersion ?? false,
// Let the app run the real acceptance flow below; the pre-seeded marker is
@@ -721,12 +1144,9 @@ test("a proxy worker dies with its browser, with and without the app running", a
// second profile broke the extension in every browser already running.
test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernPath = defaultWayfernPath(
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-extensions", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
@@ -889,3 +1309,215 @@ test("an assigned extension group reaches Wayfern and each profile stages its ow
await app.close();
}
});
/// The browser's remote-debugging port, read off its own command line: an
/// interactive launch does not hand the port back the way an API run does.
function debuggingPortOf(pid) {
const command = execFileSync(
"ps",
["-ww", "-o", "command=", "-p", String(pid)],
{ encoding: "utf8" },
);
const match = command.match(/--remote-debugging-port=(\d+)/);
assert.ok(match, `no debugging port on the command line: ${command}`);
return { port: Number(match[1]), command };
}
async function targetUrls(port) {
const targets = await fetch(`http://127.0.0.1:${port}/json`).then((r) =>
r.json(),
);
return targets
.filter((target) => target.type === "page")
.map((target) => target.url);
}
test("an interactive launch continues the last session once the identity travels at launch", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const app = appFromEnvironment("browser-session", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
});
let browserPid;
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
// The browser itself refuses to start until its terms marker exists, and
// only its own acceptance run writes one it recognises.
await app.invoke("accept_wayfern_terms");
const major = Number.parseInt(prepared.version.split(".")[0], 10);
if (major < 152) {
// Older builds take no launch identity, so Donut starts them on a fresh
// tab and there is nothing to continue.
console.log(
`[donut-e2e] Wayfern ${prepared.version} takes no launch identity; session restore is off by design, skipping the restore assertions`,
);
return;
}
const profile = await createRealProfile(
app,
prepared.version,
"Session Restore",
);
// A launch identity needs the exit's timezone; the geoip match writes it.
await app.invoke("download_geoip_database");
await app.invoke("match_profile_fingerprint_to_exit", {
profileId: profile.id,
exitIp: "8.8.8.8",
});
const stored = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
const location = JSON.parse(stored.wayfern_config.location);
assert.equal(typeof location.timezone, "string");
const userDataDir = path.join(
app.dataRoot,
"data",
"profiles",
profile.id,
"profile",
);
const launch = async (url) => {
const current = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
const launched = await app.invoke("launch_browser_profile", {
profile: current,
url,
});
assert.ok(launched.process_id);
browserPid = launched.process_id;
return launched;
};
const stop = async () => {
const current = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
await app.invoke("kill_browser_profile", { profile: current });
await waitForProcessExit(app, browserPid);
};
const waitForTargets = async (port, expected) => {
let seen = [];
await app
.waitFor(
async () => {
seen = await targetUrls(port).catch(() => []);
return expected.every((needle) =>
seen.some((url) => url.includes(needle)),
);
},
{ timeoutMs: 30_000, description: `targets ${expected.join(", ")}` },
)
.catch(() => {
// The URLs it did see are the whole diagnosis: a restore that
// dropped one tab looks identical to one that never ran.
assert.fail(
`waiting for ${expected.join(", ")} but the browser had ${
seen.length ? seen.join(", ") : "no page targets"
}`,
);
});
};
// First session: two tabs.
const first = await launch(`${fixtureUrl}/session-a`);
const { port: firstPort, command } = debuggingPortOf(first.process_id);
assert.match(command, /--restore-last-session/);
assert.match(command, /--wayfern-identity-file=/);
const identityFile = JSON.parse(
await readFile(path.join(userDataDir, "wayfern-identity.json"), "utf8"),
);
assert.equal(identityFile.identityId, stored.wayfern_config.identity_id);
assert.equal(identityFile.timezone, location.timezone);
// No claimed OS means the host, which is what an omitted operatingSystem
// means over CDP as well; the document has to spell it out.
assert.equal(
identityFile.operatingSystem,
stored.wayfern_config.os ?? currentHostOs(),
);
await waitForTargets(firstPort, ["/session-a"]);
await app.invoke("open_url_with_profile", {
profileId: profile.id,
url: `${fixtureUrl}/session-b`,
});
await waitForTargets(firstPort, ["/session-a", "/session-b"]);
await stop();
const preferences = JSON.parse(
await readFile(path.join(userDataDir, "Default", "Preferences"), "utf8"),
);
assert.equal(
preferences.profile?.exit_type,
"Normal",
"a stop must run the browser's own shutdown so the session is written",
);
// Second session: both tabs come back, and the launch URL gets its own
// tab instead of replacing a restored one.
const second = await launch(`${fixtureUrl}/session-c`);
const { port: secondPort } = debuggingPortOf(second.process_id);
await waitForTargets(secondPort, [
"/session-a",
"/session-b",
"/session-c",
]);
// A browser that died hard still comes back, with no bubble to answer.
// Chromium commits a tab change to the session file on a short delay, so a
// kill in the same second loses the newest tab through no fault of the
// launcher; wait for the write before pulling the plug.
await new Promise((resolve) => setTimeout(resolve, 6_000));
process.kill(second.process_id, "SIGKILL");
await waitForProcessExit(app, second.process_id);
await app.waitFor(
async () =>
!(await app.invoke("check_browser_status", {
profile: (
await app.invoke("list_browser_profiles")
).find((p) => p.id === profile.id),
})),
{ description: "the app to notice the killed browser" },
);
const third = await launch(null);
const { port: thirdPort } = debuggingPortOf(third.process_id);
await waitForTargets(thirdPort, ["/session-a", "/session-b", "/session-c"]);
await stop();
// Switched off, the profile starts on a fresh tab.
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: { ...stored.wayfern_config, restore_session: false },
});
const fourth = await launch(`${fixtureUrl}/session-d`);
const { port: fourthPort, command: fourthCommand } = debuggingPortOf(
fourth.process_id,
);
assert.doesNotMatch(fourthCommand, /--restore-last-session/);
await waitForTargets(fourthPort, ["/session-d"]);
assert.ok(
!(await targetUrls(fourthPort)).some((url) => url.includes("/session-a")),
"a profile with restore switched off must not reopen the old session",
);
await stop();
await app.invoke("delete_profile", { profileId: profile.id });
} catch (error) {
await app.capture("failure");
throw error;
} finally {
if (app.session && browserPid && processExists(browserPid)) {
const profile = (
await app.invoke("list_browser_profiles").catch(() => [])
).find((item) => item.process_id === browserPid);
if (profile)
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
}
await app.close();
}
});
+8 -1
View File
@@ -24,8 +24,15 @@ function commandHasExecutableEvidence(source, command) {
.split("::")
.at(-1)
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Every helper that actually CALLS the command counts. This list is the gate's
// blind spot: a suite can strengthen its assertions by routing through a new
// helper and silently lose the evidence, which is exactly what happened when
// `assertContract` replaced eight `assert.ok(await invokeContract(...))` calls
//, the assertions got stronger and the gate went red. `assertCommandErrorCode`
// joined the list when the local-MCP tests moved to asserting refusal codes.
return new RegExp(
`(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
`(?:invoke|invokeError)\\(\\s*["']${name}["']` +
`|(?:invokeContract|assertContract|assertCommandErrorCode)\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
).test(source);
}
+724 -2
View File
@@ -12,6 +12,8 @@ import { DatabaseSync } from "node:sqlite";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import {
CRX_EXTENSION_NAME,
CRX_EXTENSION_VERSION,
extensionIconPngBase64,
extensionZipBase64,
wireGuardFixture,
@@ -103,6 +105,11 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
});
assert.equal(parsedImport.imported_count, 1);
assert.deepEqual(
await app.invoke("get_proxy_check_history", { proxyId: proxy.id }),
[],
"a proxy nobody has checked has no trail",
);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
@@ -113,6 +120,49 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
// A check that failed is still a check, and it is recorded as one. The
// proxy above was edited to SOCKS5 on a closed port, so the UDP probe
// could not reach it: the honest verdict is "unknown", never "no".
const trail = await app.invoke("get_proxy_check_history", {
proxyId: proxy.id,
});
assert.equal(trail.length, 1);
assert.equal(trail[0].ok, false);
assert.equal(trail[0].ip, null);
assert.equal(trail[0].udp, "unknown");
assert.ok(
typeof trail[0].latency_ms === "number" && trail[0].latency_ms >= 0,
);
assert.ok(trail[0].timestamp > 0);
// Deleting the proxy takes the trail with it; it names exit addresses.
const doomed = await app.invoke("create_stored_proxy", {
name: "Trail Owner",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
await app.invokeError("check_proxy_validity", {
proxyId: doomed.id,
proxySettings: null,
});
const doomedTrail = await app.invoke("get_proxy_check_history", {
proxyId: doomed.id,
});
assert.equal(doomedTrail.length, 1);
// An HTTP proxy cannot carry a datagram at all, which is answered from
// the protocol without dialling anything.
assert.equal(doomedTrail[0].udp, "no");
await app.invoke("delete_stored_proxy", { proxyId: doomed.id });
assert.deepEqual(
await app.invoke("get_proxy_check_history", { proxyId: doomed.id }),
[],
);
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
// uses this to tell the user WHICH part of their setup is unsupported
// instead of implying they mistyped, so the reason must survive the IPC hop.
@@ -661,6 +711,103 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass
"importing a folder must never move or consume the user's copy of it",
);
// Importing from a link. The fixture server answers with a real CRX3
// container, so this proves the importer unwraps the signed container to
// the ZIP the store keeps rather than filing the container itself.
const fixtureBase = process.env.DONUT_E2E_FIXTURE_URL;
assert.ok(fixtureBase, "the fixture server URL has to reach the suite");
const fetched = await app.invoke("fetch_extension_from_url", {
url: `${fixtureBase}/extension.crx`,
});
assert.equal(fetched.name, CRX_EXTENSION_NAME);
assert.equal(fetched.version, CRX_EXTENSION_VERSION);
assert.equal(fetched.from_web_store, false);
assert.equal(
fetched.file_name,
"extension.zip",
"the stored payload is the ZIP, so it must not still be called a .crx",
);
assert.deepEqual(
fetched.file_data.slice(0, 4),
[0x50, 0x4b, 0x03, 0x04],
"the CRX3 header has to be stripped, not stored",
);
const fromLink = await app.invoke("add_extension", {
name: "Overridden By The Manifest",
fileName: fetched.file_name,
fileData: fetched.file_data,
});
assert.equal(fromLink.name, CRX_EXTENSION_NAME);
assert.equal(fromLink.version, CRX_EXTENSION_VERSION);
assert.equal(fromLink.source_kind, "archive");
assert.equal(fromLink.file_type, "zip");
// Assignable like any other extension: the link is only how it arrived.
const linkGroup = await app.invoke("create_extension_group", {
name: "Downloaded Extensions",
});
assert.deepEqual(
(
await app.invoke("add_extension_to_group", {
groupId: linkGroup.id,
extensionId: fromLink.id,
})
).extension_ids,
[fromLink.id],
);
await app.invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: linkGroup.id,
});
assert.equal(
(
await app.invoke("get_extension_group_for_profile", {
profileId: profile.id,
})
).id,
linkGroup.id,
);
// A body that is not an extension is refused with the code, and nothing
// is stored for it.
assert.match(
await app.invokeError("fetch_extension_from_url", {
url: `${fixtureBase}/not-an-extension.zip`,
}),
/EXTENSION_NOT_AN_EXTENSION/,
);
for (const rejected of [
"not a link at all",
"https://example.invalid/downloads",
"https://example.invalid/installer.exe",
// 32 characters, but an extension id only uses a-p.
"abcdefghijklmnopabcdefghijklmnoz",
// Plain HTTP off loopback never crosses the wire, whatever it points at.
"http://files.example.invalid/pack.crx",
]) {
assert.match(
await app.invokeError("fetch_extension_from_url", { url: rejected }),
/EXTENSION_URL_INVALID/,
rejected,
);
}
assert.match(
await app.invokeError("fetch_extension_from_url", {
url: `${fixtureBase}/absent-extension.crx`,
}),
/EXTENSION_NOT_AN_EXTENSION|EXTENSION_DOWNLOAD_FAILED/,
);
assert.equal((await app.invoke("list_extensions")).length, 1);
await app.invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: null,
});
await app.invoke("delete_extension_group", { groupId: linkGroup.id });
await app.invoke("delete_extension", { extensionId: fromLink.id });
assert.deepEqual(await app.invoke("list_extensions"), []);
const vpn = await app.invoke("create_vpn_config_manual", {
name: "E2E WireGuard",
vpnType: "WireGuard",
@@ -766,11 +913,16 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
expirationDate: 2_000_000_000,
},
]);
const imported = await app.invoke("import_cookies_from_file", {
const imported = await app.invoke("import_pasted_cookies", {
profileId: source.id,
content: cookieJson,
site: null,
mode: "merge",
includeExpired: false,
});
assert.equal(imported.cookies_imported, 1);
assert.equal(imported.added, 1);
assert.equal(imported.overwritten, 0);
assert.equal(imported.deleted, 0);
const cookies = await app.invoke("read_profile_cookies", {
profileId: source.id,
});
@@ -803,6 +955,62 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
/fixture\.local/,
);
const paste = [
"# Netscape HTTP Cookie File",
"#HttpOnly_.fixture.local\tTRUE\t/\tFALSE\t2000000000\tpasted\tpasted-value",
].join("\n");
const analysis = await app.invoke("analyze_pasted_cookies", {
profileId: target.id,
content: paste,
site: null,
});
assert.equal(analysis.format, "netscape");
assert.equal(analysis.cookies.length, 1);
assert.equal(analysis.cookies[0].name, "pasted");
assert.equal(analysis.cookies[0].isHttpOnly, true);
assert.equal(
analysis.cookies[0].value,
undefined,
"the preview must never carry the cookie value",
);
assert.equal(analysis.siteRequired, false);
assert.equal(analysis.expiredCount, 0);
assert.equal(analysis.blockedBy, null);
// The copied fixture.local cookie is the one row replace mode would clear.
assert.equal(analysis.replaceDeleteCount, 1);
const merged = await app.invoke("import_pasted_cookies", {
profileId: target.id,
content: paste,
site: null,
mode: "merge",
includeExpired: false,
});
assert.equal(merged.added, 1);
assert.equal(merged.deleted, 0);
assert.equal(merged.skipped, 0);
assert.equal(
(await app.invoke("get_profile_cookie_stats", { profileId: target.id }))
.total_count,
2,
);
// Both spellings of the pasted site go, and only they do.
const replacedPaste = await app.invoke("import_pasted_cookies", {
profileId: target.id,
content: paste,
site: null,
mode: "replaceMatchingSites",
includeExpired: false,
});
assert.equal(replacedPaste.deleted, 2);
assert.equal(replacedPaste.added, 1);
const afterReplace = await app.invoke("read_profile_cookies", {
profileId: target.id,
});
assert.equal(afterReplace.total_count, 1);
assert.equal(afterReplace.domains[0].cookies[0].name, "pasted");
await app.invoke("set_profile_password", {
profileId: source.id,
password: "correct horse battery staple",
@@ -865,3 +1073,517 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
});
});
});
test("deleted profiles land in the trash and come back intact on restore", async () => {
await withApp("entities-trash", async (app) => {
const initialSettings = await app.invoke("get_app_settings");
assert.equal(initialSettings.trash_retention_days, 30);
const savedSettings = await app.invoke("save_app_settings", {
settings: { ...initialSettings, trash_retention_days: 7 },
});
assert.equal(savedSettings.trash_retention_days, 7);
assert.equal(
(await app.invoke("get_app_settings")).trash_retention_days,
7,
);
// Out-of-range values are clamped, never rejected.
const clamped = await app.invoke("save_app_settings", {
settings: { ...savedSettings, trash_retention_days: 9000 },
});
assert.equal(clamped.trash_retention_days, 365);
await app.invoke("save_app_settings", {
settings: { ...clamped, trash_retention_days: 7 },
});
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
const group = await app.invoke("create_profile_group", {
name: "Trash Group",
});
const created = await app.invoke("create_browser_profile_new", {
name: "Recoverable",
browserStr: "wayfern",
version: "150.0.7871.100",
releaseType: "stable",
proxyId: null,
vpnId: null,
wayfernConfig: {
fingerprint: "{}",
identity_id: "identity-e2e",
identity_overrides: JSON.stringify({ userAgent: "Custom UA" }),
location: JSON.stringify({
timezone: "Europe/Berlin",
language: "de-DE",
}),
},
groupId: group.id,
ephemeral: false,
dnsBlocklist: null,
launchHook: null,
});
await app.invoke("update_profile_tags", {
profileId: created.id,
tags: ["shop", "eu"],
});
const before = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === created.id,
);
assert.equal(before.wayfern_config.identity_id, "identity-e2e");
assert.deepEqual(before.tags, ["shop", "eu"]);
assert.equal(before.group_id, group.id);
// Real files to carry through the move, plus a cache the trash must drop.
const profilesDir = path.join(app.dataRoot, "data", "profiles");
const dataDir = path.join(profilesDir, created.id, "profile");
await mkdir(path.join(dataDir, "Default"), { recursive: true });
await writeFile(path.join(dataDir, "Default", "Cookies"), "cookie-db");
await mkdir(path.join(dataDir, "Cache"), { recursive: true });
await writeFile(path.join(dataDir, "Cache", "blob"), "cache-bytes");
await app.invoke("delete_profile", { profileId: created.id });
assert.equal(
(await app.invoke("list_browser_profiles")).some(
(item) => item.id === created.id,
),
false,
);
const trashed = await app.invoke("list_trashed_profiles");
assert.equal(trashed.length, 1);
assert.equal(trashed[0].id, created.id);
assert.equal(trashed[0].name, "Recoverable");
assert.equal(trashed[0].browser, "wayfern");
assert.equal(trashed[0].version, "150.0.7871.100");
assert.equal(trashed[0].group_id, group.id);
assert.equal(trashed[0].password_protected, false);
assert.equal(
trashed[0].expires_at - trashed[0].deleted_at,
7 * 24 * 60 * 60,
);
assert.ok(trashed[0].size_bytes > 0);
const trashDir = path.join(app.dataRoot, "data", "trash");
const entryDir = path.join(trashDir, created.id);
assert.ok(existsSync(path.join(entryDir, "profile.json")));
assert.ok(existsSync(path.join(entryDir, "manifest.json")));
assert.equal(
await readFile(
path.join(entryDir, "profile", "Default", "Cookies"),
"utf8",
),
"cookie-db",
);
assert.equal(
existsSync(path.join(entryDir, "profile", "Cache")),
false,
"caches are pruned before the move",
);
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
// A live profile carrying the same name pushes the restored one to a suffix.
const namesake = await createProfile(app, "Recoverable");
const restored = await app.invoke("restore_trashed_profile", {
profileId: created.id,
});
assert.equal(restored.id, created.id);
assert.equal(restored.name, "Recoverable (restored)");
assert.deepEqual(restored.wayfern_config, before.wayfern_config);
assert.deepEqual(restored.tags, before.tags);
assert.equal(restored.group_id, group.id);
assert.ok(restored.updated_at >= (before.updated_at ?? 0));
const live = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === created.id,
);
assert.deepEqual(live.wayfern_config, before.wayfern_config);
assert.deepEqual(live.tags, before.tags);
assert.equal(
await readFile(
path.join(profilesDir, created.id, "profile", "Default", "Cookies"),
"utf8",
),
"cookie-db",
);
assert.equal(existsSync(entryDir), false);
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.match(
await app.invokeError("restore_trashed_profile", {
profileId: created.id,
}),
/TRASH_ENTRY_NOT_FOUND/,
);
// A group deleted while the profile sat in the trash is not resurrected.
await app.invoke("delete_profile", { profileId: created.id });
await app.invoke("delete_profile_group", { groupId: group.id });
const restoredWithoutGroup = await app.invoke("restore_trashed_profile", {
profileId: created.id,
});
assert.equal(restoredWithoutGroup.id, created.id);
assert.equal(restoredWithoutGroup.group_id, null);
assert.deepEqual(
restoredWithoutGroup.wayfern_config,
before.wayfern_config,
);
// Delete again, then purge: gone for good.
await app.invoke("delete_profile", { profileId: created.id });
assert.equal((await app.invoke("list_trashed_profiles")).length, 1);
await app.invoke("purge_trashed_profile", { profileId: created.id });
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.equal(existsSync(entryDir), false);
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
assert.match(
await app.invokeError("purge_trashed_profile", {
profileId: created.id,
}),
/TRASH_ENTRY_NOT_FOUND/,
);
// An explicit permanent delete never lands in the trash.
const doomed = await createProfile(app, "Doomed");
await app.invoke("delete_profile", {
profileId: doomed.id,
permanent: true,
});
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.equal(existsSync(path.join(trashDir, doomed.id)), false);
assert.equal(existsSync(path.join(profilesDir, doomed.id)), false);
// A bulk delete trashes every profile; emptying the trash clears them all.
const bulkA = await createProfile(app, "Bulk A");
const bulkB = await createProfile(app, "Bulk B");
await app.invoke("delete_selected_profiles", {
profileIds: [bulkA.id, bulkB.id],
});
assert.deepEqual(
(await app.invoke("list_trashed_profiles"))
.map((entry) => entry.name)
.sort(),
["Bulk A", "Bulk B"],
);
assert.equal(await app.invoke("empty_trash"), 2);
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.equal(existsSync(path.join(trashDir, bulkA.id)), false);
// Restore refuses an entry whose id a live profile already carries.
const conflictDir = path.join(trashDir, namesake.id);
await mkdir(conflictDir, { recursive: true });
await writeFile(
path.join(conflictDir, "profile.json"),
JSON.stringify(namesake),
);
await writeFile(
path.join(conflictDir, "manifest.json"),
JSON.stringify({
deleted_at: 1,
expires_at: 4_102_444_800,
size_bytes: 0,
original_name: namesake.name,
}),
);
assert.match(
await app.invokeError("restore_trashed_profile", {
profileId: namesake.id,
}),
/TRASH_RESTORE_CONFLICT/,
);
await app.invoke("purge_trashed_profile", { profileId: namesake.id });
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
await app.invoke("delete_profile", {
profileId: namesake.id,
permanent: true,
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
});
});
test("proxies distribute one to one, and group bookmarks reach the profile's Bookmarks file", async () => {
await withApp("entities-distribution-bookmarks", async (app) => {
const profiles = [];
for (const name of ["Fleet 1", "Fleet 2", "Fleet 3", "Fleet 4"]) {
profiles.push(await createProfile(app, name));
}
const proxies = [];
for (const [index, name] of ["Exit A", "Exit B", "Exit C"].entries()) {
proxies.push(
await app.invoke("create_stored_proxy", {
name,
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9001 + index,
username: null,
password: null,
},
}),
);
}
const profileIds = profiles.map((profile) => profile.id);
const proxyIds = proxies.map((proxy) => proxy.id);
// Four profiles, three proxies: three pairs and one profile left alone.
// The fourth must NEVER wrap around onto the first proxy.
const plan = await app.invoke("plan_proxy_distribution", {
profileIds,
proxyIds,
allowSharing: false,
});
assert.deepEqual(
plan.pairs,
proxyIds.map((proxyId, index) => ({
profile_id: profileIds[index],
proxy_id: proxyId,
})),
);
assert.deepEqual(plan.unpaired_profile_ids, [profileIds[3]]);
assert.deepEqual(plan.unused_proxy_ids, []);
assert.deepEqual(plan.shared_proxy_ids, []);
assert.deepEqual(plan.running_profile_ids, []);
const results = await app.invoke("distribute_proxies_to_profiles", {
pairs: plan.pairs,
});
assert.equal(results.length, 3);
assert.ok(results.every((result) => result.ok));
const afterDistribution = await app.invoke("list_browser_profiles");
const proxyOf = (id) =>
afterDistribution.find((profile) => profile.id === id).proxy_id;
assert.equal(proxyOf(profileIds[0]), proxyIds[0]);
assert.equal(proxyOf(profileIds[1]), proxyIds[1]);
assert.equal(proxyOf(profileIds[2]), proxyIds[2]);
assert.equal(proxyOf(profileIds[3]) ?? null, null);
// A proxy someone else holds is refused by default and only offered once
// the caller asks for sharing explicitly.
const strict = await app.invoke("plan_proxy_distribution", {
profileIds: [profileIds[3]],
proxyIds: [proxyIds[0]],
allowSharing: false,
});
assert.deepEqual(strict.pairs, []);
assert.deepEqual(strict.shared_proxy_ids, [proxyIds[0]]);
assert.deepEqual(strict.unpaired_profile_ids, [profileIds[3]]);
const permissive = await app.invoke("plan_proxy_distribution", {
profileIds: [profileIds[3]],
proxyIds: [proxyIds[0]],
allowSharing: true,
});
assert.deepEqual(permissive.pairs, [
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
]);
// Per-profile failures never break the batch: one good pair still lands.
const mixed = await app.invoke("distribute_proxies_to_profiles", {
pairs: [
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
{ profile_id: profileIds[3], proxy_id: proxyIds[1] },
{
profile_id: profileIds[0],
proxy_id: "00000000-0000-4000-8000-000000000000",
},
],
});
assert.equal(mixed[0].ok, true);
assert.equal(mixed[1].ok, false);
assert.match(mixed[1].error, /PROFILE_PAIRED_TWICE/);
assert.equal(mixed[2].ok, false);
assert.match(mixed[2].error, /PROXY_NOT_FOUND/);
assert.equal(
(await app.invoke("list_browser_profiles")).find(
(profile) => profile.id === profileIds[3],
).proxy_id,
proxyIds[0],
);
// --- group bookmarks ---
const group = await app.invoke("create_profile_group", {
name: "Client Sites",
});
assert.deepEqual(
await app.invoke("get_group_bookmarks", { groupId: group.id }),
[],
);
const refused = await app.invokeError("set_group_bookmarks", {
groupId: group.id,
bookmarks: [{ title: "Keys", url: "file:///etc/passwd", folder: null }],
});
assert.match(refused, /URL_SCHEME_NOT_ALLOWED/);
const unnamed = await app.invokeError("set_group_bookmarks", {
groupId: group.id,
bookmarks: [{ title: " ", url: "https://ok.example", folder: null }],
});
assert.match(unnamed, /NAME_CANNOT_BE_EMPTY/);
const saved = await app.invoke("set_group_bookmarks", {
groupId: group.id,
bookmarks: [
{ title: "Support", url: "https://support.example", folder: null },
{ title: "Console", url: "https://console.example", folder: "Ops" },
],
});
assert.equal(saved.length, 2);
assert.equal(saved[1].folder, "Ops");
assert.equal(
(await app.invoke("get_groups_with_profile_counts")).find(
(item) => item.id === group.id,
).bookmark_count,
2,
);
const target = profiles[0];
await app.invoke("assign_profiles_to_group", {
profileIds: [target.id],
groupId: group.id,
});
// Seed the profile's own Bookmarks file the way a real Chromium session
// would have left it, so the write has something of the user's to preserve.
const bookmarksFile = path.join(
app.dataRoot,
"data",
"profiles",
target.id,
"profile",
"Default",
"Bookmarks",
);
await mkdir(path.dirname(bookmarksFile), { recursive: true });
const permanentFolder = (id, name) => ({
children: [],
date_added: "13300000000000000",
date_modified: "13300000000000000",
guid: `0000000${id}-0000-4000-8000-000000000000`,
id: String(id),
name,
type: "folder",
});
await writeFile(
bookmarksFile,
JSON.stringify({
checksum: "0".repeat(32),
roots: {
bookmark_bar: {
...permanentFolder(1, "Bookmarks bar"),
children: [
{
date_added: "13300000000000000",
guid: "aaaaaaaa-0000-4000-8000-000000000000",
id: "9",
name: "My Bank",
type: "url",
url: "https://bank.example/",
},
],
},
other: permanentFolder(2, "Other bookmarks"),
synced: permanentFolder(3, "Mobile bookmarks"),
},
sync_metadata: "Zm9v",
version: 1,
}),
);
const readBookmarks = async () =>
JSON.parse(await readFile(bookmarksFile, "utf8"));
const managedFolderOf = (document) =>
document.roots.bookmark_bar.children.filter(
(child) =>
child.type === "folder" &&
child.meta_info?.donut_managed_group_bookmarks === "1",
);
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
true,
);
let document = await readBookmarks();
let managed = managedFolderOf(document);
assert.equal(managed.length, 1);
assert.equal(managed[0].name, "Donut Group Bookmarks");
assert.deepEqual(
managed[0].children.map((child) => child.name),
["Support", "Ops"],
);
assert.deepEqual(
managed[0].children[1].children.map((child) => child.url),
["https://console.example"],
);
// The user's own bookmark, the other roots and Chromium's opaque state all
// survive; only the checksum is rewritten to describe the new tree.
assert.equal(document.roots.bookmark_bar.children[0].name, "My Bank");
assert.equal(document.sync_metadata, "Zm9v");
assert.equal(document.version, 1);
assert.notEqual(document.checksum, "0".repeat(32));
assert.match(document.checksum, /^[0-9a-f]{32}$/);
// Applying again is a no-op: the folder is not duplicated and the file is
// not even rewritten.
const firstWrite = await readFile(bookmarksFile, "utf8");
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
false,
);
assert.equal(await readFile(bookmarksFile, "utf8"), firstWrite);
// Removing a bookmark from the group removes it from the folder next time.
await app.invoke("set_group_bookmarks", {
groupId: group.id,
bookmarks: [
{ title: "Support", url: "https://support.example", folder: null },
],
});
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
true,
);
document = await readBookmarks();
managed = managedFolderOf(document);
assert.equal(managed.length, 1);
assert.deepEqual(
managed[0].children.map((child) => child.name),
["Support"],
);
// Emptying the group takes the whole folder away and leaves the user's own.
await app.invoke("set_group_bookmarks", {
groupId: group.id,
bookmarks: [],
});
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
true,
);
document = await readBookmarks();
assert.deepEqual(managedFolderOf(document), []);
assert.deepEqual(
document.roots.bookmark_bar.children.map((child) => child.name),
["My Bank"],
);
// A profile in no group is left entirely alone.
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: profileIds[1],
}),
false,
);
await app.invoke("delete_selected_profiles", { profileIds });
await app.invoke("delete_profile_group", { groupId: group.id });
for (const proxy of proxies) {
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
}
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+123 -52
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
@@ -270,57 +271,55 @@ async function createProfileThroughUi(app, groupName) {
return profiles.find((profile) => profile.name === "Visible Network Profile");
}
async function assignNetworkThroughUi(app, profileName, currentName, newName) {
const trigger = await app.execute(
/**
* The popover trigger sitting in a named COLUMN of a profile's row.
*
* Anchored to the column, never to the label the cell happens to show. Both
* callers used to search the whole row for the cell's current text, "Default"
* for the extension group, "Not selected" for the network, and both of those
* strings had long since become "None" in the app. Neither string exists
* anywhere in src/ any more, so the assertions failed against a UI that was
* working correctly, and with no E2E in CI nothing reported it.
*
* Matching on "None" instead would only move the problem: Proxy / VPN and EXT
* render the identical text, so a row-wide search would pick whichever came
* first in the DOM. The column is the thing that actually identifies the
* control, so that is what this matches on.
*
* A renamed header returns the header list rather than null, so the failure
* says which column went missing instead of "was not visible".
*/
async function columnTrigger(app, profileName, header) {
return app.execute(
`
const row = [...document.querySelectorAll("tr")].find((candidate) =>
const row = [...document.querySelectorAll("tbody tr")].find((candidate) =>
(candidate.innerText || "").includes(arguments[0])
);
const expected = arguments[1].toLocaleLowerCase();
return [...(row?.querySelectorAll('[aria-haspopup="dialog"]') ?? [])].find(
(trigger) => (trigger.innerText || trigger.textContent || "")
.toLocaleLowerCase()
.includes(expected)
) ?? null;
if (!row) return null;
const headers = [
...(row.closest("table")?.querySelectorAll("thead th") ?? []),
].map((cell) => (cell.innerText || cell.textContent || "").trim());
const index = headers.indexOf(arguments[1]);
if (index < 0) return "MISSING_COLUMN:" + headers.join(" | ");
return (
row.children[index]?.querySelector(
'[aria-haspopup="dialog"], button',
) ?? null
);
`,
[profileName, currentName],
);
assert.ok(trigger, `Network selector for ${profileName} was not visible`);
await app.session.click(trigger);
await app.clickText(newName, { exact: false, roles: ["option"] });
await app.waitFor(
() =>
app.execute(
`
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
.some((content) => (content.innerText || "").includes(arguments[0]));
`,
[newName],
),
{ description: `${newName} network picker to unmount` },
[profileName, header],
);
}
async function assignExtensionGroupThroughUi(
app,
profileName,
currentName,
newName,
) {
const trigger = await app.execute(
`
const row = [...document.querySelectorAll("tr")].find((candidate) =>
(candidate.innerText || "").includes(arguments[0])
);
return [...(row?.querySelectorAll("button") ?? [])].find(
(button) => (button.innerText || button.textContent || "")
.trim()
.includes(arguments[1])
) ?? null;
`,
[profileName, currentName],
);
assert.ok(trigger, `Extension selector for ${profileName} was not visible`);
async function assignThroughUi(app, profileName, header, newName, what) {
const trigger = await columnTrigger(app, profileName, header);
if (typeof trigger === "string") {
assert.fail(
`The "${header}" column is gone; the table now has: ` +
trigger.replace("MISSING_COLUMN:", ""),
);
}
assert.ok(trigger, `${what} selector for ${profileName} was not visible`);
await app.session.click(trigger);
await app.clickText(newName, { exact: false, roles: ["option"] });
await app.waitFor(
@@ -332,10 +331,16 @@ async function assignExtensionGroupThroughUi(
`,
[newName],
),
{ description: `${newName} extension picker to unmount` },
{ description: `${newName} ${what.toLowerCase()} picker to unmount` },
);
}
const assignNetworkThroughUi = (app, profileName, newName) =>
assignThroughUi(app, profileName, "Proxy / VPN", newName, "Network");
const assignExtensionGroupThroughUi = (app, profileName, newName) =>
assignThroughUi(app, profileName, "EXT", newName, "Extension");
async function runProfile(_app, base, token, profileId, url) {
const launched = await request(`${base}/v1/profiles/${profileId}/run`, {
method: "POST",
@@ -808,7 +813,6 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
await assignExtensionGroupThroughUi(
app,
profile.name,
"Default",
extensionEntities.group.name,
);
await app.waitFor(
@@ -824,6 +828,23 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
name: "Residential SOCKS5",
proxySettings: socksSettings,
});
// The exit's ISP and timezone are read from the MaxMind databases on this
// machine, never from an outside lookup service, so they have to actually
// be in place before a check can report them. The create-profile dialog
// starts that download in the background; this waits for it rather than
// racing it.
await app.invoke("download_geoip_database");
await app.waitFor(
async () =>
existsSync(path.join(app.dataRoot, "cache", "GeoLite2-City.mmdb")) &&
existsSync(path.join(app.dataRoot, "cache", "GeoLite2-ASN.mmdb")),
{
description: "the local MaxMind city and ASN databases",
timeoutMs: 180_000,
},
);
const [httpCheck, socksCheck] = await Promise.all([
app.invoke("check_proxy_validity", {
proxyId: httpProxy.id,
@@ -839,12 +860,62 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
assert.ok(isIP(httpCheck.ip));
assert.ok(isIP(socksCheck.ip));
await assignNetworkThroughUi(
app,
profile.name,
"Not selected",
httpProxy.name,
// An HTTP proxy tunnels TCP with CONNECT and has no datagram command, so
// the verdict follows from the protocol and is never a probe result.
assert.equal(httpCheck.udp, "no");
// The SOCKS5 proxy answered the exit lookup, so the UDP probe reached it
// too: the verdict has to be a real answer, never "unknown". Which answer
// is the provider's to decide.
assert.ok(
["yes", "no"].includes(socksCheck.udp),
`a reachable SOCKS5 proxy must give a definite UDP verdict, got ${socksCheck.udp}`,
);
for (const [label, check] of [
["http", httpCheck],
["socks5", socksCheck],
]) {
assert.ok(
typeof check.latency_ms === "number" && check.latency_ms > 0,
`${label} check has to report how long it took`,
);
// Read out of the local MaxMind databases: the exit address is never
// handed to an outside lookup service to learn these.
assert.ok(
typeof check.isp === "string" && check.isp.trim().length > 0,
`${label} check has to name the exit's ISP, got ${JSON.stringify(check.isp)}`,
);
assert.ok(
typeof check.timezone === "string" && check.timezone.includes("/"),
`${label} check has to report the exit's timezone, got ${JSON.stringify(check.timezone)}`,
);
}
// The trail grows by one line per check, newest first, and carries what
// the receipt carried.
const firstTrail = await app.invoke("get_proxy_check_history", {
proxyId: socksProxy.id,
});
assert.equal(firstTrail.length, 1);
assert.equal(firstTrail[0].ok, true);
assert.equal(firstTrail[0].ip, socksCheck.ip);
assert.equal(firstTrail[0].udp, socksCheck.udp);
assert.equal(firstTrail[0].isp, socksCheck.isp);
await app.invoke("check_proxy_validity", {
proxyId: socksProxy.id,
proxySettings: null,
});
const grownTrail = await app.invoke("get_proxy_check_history", {
proxyId: socksProxy.id,
});
assert.equal(grownTrail.length, 2);
assert.ok(
grownTrail[0].timestamp >= grownTrail[1].timestamp,
"the trail is newest first",
);
await assignNetworkThroughUi(app, profile.name, httpProxy.name);
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).find(
@@ -885,7 +956,7 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
activeCdp = null;
await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]);
await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name);
await assignNetworkThroughUi(app, profile.name, vpn.name);
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).find(
+191
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
@@ -70,11 +71,89 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
assert.ok(system && typeof system === "object");
assert.equal(typeof (await app.invoke("read_log_files")), "string");
// Feature tips: what was seen, the one-a-day pacing, and the decision
// behind the paid-plan welcome all live in the settings file.
const tips = await app.invoke("get_tips_state");
assert.equal(tips.auto_show, true);
assert.deepEqual(tips.seen, []);
assert.equal(tips.auto_due, true, "a fresh install owes its first tip");
const marked = await app.invoke("mark_tip_seen", {
tipId: "dnsBlocklist",
auto: true,
});
assert.deepEqual(marked.seen, ["dnsBlocklist"]);
assert.equal(typeof marked.last_auto_shown_at, "number");
assert.equal(marked.auto_due, false, "one automatic tip a day");
const browsed = await app.invoke("mark_tip_seen", {
tipId: "proxyCheck",
auto: false,
});
assert.deepEqual(browsed.seen, ["dnsBlocklist", "proxyCheck"]);
assert.equal(
browsed.last_auto_shown_at,
marked.last_auto_shown_at,
"a browsed tip must not restart the pacing",
);
const quiet = await app.invoke("set_tips_auto_show", { enabled: false });
assert.equal(quiet.auto_show, false);
assert.equal(quiet.auto_due, false);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-free",
paid: false,
freshLogin: true,
}),
false,
"a free account is never greeted",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-free",
paid: true,
freshLogin: false,
}),
true,
"free to paid is the upgrade the welcome exists for",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-free",
paid: true,
freshLogin: true,
}),
false,
"and it is greeted once",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-web",
paid: true,
freshLogin: true,
}),
true,
"a paid account first seen right after signing in came from checkout",
);
assert.equal(
await app.invoke("observe_cloud_plan", {
userId: "acct-old",
paid: true,
freshLogin: false,
}),
false,
"a paid account in an old session is not new to its plan",
);
await app.restart();
const afterRestart = await app.invoke("get_app_settings");
assert.equal(afterRestart.theme, "dark");
assert.equal(afterRestart.language, "en");
assert.equal(afterRestart.onboarding_completed, true);
assert.deepEqual(afterRestart.tips_seen, ["dnsBlocklist", "proxyCheck"]);
assert.equal(afterRestart.tips_auto_show, false);
assert.deepEqual((await app.invoke("get_tips_state")).seen, [
"dnsBlocklist",
"proxyCheck",
]);
const settingsFile = path.join(
app.dataRoot,
@@ -196,3 +275,115 @@ test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle"
await app.close();
}
});
test("the data directory can be moved to another folder and the choice survives a restart", async () => {
await withApp(
"smoke-data-root",
async (app) => {
// Every path below is inside this session's own temporary root. The
// real installation is never a source or a destination here.
const defaultRoot = path.join(app.dataRoot, "data");
const pointerFile = path.join(app.dataRoot, "data-root.json");
const destination = path.join(app.root, "moved-donut-data");
const before = await app.invoke("get_data_root_info");
assert.equal(before.active_path, defaultRoot);
assert.equal(before.configured_path, null);
assert.equal(before.restart_required, false);
assert.equal(before.active_path_missing, false);
assert.equal(before.overridden_by_environment, false);
assert.ok(before.file_count > 0, "the seeded settings file is counted");
assert.ok(before.size_bytes > 0, "the directory reports a real size");
assert.equal(typeof before.app_directory_name, "string");
const profile = await app.invoke("create_browser_profile_new", {
name: "Carried Across",
browserStr: "wayfern",
version: "150.0.7871.100",
releaseType: "stable",
proxyId: null,
vpnId: null,
wayfernConfig: { fingerprint: "{}" },
groupId: null,
ephemeral: false,
dnsBlocklist: null,
launchHook: null,
});
// Each refusal is its own code, because each one has a different fix.
assert.match(
await app.invokeError("move_data_root", { destination: defaultRoot }),
/DATA_ROOT_SAME_AS_CURRENT/,
);
assert.match(
await app.invokeError("move_data_root", {
destination: path.join(defaultRoot, "profiles", "elsewhere"),
}),
/DATA_ROOT_DESTINATION_INSIDE_SOURCE/,
);
assert.match(
await app.invokeError("move_data_root", {
destination: "not/absolute",
}),
/DATA_ROOT_DESTINATION_NOT_WRITABLE/,
);
assert.equal(
existsSync(destination),
false,
"a refused move must not create the destination",
);
const moved = await app.invoke("move_data_root", { destination });
assert.equal(moved.configured_path, destination);
assert.equal(moved.restart_required, true);
// The move takes effect at the next start: this process keeps every
// path it resolved when it started.
assert.equal(moved.active_path, defaultRoot);
// Copy, then verify, then delete: the old directory only goes once the
// copy has been proven whole.
assert.equal(existsSync(defaultRoot), false, "the source is removed");
assert.ok(
existsSync(path.join(destination, "settings", "app_settings.json")),
"settings travelled with the move",
);
assert.ok(
existsSync(path.join(destination, "profiles")),
"profiles travelled with the move",
);
// The pointer lives beside the data directory, never inside it, or the
// delete above would have taken it and the next start would forget.
const pointer = JSON.parse(await readFile(pointerFile, "utf8"));
assert.equal(pointer.path, destination);
await app.restart();
const after = await app.invoke("get_data_root_info");
assert.equal(after.active_path, destination);
assert.equal(after.configured_path, destination);
assert.equal(after.restart_required, false);
assert.equal(after.active_path_missing, false);
const profiles = await app.invoke("list_browser_profiles");
assert.ok(
profiles.some((entry) => entry.id === profile.id),
"the moved directory still holds the profile",
);
const settings = await app.invoke("get_app_settings");
assert.equal(settings.onboarding_completed, true);
// Forgetting the choice is the escape hatch for a drive that is gone
// for good; it moves nothing, so it too only lands on the next start.
const cleared = await app.invoke("clear_data_root_choice");
assert.equal(cleared.configured_path, null);
assert.equal(existsSync(pointerFile), false);
await app.restart();
const restored = await app.invoke("get_data_root_info");
assert.equal(restored.active_path, defaultRoot);
assert.equal(restored.configured_path, null);
},
{ seedDownloadedBrowser: true },
);
});
+132 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import test from "node:test";
import { appFromEnvironment } from "../lib/app.mjs";
@@ -482,10 +483,54 @@ test("global config sealing and encrypted profile sync reject a wrong password,
"correct password decrypts profile browser file",
);
const emptyProfile = await createProfile(source, "Encrypted Empty Profile");
await source.invoke("set_profile_sync_mode", {
profileId: emptyProfile.id,
syncMode: "Encrypted",
});
await waitFor(
source,
async () =>
(await listRemote(`profiles/${emptyProfile.id}/`)).some(
(object) =>
object.key === `profiles/${emptyProfile.id}/metadata.json`,
),
"empty profile metadata uploaded before rollover",
);
await source.invoke("set_e2e_password", {
password: "rolled encryption password",
});
await source.invoke("rollover_encryption_for_all_entities");
let rollingOver = true;
let manifestDisappeared = false;
await Promise.all([
source.invoke("rollover_encryption_for_all_entities").finally(() => {
rollingOver = false;
}),
(async () => {
while (rollingOver) {
const objects = await listRemote(`profiles/${encryptedProfile.id}/`);
manifestDisappeared ||= !objects.some(
(object) =>
object.key === `profiles/${encryptedProfile.id}/manifest.json`,
);
if (rollingOver) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
})(),
]);
assert.equal(
manifestDisappeared,
false,
"rollover must not let another device interpret a missing manifest as an empty remote profile",
);
assert.ok(
(await listRemote(`profiles/${emptyProfile.id}/`)).some(
(object) => object.key === `profiles/${emptyProfile.id}/manifest.json`,
),
"rollover must publish a manifest even for an empty profile",
);
await waitFor(
source,
async () => {
@@ -554,6 +599,10 @@ test("global config sealing and encrypted profile sync reject a wrong password,
profileId: encryptedProfile.id,
syncMode: "Disabled",
});
await source.invoke("set_profile_sync_mode", {
profileId: emptyProfile.id,
syncMode: "Disabled",
});
await source.invoke("delete_e2e_password");
assert.equal(await source.invoke("check_has_e2e_password"), false);
const missingPassword = await source.invokeError("verify_e2e_password", {
@@ -575,3 +624,85 @@ test("global config sealing and encrypted profile sync reject a wrong password,
]);
}
});
// A self-hosted server reaches its storage over an address only it can
// resolve — the documented compose file points S3_ENDPOINT at
// http://minio:9000, a Docker service name that exists on the compose network
// and nowhere else. Files never travel through the sync server, so every
// presigned URL then names a host the desktop cannot open: /health and /readyz
// stay green while every single transfer dies at connect. Reported as "the
// endpoint connection works every time, but no MB is ever synced".
test("the connection check fails a server whose storage host this device cannot reach", async () => {
assert.ok(syncUrl && syncToken, "Sync infrastructure was not started");
const app = appFromEnvironment("sync-preflight");
// Answers exactly like a healthy self-hosted server that signs presigned
// URLs against a container-only host.
const misconfigured = createServer((request, response) => {
if (request.url === "/readyz") {
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
status: "ready",
s3: true,
storageEndpoint: "http://minio.invalid:9000",
}),
);
return;
}
response.writeHead(404);
response.end();
});
await new Promise((resolve) => misconfigured.listen(0, "127.0.0.1", resolve));
const misconfiguredUrl = `http://127.0.0.1:${misconfigured.address().port}`;
try {
await app.start();
const healthy = await app.invoke("check_sync_server_connection", {
serverUrl: syncUrl,
});
assert.equal(healthy.server_reachable, true, "real sync server answers");
assert.notEqual(
healthy.storage_reachable,
false,
"the suite's own storage must be reachable from the test device",
);
// The regression itself: green server, storage nobody here can open.
const broken = await app.invoke("check_sync_server_connection", {
serverUrl: misconfiguredUrl,
});
assert.equal(broken.server_reachable, true, "server itself answered");
assert.equal(broken.storage_ready, true, "server reaches its own storage");
assert.equal(broken.storage_endpoint, "http://minio.invalid:9000");
assert.equal(
broken.storage_reachable,
false,
"an unreachable storage host must not report as a working connection",
);
assert.ok(
broken.storage_error && broken.storage_error.length > 0,
"the failure must carry a cause",
);
assert.notEqual(
broken.storage_error,
"error sending request",
"the cause must name the transport failure, not the bare reqwest text",
);
// A server that does not answer at all stays a plain connection failure,
// so the two are never confused in the UI.
const dead = await app.invoke("check_sync_server_connection", {
serverUrl: "http://127.0.0.1:1",
});
assert.equal(dead.server_reachable, false);
assert.equal(dead.storage_reachable, null);
} catch (error) {
await app.capture("failure");
throw error;
} finally {
await new Promise((resolve) => misconfigured.close(resolve));
await app.close();
}
});
+1334 -5
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.29.5";
releaseVersion = "0.30.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage";
hash = "sha256-/zOFGaHTk4S53DLZe2BXmILe/P1dlb9dT94hyvEqXcM=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_amd64.AppImage";
hash = "sha256-Vcs7ZyWUOcny+ZjoxoP5U6laOTXbqMFAdr+DZABUeJM=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage";
hash = "sha256-w2xibn/GZq681vZDj9pYNOrwhnqxX0POhmBO8XMFb+c=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.30.0/Donut_0.30.0_aarch64.AppImage";
hash = "sha256-KLZe+Vgce9KCzacVESnCkleD6x7yZbWofdMCrHjlJjE=";
}
else
null;
+45 -36
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.29.5",
"version": "0.30.0",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
@@ -10,11 +10,19 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:agent && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:tips": "node --test src/lib/tips.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs src/lib/schedule-layout.test.mjs",
"test:cookie-bot-outcomes": "node --test src/lib/cookie-bot-outcomes.test.mjs",
"test:agent": "node --test src/lib/agent.test.mjs",
"test:backend-errors": "node --test src/lib/backend-errors.test.mjs",
"test:i18n-parity": "node --test src/lib/i18n-parity.test.mjs",
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
"test:proxy-type": "node --test src/lib/proxy-type.test.mjs",
"test:proxy-first-hop-claims": "node --test src/lib/proxy-first-hop-claims.test.mjs",
"test:profile-search": "node --test src/lib/profile-search.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
"licenses:generate": "node scripts/generate-licenses.mjs",
@@ -50,64 +58,65 @@
"precargo": "pnpm copy-proxy-binary"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-portal": "^1.1.13",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-portal": "^1.1.17",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.5",
"@tanstack/react-virtual": "^3.14.10",
"@tauri-apps/api": "~2.11.1",
"@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-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "~2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"aria-hidden": "1.2.6",
"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.3.4",
"lucide-react": "^1.23.0",
"motion": "^12.42.2",
"next": "^16.2.11",
"i18next": "^26.4.0",
"lucide-react": "^1.34.0",
"motion": "^13.1.1",
"next": "^16.3.3",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.6.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.12",
"react-icons": "^5.7.0",
"recharts": "3.9.2",
"sonner": "^2.0.7",
"recharts": "3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.2",
"@tailwindcss/postcss": "^4.3.2",
"@biomejs/biome": "2.5.10",
"@tailwindcss/postcss": "^4.3.3",
"@tauri-apps/cli": "~2.11.4",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"husky": "^9.1.7",
"lint-staged": "^17.0.8",
"lint-staged": "^17.3.0",
"spdx-expression-parse": "5.0.0",
"tailwindcss": "^4.3.2",
"tailwindcss": "^4.3.3",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
+2143 -2572
View File
File diff suppressed because it is too large Load Diff
+5 -58
View File
@@ -26,14 +26,15 @@ overrides:
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.5: '>=3.1.5 <4'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
qs@<6.16.0: '>=6.16.0'
js-cookie@<3.0.7: '>=3.0.7'
nanoid@<3.3.17: '>=3.3.17 <4'
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
multer@>=2.0.0 <2.2.0: '>=2.2.0'
multer@>=2.0.0 <2.3.0: '>=2.3.0'
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
js-yaml@<3.15.1: '>=3.15.1 <4'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
js-yaml@<3.15.2: '>=3.15.2 <4'
js-yaml@>=4.0.0 <4.3.2: '>=4.3.2 <5'
browserslist@<4.28.7: '>=4.28.7'
'@babel/core@<7.29.6': '>=7.29.6 <8'
brace-expansion@<5.0.9: 5.0.9
sharp@<0.35.0: '>=0.35.0 <0.36'
@@ -43,59 +44,5 @@ allowBuilds:
sharp: true
unrs-resolver: true
minimumReleaseAgeExclude:
- '@radix-ui/primitive@1.1.5'
- '@radix-ui/react-accordion@1.2.16'
- '@radix-ui/react-alert-dialog@1.1.19'
- '@radix-ui/react-avatar@1.2.2'
- '@radix-ui/react-checkbox@1.3.7'
- '@radix-ui/react-collapsible@1.1.16'
- '@radix-ui/react-collection@1.1.12'
- '@radix-ui/react-context-menu@2.3.3'
- '@radix-ui/react-context@1.2.0'
- '@radix-ui/react-dialog@1.1.19'
- '@radix-ui/react-dismissable-layer@1.1.15'
- '@radix-ui/react-dropdown-menu@2.1.20'
- '@radix-ui/react-focus-scope@1.1.12'
- '@radix-ui/react-form@0.1.12'
- '@radix-ui/react-hover-card@1.1.19'
- '@radix-ui/react-menu@2.1.20'
- '@radix-ui/react-menubar@1.1.20'
- '@radix-ui/react-navigation-menu@1.2.18'
- '@radix-ui/react-one-time-password-field@0.1.12'
- '@radix-ui/react-password-toggle-field@0.1.7'
- '@radix-ui/react-popover@1.1.19'
- '@radix-ui/react-popper@1.3.3'
- '@radix-ui/react-presence@1.1.7'
- '@radix-ui/react-progress@1.1.12'
- '@radix-ui/react-radio-group@1.4.3'
- '@radix-ui/react-roving-focus@1.1.15'
- '@radix-ui/react-scroll-area@1.2.14'
- '@radix-ui/react-select@2.3.3'
- '@radix-ui/react-slider@1.4.3'
- '@radix-ui/react-switch@1.3.3'
- '@radix-ui/react-tabs@1.1.17'
- '@radix-ui/react-toast@1.2.19'
- '@radix-ui/react-toggle-group@1.1.15'
- '@radix-ui/react-toggle@1.1.14'
- '@radix-ui/react-toolbar@1.1.15'
- '@radix-ui/react-tooltip@1.2.12'
- radix-ui@1.6.2
- '@aws-sdk/checksums@3.1000.14'
- '@aws-sdk/client-s3@3.1081.0'
- '@aws-sdk/core@3.974.29'
- '@aws-sdk/credential-provider-env@3.972.55'
- '@aws-sdk/credential-provider-http@3.972.57'
- '@aws-sdk/credential-provider-ini@3.972.62'
- '@aws-sdk/credential-provider-login@3.972.61'
- '@aws-sdk/credential-provider-node@3.972.64'
- '@aws-sdk/credential-provider-process@3.972.55'
- '@aws-sdk/credential-provider-sso@3.972.61'
- '@aws-sdk/credential-provider-web-identity@3.972.61'
- '@aws-sdk/middleware-sdk-s3@3.972.60'
- '@aws-sdk/nested-clients@3.997.29'
- '@aws-sdk/s3-request-presigner@3.1081.0'
- '@aws-sdk/token-providers@3.1081.0'
patchedDependencies:
brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch
+6 -2
View File
@@ -5,7 +5,11 @@ import { pathToFileURL } from "node:url";
const URL_PATTERN = /\b[a-z][a-z\d+.-]{1,20}:\/\/[^\s<>"'`]+/giu;
const PRIVATE_KEY_PATTERN =
/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/giu;
const BEARER_PATTERN = /\bBearer\s+[A-Za-z\d._~+/=-]+/giu;
// Mirrors AUTH_SCHEME_RE in src-tauri/src/log_redaction.rs: schemes whose
// credential is a bare token after the scheme name, which the assignment
// pattern below cannot match because its value class stops at the space.
const AUTH_SCHEME_PATTERN =
/\b(Bearer|Basic|Token|Digest|Negotiate|NTLM)\s+[A-Za-z\d._~+/=-]+/giu;
const SECRET_ASSIGNMENT_PATTERN =
/\b(?:api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(?:password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+/giu;
const JWT_PATTERN = /\beyJ[A-Za-z\d_-]+\.[A-Za-z\d_-]+\.[A-Za-z\d_-]+\b/gu;
@@ -66,7 +70,7 @@ export function redactSensitiveText(text, { sensitiveValues = [] } = {}) {
return redacted
.replace(PRIVATE_KEY_PATTERN, "<redacted-private-key>")
.replace(URL_PATTERN, safeUrlLabel)
.replace(BEARER_PATTERN, "Bearer <redacted-secret>")
.replace(AUTH_SCHEME_PATTERN, "$1 <redacted-secret>")
.replace(SECRET_ASSIGNMENT_PATTERN, "<redacted-secret>")
.replace(JWT_PATTERN, "<redacted-token>")
.replace(TOKEN_PATTERN, "<redacted-token>")
+11
View File
@@ -0,0 +1,11 @@
# Build and test artifacts for the two standalone SDK packages. Neither is part
# of the pnpm workspace, so they carry their own ignores rather than adding
# Python and npm noise to the repository root.
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
build/
dist/
node_modules/
+285
View File
@@ -0,0 +1,285 @@
# Donut Browser SDKs
Two thin clients for the REST API that Donut Browser serves on this machine:
[`python/`](python) (`donutbrowser`) and [`node/`](node) (`@donutbrowser/sdk`).
They are deliberately thin. Every method is one request to one path that the
app publishes in its own `/openapi.json`, with the request and response shapes
taken from the Rust handlers in `src-tauri/src/api_server.rs`. Nothing is
cached, nothing is retried, and no endpoint is invented. What the two add on top
of a bare HTTP call is the part that is tedious to redo in every script:
- the bearer token and the port, read from arguments or the environment,
- one exception class per documented status, with `Retry-After` parsed and the
app's `{"code": ...}` error bodies decoded,
- a launch-and-stop helper, so a script cannot leave a browser running,
- a drift check that fails the tests when the app grows an endpoint the SDK
does not cover.
Neither package is part of the pnpm workspace. They build, test and publish on
their own, so they never slow the desktop app's own checks down.
## Switch the API on first
**The local REST API is off by default. It must be enabled in the app under
Settings → Integrations → Local API → "Enable Local API Server".**
That screen also shows the two things a client needs:
- the **port**, `10108` unless it was already taken or you changed it, and
- the **authentication token**, sent as `Authorization: Bearer <token>`.
The server binds `127.0.0.1` only, so it is never reachable from another
machine. Requests are also refused with `403` until the Wayfern terms have been
accepted in the app.
Both SDKs read arguments first, then the environment:
| Setting | Argument | Environment | Default |
| --- | --- | --- | --- |
| Token | `token` | `DONUT_API_TOKEN` | none; required |
| Port | `port` | `DONUT_API_PORT` | `10108` |
| Host | `host` | — | `127.0.0.1` |
`base_url` / `baseUrl` overrides host and port entirely, for the rare case of a
tunnel or a path prefix in front of the app.
## Python
Requires Python 3.10 or newer. **No runtime dependencies:** the client talks to
a loopback server on the same machine, so `http.client` from the standard
library is enough. That keeps `pip install donutbrowser` from dragging anything
into an automation environment, and it sidesteps a real trap — `urllib.request`
honours `http_proxy` from the environment, which would send calls meant for the
local app through whatever proxy the shell happens to have set.
```bash
cd sdk/python
pip install -e .
```
A worked example: launch a profile, drive the page through the agent endpoints,
and stop the browser.
```python
from donutbrowser import Conflict, DonutClient, NotFound, RateLimited
PROFILE_ID = "your-profile-id"
with DonutClient(token="...") as client:
# `run` starts the browser on entry and stops it on exit, even if the body
# raises. `session.cdp_url` is the DevTools endpoint the launch returned.
with client.run(PROFILE_ID, url="https://example.com", headless=True) as session:
print("CDP:", session.cdp_url)
# Read the page the way the agent sees it: roles, names, text, bounds.
page = client.agent_perceive(PROFILE_ID, viewport_only=True)
print(page["stats"]["returnedNodes"], "nodes,", len(page["text"]), "characters")
# Name an element without a selector, and check it is unambiguous.
search = {"role": "textbox", "nameContains": "Search"}
resolved = client.agent_resolve_locator(PROFILE_ID, locator=search)
assert resolved["matchCount"] == 1
client.agent_type(PROFILE_ID, locator=search, text="donut browser")
client.agent_click(PROFILE_ID, locator={"role": "button", "name": "Search"})
# Pull a table out of whatever came back.
rows = client.agent_extract(
PROFILE_ID,
container={"role": "listitem"},
field_map=[
{"key": "title", "locator": {"role": "heading"}, "source": "text"},
{"key": "link", "locator": {"role": "link"}, "source": "link"},
],
max_pages=3,
)
for row in rows["rows"]:
print(row["values"])
# The browser is stopped here.
```
Errors are classes, not status codes:
```python
try:
client.run_profile(PROFILE_ID)
except Conflict as busy:
print("someone else has it:", busy.code) # PROFILE_LOCKED_BY_MEMBER, ...
except RateLimited as limited:
print("wait", limited.retry_after, "seconds")
except NotFound:
print("no such profile")
```
### Tests
```bash
cd sdk/python
pip install -e ".[dev]"
pytest
```
## Node
Requires Node 22 or newer, for the built-in `fetch`. **No runtime
dependencies**; `typescript` is a development dependency and is needed only to
build `dist/` for publishing. The tests run straight from the TypeScript
sources through Node's own type stripping, so `npm test` works with nothing
installed at all.
```bash
cd sdk/node
npm install # only needed for `npm run build`
npm run build
```
The convenience helper is `withProfile(profileId, options, work)`, a callback
rather than `await using`. `await using` is not yet syntax any released V8
understands, so TypeScript has to down-level it — which would stop the sources
running under Node's type stripping, and with it `npm test` on a clean
checkout. The callback form works on every Node 22. A `RunSession` does also
implement `Symbol.asyncDispose`, so `await using` is there for anyone whose
toolchain already handles it.
```ts
import { Conflict, DonutClient, NotFound, RateLimited } from "@donutbrowser/sdk";
const PROFILE_ID = "your-profile-id";
const client = new DonutClient({ token: "..." });
// The browser starts before `work` runs and is stopped after it, even when it
// throws. `session.cdpUrl` is the DevTools endpoint the launch returned.
const titles = await client.withProfile(
PROFILE_ID,
{ url: "https://example.com", headless: true },
async (session) => {
console.log("CDP:", session.cdpUrl);
const page = await client.agentPerceive(PROFILE_ID, { viewport_only: true });
console.log(page.stats.returnedNodes, "nodes,", page.text.length, "characters");
const search = { role: "textbox", nameContains: "Search" };
const resolved = await client.agentResolveLocator(PROFILE_ID, { locator: search });
if (resolved.matchCount !== 1) {
throw new Error("the search box is ambiguous");
}
await client.agentType(PROFILE_ID, { locator: search, text: "donut browser" });
await client.agentClick(PROFILE_ID, {
locator: { role: "button", name: "Search" },
});
const extraction = await client.agentExtract(PROFILE_ID, {
container: { role: "listitem" },
field_map: [
{ key: "title", locator: { role: "heading" }, source: "text" },
{ key: "link", locator: { role: "link" }, source: "link" },
],
max_pages: 3,
});
return extraction.rows.map((row) => row.values.title);
},
);
// The browser is stopped here.
try {
await client.runProfile(PROFILE_ID);
} catch (error) {
if (error instanceof Conflict) {
console.log("someone else has it:", error.code);
} else if (error instanceof RateLimited) {
console.log("wait", error.retryAfter, "seconds");
} else if (error instanceof NotFound) {
console.log("no such profile");
} else {
throw error;
}
}
```
### Tests
```bash
cd sdk/node
npm test
```
`npm test` runs the TypeScript sources directly, which needs Node 22.18 or
newer (type stripping is unflagged from that release). The published package
ships compiled `.mjs`, so consumers only need Node 22.
## Errors
Both packages map the app's documented statuses onto the same set of classes.
The 5xx classes share one base, so a single `ServerError` branch catches every
server-side failure.
| Status | Python | Node | Meaning |
| ---: | --- | --- | --- |
| 400 | `ValidationError` | `ValidationError` | Malformed request, duplicate name, unsupported input |
| 401 | `Unauthorized` | `Unauthorized` | Missing or wrong bearer token |
| 402 | `PaymentRequired` | `PaymentRequired` | Automation needs an active paid plan |
| 403 | `Forbidden` | `Forbidden` | Wayfern terms not accepted, or not signed in |
| 404 | `NotFound` | `NotFound` | No entity with that id |
| 408 | `RequestTimeout` | `RequestTimeout` | `agent/pick` waited and nothing was picked |
| 409 | `Conflict` | `Conflict` | A browser, a teammate or a remote session holds the profile |
| 429 | `RateLimited` | `RateLimited` | Automation quota spent; `retry_after` / `retryAfter` |
| 500 | `ServerError` | `ServerError` | Internal failure |
| 502 | `BadGateway` | `BadGateway` | The browser or the relay answered wrongly |
| 503 | `ServiceUnavailable` | `ServiceUnavailable` | Cloud, fleet or lock service unreachable |
Anything else becomes `DonutAPIError` / `DonutApiError` (a `ServerError` for an
unrecognised 5xx), so a status added to the app later still arrives as
something a caller can catch. A transport failure — the app not running, the
API switched off, the wrong port — is `DonutConnectionError`, never an API
error, so "Donut is not there" is never confused with "Donut said no".
Every error carries `status`, `body`, `method` and `path`. When the body is one
of the app's structured `{"code": ..., "params": {...}}` strings, `code` and
`params` are filled in too.
A `503` from stopping something means the fleet could not be reached and the
remote browser is **still running**, not that it stopped.
## Staying in step with the app
`api-paths.json` in this directory lists every operation the app publishes. It
is generated from the `#[utoipa::path]` annotations and the `ApiDoc` `paths(...)`
list in `src-tauri/src/api_server.rs` — the two things the served
`/openapi.json` is actually built from — and the generator fails if a handler is
annotated but missing from `ApiDoc`, which is exactly how an endpoint silently
disappears from the spec.
```bash
python3 sdk/tools/extract-api-paths.py
```
Each SDK keeps its own table of operation to method (`donutbrowser.coverage` and
`OPERATIONS` in the Node package), and both test suites hold that table against
the snapshot in **both** directions:
- an operation in the snapshot that the SDK neither wraps nor lists as omitted
fails the suite, so a new endpoint cannot slip past unnoticed;
- an entry the app no longer publishes fails too, so a removed endpoint cannot
linger as a dead method;
- every wrapped operation must name a method that really exists, no two
operations may claim the same method, and every omission must carry a reason.
On top of that, one parameterised test per method drives it against a fake
server and asserts the exact verb, path, query string and JSON body it sends.
That is what ties the table to reality rather than to a comment.
Of the 71 published operations, 70 are wrapped. The one omission:
- `GET /v1/remote-sessions/{id}/cdp` is a WebSocket upgrade, not a request an
HTTP client can make, and bundling a websocket implementation would end the
zero-dependency promise for one endpoint. `remote_session_cdp_url()` /
`remoteSessionCdpUrl()` builds the `ws://` address instead, so a websocket
library of your choosing can connect — send the same `Authorization: Bearer`
header on the handshake.
## Tests
Both suites run offline against a fake HTTP server on an ephemeral loopback
port. Neither needs the desktop app, a browser, a network, or credentials.
+363
View File
@@ -0,0 +1,363 @@
{
"source": "src-tauri/src/api_server.rs",
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
"description": "Every operation the desktop app publishes in its /openapi.json. The SDK test suites assert this list and their own coverage tables match exactly, so an endpoint added to the app fails the SDK tests until it is either wrapped or deliberately listed as omitted.",
"operation_count": 71,
"operations": [
{
"operation_id": "download_browser_api",
"method": "POST",
"path": "/v1/browsers/download"
},
{
"operation_id": "get_browser_versions",
"method": "GET",
"path": "/v1/browsers/{browser}/versions"
},
{
"operation_id": "check_browser_downloaded",
"method": "GET",
"path": "/v1/browsers/{browser}/versions/{version}/downloaded"
},
{
"operation_id": "get_cookie_bot_conflicts",
"method": "GET",
"path": "/v1/cookie-bot/conflicts"
},
{
"operation_id": "list_cookie_bot_presets",
"method": "GET",
"path": "/v1/cookie-bot/presets"
},
{
"operation_id": "list_cookie_bot_runs",
"method": "GET",
"path": "/v1/cookie-bot/runs"
},
{
"operation_id": "start_cookie_bot_run",
"method": "POST",
"path": "/v1/cookie-bot/runs"
},
{
"operation_id": "cancel_cookie_bot_run",
"method": "DELETE",
"path": "/v1/cookie-bot/runs/{run_id}"
},
{
"operation_id": "list_cookie_bot_schedules",
"method": "GET",
"path": "/v1/cookie-bot/schedules"
},
{
"operation_id": "delete_cookie_bot_schedule",
"method": "DELETE",
"path": "/v1/cookie-bot/schedules/{profile_id}"
},
{
"operation_id": "get_cookie_bot_schedule",
"method": "GET",
"path": "/v1/cookie-bot/schedules/{profile_id}"
},
{
"operation_id": "set_cookie_bot_schedule",
"method": "PUT",
"path": "/v1/cookie-bot/schedules/{profile_id}"
},
{
"operation_id": "get_cookie_bot_usage",
"method": "GET",
"path": "/v1/cookie-bot/usage"
},
{
"operation_id": "get_extension_groups",
"method": "GET",
"path": "/v1/extension-groups"
},
{
"operation_id": "create_extension_group_api",
"method": "POST",
"path": "/v1/extension-groups"
},
{
"operation_id": "delete_extension_group_api",
"method": "DELETE",
"path": "/v1/extension-groups/{id}"
},
{
"operation_id": "get_extension_group_api",
"method": "GET",
"path": "/v1/extension-groups/{id}"
},
{
"operation_id": "update_extension_group_api",
"method": "PUT",
"path": "/v1/extension-groups/{id}"
},
{
"operation_id": "remove_extension_from_group_api",
"method": "DELETE",
"path": "/v1/extension-groups/{id}/extensions/{extension_id}"
},
{
"operation_id": "add_extension_to_group_api",
"method": "POST",
"path": "/v1/extension-groups/{id}/extensions/{extension_id}"
},
{
"operation_id": "get_extensions",
"method": "GET",
"path": "/v1/extensions"
},
{
"operation_id": "create_extension_api",
"method": "POST",
"path": "/v1/extensions"
},
{
"operation_id": "delete_extension_api",
"method": "DELETE",
"path": "/v1/extensions/{id}"
},
{
"operation_id": "get_extension_api",
"method": "GET",
"path": "/v1/extensions/{id}"
},
{
"operation_id": "update_extension_api",
"method": "PUT",
"path": "/v1/extensions/{id}"
},
{
"operation_id": "get_groups",
"method": "GET",
"path": "/v1/groups"
},
{
"operation_id": "create_group",
"method": "POST",
"path": "/v1/groups"
},
{
"operation_id": "delete_group",
"method": "DELETE",
"path": "/v1/groups/{id}"
},
{
"operation_id": "get_group",
"method": "GET",
"path": "/v1/groups/{id}"
},
{
"operation_id": "update_group",
"method": "PUT",
"path": "/v1/groups/{id}"
},
{
"operation_id": "get_profiles",
"method": "GET",
"path": "/v1/profiles"
},
{
"operation_id": "create_profile",
"method": "POST",
"path": "/v1/profiles"
},
{
"operation_id": "batch_run_profiles",
"method": "POST",
"path": "/v1/profiles/batch/run"
},
{
"operation_id": "batch_stop_profiles",
"method": "POST",
"path": "/v1/profiles/batch/stop"
},
{
"operation_id": "distribute_proxies",
"method": "POST",
"path": "/v1/profiles/distribute-proxies"
},
{
"operation_id": "import_profiles_api",
"method": "POST",
"path": "/v1/profiles/import"
},
{
"operation_id": "detect_import_profiles",
"method": "GET",
"path": "/v1/profiles/import/detect"
},
{
"operation_id": "delete_profile",
"method": "DELETE",
"path": "/v1/profiles/{id}"
},
{
"operation_id": "get_profile",
"method": "GET",
"path": "/v1/profiles/{id}"
},
{
"operation_id": "update_profile",
"method": "PUT",
"path": "/v1/profiles/{id}"
},
{
"operation_id": "agent_click_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/click"
},
{
"operation_id": "agent_extract_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/extract"
},
{
"operation_id": "agent_perceive_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/perceive"
},
{
"operation_id": "agent_pick_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/pick"
},
{
"operation_id": "agent_resolve_locator_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/resolve-locator"
},
{
"operation_id": "agent_type_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/type"
},
{
"operation_id": "set_profile_cloud_sync",
"method": "POST",
"path": "/v1/profiles/{id}/cloud-sync"
},
{
"operation_id": "import_profile_cookies",
"method": "POST",
"path": "/v1/profiles/{id}/cookies/import"
},
{
"operation_id": "kill_profile",
"method": "POST",
"path": "/v1/profiles/{id}/kill"
},
{
"operation_id": "open_url_in_profile",
"method": "POST",
"path": "/v1/profiles/{id}/open-url"
},
{
"operation_id": "run_profile",
"method": "POST",
"path": "/v1/profiles/{id}/run"
},
{
"operation_id": "run_profile_remote",
"method": "POST",
"path": "/v1/profiles/{id}/run-remote"
},
{
"operation_id": "get_proxies",
"method": "GET",
"path": "/v1/proxies"
},
{
"operation_id": "create_proxy",
"method": "POST",
"path": "/v1/proxies"
},
{
"operation_id": "import_proxies_api",
"method": "POST",
"path": "/v1/proxies/import"
},
{
"operation_id": "delete_proxy",
"method": "DELETE",
"path": "/v1/proxies/{id}"
},
{
"operation_id": "get_proxy",
"method": "GET",
"path": "/v1/proxies/{id}"
},
{
"operation_id": "update_proxy",
"method": "PUT",
"path": "/v1/proxies/{id}"
},
{
"operation_id": "get_remote_hours",
"method": "GET",
"path": "/v1/remote-hours"
},
{
"operation_id": "list_remote_sessions_api",
"method": "GET",
"path": "/v1/remote-sessions"
},
{
"operation_id": "stop_remote_session",
"method": "DELETE",
"path": "/v1/remote-sessions/{id}"
},
{
"operation_id": "get_remote_session_api",
"method": "GET",
"path": "/v1/remote-sessions/{id}"
},
{
"operation_id": "remote_session_cdp",
"method": "GET",
"path": "/v1/remote-sessions/{id}/cdp"
},
{
"operation_id": "get_tags",
"method": "GET",
"path": "/v1/tags"
},
{
"operation_id": "get_vpns",
"method": "GET",
"path": "/v1/vpns"
},
{
"operation_id": "create_vpn",
"method": "POST",
"path": "/v1/vpns"
},
{
"operation_id": "import_vpn",
"method": "POST",
"path": "/v1/vpns/import"
},
{
"operation_id": "delete_vpn",
"method": "DELETE",
"path": "/v1/vpns/{id}"
},
{
"operation_id": "get_vpn",
"method": "GET",
"path": "/v1/vpns/{id}"
},
{
"operation_id": "update_vpn",
"method": "PUT",
"path": "/v1/vpns/{id}"
},
{
"operation_id": "export_vpn",
"method": "GET",
"path": "/v1/vpns/{id}/export"
}
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@donutbrowser/sdk",
"version": "0.1.0",
"description": "Thin client for the Donut Browser local REST API",
"license": "AGPL-3.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=22"
},
"scripts": {
"test": "node --test test/*.test.mts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"donut-browser",
"browser-automation",
"anti-detect",
"cdp"
],
"homepage": "https://donutbrowser.com",
"repository": {
"type": "git",
"url": "git+https://github.com/zhom/donutbrowser.git",
"directory": "sdk/node"
},
"devDependencies": {
"typescript": "^5.9.0"
}
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
/**
* Which app operation each client method wraps.
*
* This table is the SDK's half of a two-sided check. `sdk/api-paths.json` holds
* every operation the desktop app publishes, generated from
* `src-tauri/src/api_server.rs`. The test suite asserts the two agree exactly
* in both directions, so:
*
* - an endpoint added to the app fails the SDK tests until it is wrapped here,
* or listed in `OMITTED` with a reason, and
* - an entry here that the app no longer publishes fails too.
*
* The same table is mirrored in the Python package, and the same snapshot
* proves it.
*/
/** `"<VERB> <path template>"`, exactly as the app publishes it. */
export type OperationKey = string;
/** Operation to the name of the `DonutClient` method that calls it. */
export const OPERATIONS: ReadonlyMap<OperationKey, string> = new Map([
["POST /v1/browsers/download", "downloadBrowser"],
["GET /v1/browsers/{browser}/versions", "listBrowserVersions"],
["GET /v1/browsers/{browser}/versions/{version}/downloaded", "isBrowserDownloaded"],
["GET /v1/cookie-bot/conflicts", "getCookieBotConflicts"],
["GET /v1/cookie-bot/presets", "listCookieBotPresets"],
["GET /v1/cookie-bot/runs", "listCookieBotRuns"],
["POST /v1/cookie-bot/runs", "startCookieBotRun"],
["DELETE /v1/cookie-bot/runs/{run_id}", "cancelCookieBotRun"],
["GET /v1/cookie-bot/schedules", "listCookieBotSchedules"],
["DELETE /v1/cookie-bot/schedules/{profile_id}", "deleteCookieBotSchedule"],
["GET /v1/cookie-bot/schedules/{profile_id}", "getCookieBotSchedule"],
["PUT /v1/cookie-bot/schedules/{profile_id}", "setCookieBotSchedule"],
["GET /v1/cookie-bot/usage", "getCookieBotUsage"],
["GET /v1/extension-groups", "listExtensionGroups"],
["POST /v1/extension-groups", "createExtensionGroup"],
["DELETE /v1/extension-groups/{id}", "deleteExtensionGroup"],
["GET /v1/extension-groups/{id}", "getExtensionGroup"],
["PUT /v1/extension-groups/{id}", "updateExtensionGroup"],
["DELETE /v1/extension-groups/{id}/extensions/{extension_id}", "removeExtensionFromGroup"],
["POST /v1/extension-groups/{id}/extensions/{extension_id}", "addExtensionToGroup"],
["GET /v1/extensions", "listExtensions"],
["POST /v1/extensions", "createExtension"],
["DELETE /v1/extensions/{id}", "deleteExtension"],
["GET /v1/extensions/{id}", "getExtension"],
["PUT /v1/extensions/{id}", "updateExtension"],
["GET /v1/groups", "listGroups"],
["POST /v1/groups", "createGroup"],
["DELETE /v1/groups/{id}", "deleteGroup"],
["GET /v1/groups/{id}", "getGroup"],
["PUT /v1/groups/{id}", "updateGroup"],
["GET /v1/profiles", "listProfiles"],
["POST /v1/profiles", "createProfile"],
["POST /v1/profiles/batch/run", "batchRunProfiles"],
["POST /v1/profiles/batch/stop", "batchStopProfiles"],
["POST /v1/profiles/distribute-proxies", "distributeProxies"],
["POST /v1/profiles/import", "importProfiles"],
["GET /v1/profiles/import/detect", "detectImportProfiles"],
["DELETE /v1/profiles/{id}", "deleteProfile"],
["GET /v1/profiles/{id}", "getProfile"],
["PUT /v1/profiles/{id}", "updateProfile"],
["POST /v1/profiles/{id}/agent/click", "agentClick"],
["POST /v1/profiles/{id}/agent/extract", "agentExtract"],
["POST /v1/profiles/{id}/agent/perceive", "agentPerceive"],
["POST /v1/profiles/{id}/agent/pick", "agentPick"],
["POST /v1/profiles/{id}/agent/resolve-locator", "agentResolveLocator"],
["POST /v1/profiles/{id}/agent/type", "agentType"],
["POST /v1/profiles/{id}/cloud-sync", "setProfileCloudSync"],
["POST /v1/profiles/{id}/cookies/import", "importProfileCookies"],
["POST /v1/profiles/{id}/kill", "killProfile"],
["POST /v1/profiles/{id}/open-url", "openUrl"],
["POST /v1/profiles/{id}/run", "runProfile"],
["POST /v1/profiles/{id}/run-remote", "runProfileRemote"],
["GET /v1/proxies", "listProxies"],
["POST /v1/proxies", "createProxy"],
["POST /v1/proxies/import", "importProxies"],
["DELETE /v1/proxies/{id}", "deleteProxy"],
["GET /v1/proxies/{id}", "getProxy"],
["PUT /v1/proxies/{id}", "updateProxy"],
["GET /v1/remote-hours", "getRemoteHours"],
["GET /v1/remote-sessions", "listRemoteSessions"],
["DELETE /v1/remote-sessions/{id}", "stopRemoteSession"],
["GET /v1/remote-sessions/{id}", "getRemoteSession"],
["GET /v1/tags", "listTags"],
["GET /v1/vpns", "listVpns"],
["POST /v1/vpns", "createVpn"],
["POST /v1/vpns/import", "importVpn"],
["DELETE /v1/vpns/{id}", "deleteVpn"],
["GET /v1/vpns/{id}", "getVpn"],
["PUT /v1/vpns/{id}", "updateVpn"],
["GET /v1/vpns/{id}/export", "exportVpn"],
]);
/** Operations this SDK deliberately does not call, and why. */
export const OMITTED: ReadonlyMap<OperationKey, string> = new Map([
[
"GET /v1/remote-sessions/{id}/cdp",
"A WebSocket upgrade, not a request. fetch() cannot speak it, and bundling a " +
"websocket implementation would end this package's zero-dependency promise for " +
"one endpoint. DonutClient.remoteSessionCdpUrl() builds the ws:// address so a " +
"websocket library of the caller's choosing can connect, sending the same " +
"Authorization: Bearer header on the handshake.",
],
]);
+211
View File
@@ -0,0 +1,211 @@
/**
* Exceptions thrown by the Donut Browser SDK.
*
* The local REST API answers with a plain-text body and one of a small set of
* statuses. Each status means one thing, so each gets its own class and a
* caller can branch on `instanceof` instead of on a number:
*
* | Status | Class | Meaning |
* | -----: | --------------------- | ----------------------------------------- |
* | 400 | `ValidationError` | Malformed request, duplicate name |
* | 401 | `Unauthorized` | Missing or wrong bearer token |
* | 402 | `PaymentRequired` | Automation needs an active paid plan |
* | 403 | `Forbidden` | Terms not accepted, or not signed in |
* | 404 | `NotFound` | No such profile, group, proxy, ... |
* | 408 | `RequestTimeout` | `agent/pick` waited and nothing was picked |
* | 409 | `Conflict` | Something else holds the profile |
* | 429 | `RateLimited` | Quota spent; see `retryAfter` |
* | 500 | `ServerError` | Internal failure |
* | 502 | `BadGateway` | The browser or relay answered wrongly |
* | 503 | `ServiceUnavailable` | Cloud, fleet or lock service unreachable |
*
* Some bodies are the structured `{"code": ..., "params": {...}}` strings the
* desktop app shares with its own frontend. When one arrives, `code` and
* `params` are filled in; otherwise `code` is `null` and `body` holds the
* diagnostic text as sent.
*/
/** Base class for everything this package throws. */
export class DonutError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
}
}
/**
* The app could not be reached at all.
*
* Usually means the local API is switched off, is listening on another port,
* or the desktop app is not running.
*/
export class DonutConnectionError extends DonutError {}
export interface DonutApiErrorInit {
method?: string;
path?: string;
headers?: Headers | Record<string, string>;
}
/** The app answered, and the answer was an error status. */
export class DonutApiError extends DonutError {
status: number;
body: string;
method: string;
path: string;
headers: Record<string, string>;
/** The `code` of a structured `{"code": ...}` body, else `null`. */
code: string | null;
/** The `params` of a structured body, else an empty object. */
params: Record<string, unknown>;
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
const method = init.method ?? "";
const path = init.path ?? "";
const headers = normaliseHeaders(init.headers);
let code: string | null = null;
let params: Record<string, unknown> = {};
const trimmed = body.trim();
if (trimmed.startsWith("{")) {
try {
const decoded: unknown = JSON.parse(trimmed);
if (decoded !== null && typeof decoded === "object") {
const record = decoded as Record<string, unknown>;
if (typeof record.code === "string") {
code = record.code;
if (record.params !== null && typeof record.params === "object") {
params = record.params as Record<string, unknown>;
}
}
}
} catch {
// Not JSON after all; the plain text below is the whole story.
}
}
const where = `${method} ${path}`.trim();
const detail = code ?? (trimmed || "(empty body)");
super(where ? `${status} on ${where}: ${detail}` : `${status}: ${detail}`);
this.status = status;
this.body = body;
this.method = method;
this.path = path;
this.headers = headers;
this.code = code;
this.params = params;
}
}
/** 400: the request was malformed, duplicated a name, or named something unsupported. */
export class ValidationError extends DonutApiError {}
/** 401: no bearer token, the wrong one, or the local API has no token stored. */
export class Unauthorized extends DonutApiError {}
/** 402: this action needs an active paid plan, or the proxy behind it lapsed. */
export class PaymentRequired extends DonutApiError {}
/** 403: the Wayfern terms are not accepted, or this desktop is not signed in. */
export class Forbidden extends DonutApiError {}
/** 404: no entity with that id. */
export class NotFound extends DonutApiError {}
/** 408: `agentPick` waited its whole timeout and nothing was picked. */
export class RequestTimeout extends DonutApiError {}
/** 409: something else holds the profile — a browser, a teammate, a remote session. */
export class Conflict extends DonutApiError {}
/**
* 500 and the other 5xx: the app, the fleet or an upstream failed.
*
* `BadGateway` and `ServiceUnavailable` extend this, so one
* `instanceof ServerError` covers every server-side failure.
*/
export class ServerError extends DonutApiError {}
/** 502: the browser or the relay did not answer the way it documents. */
export class BadGateway extends ServerError {}
/**
* 503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
*
* Whatever was running keeps running: a 503 from `killProfile` or from stopping
* a remote session means the browser is still up, not that it stopped.
*/
export class ServiceUnavailable extends ServerError {}
/**
* 429: the shared automation quota is spent.
*
* `retryAfter` is the number of seconds the server asked the caller to wait,
* taken from the `Retry-After` response header. It is `null` only when the
* header is missing or unreadable.
*/
export class RateLimited extends DonutApiError {
retryAfter: number | null;
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
super(status, body, init);
const raw = this.headers["retry-after"];
const seconds = raw === undefined ? Number.NaN : Number.parseInt(raw.trim(), 10);
this.retryAfter = Number.isFinite(seconds) ? seconds : null;
}
}
function normaliseHeaders(
headers: Headers | Record<string, string> | undefined,
): Record<string, string> {
const result: Record<string, string> = {};
if (headers === undefined) {
return result;
}
if (typeof (headers as Headers).forEach === "function" && !Array.isArray(headers)) {
(headers as Headers).forEach((value, key) => {
result[key.toLowerCase()] = value;
});
return result;
}
for (const [key, value] of Object.entries(headers as Record<string, string>)) {
result[key.toLowerCase()] = value;
}
return result;
}
const BY_STATUS = new Map<number, typeof DonutApiError>([
[400, ValidationError],
[401, Unauthorized],
[402, PaymentRequired],
[403, Forbidden],
[404, NotFound],
[408, RequestTimeout],
[409, Conflict],
[429, RateLimited],
[500, ServerError],
[502, BadGateway],
[503, ServiceUnavailable],
]);
/**
* Build the error that belongs to `status`.
*
* A status with no class of its own becomes a plain `DonutApiError`, so a
* future status added to the app still throws something a caller can catch
* rather than escaping as a decode failure.
*/
export function errorForStatus(
status: number,
body: string,
init: DonutApiErrorInit = {},
): DonutApiError {
const known = BY_STATUS.get(status);
if (known !== undefined) {
return new known(status, body, init);
}
return status >= 500
? new ServerError(status, body, init)
: new DonutApiError(status, body, init);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Donut Browser SDK: a thin client for the app's local REST API.
*
* The local API is off by default. Switch it on in the app under **Settings,
* Integrations, Local API, "Enable Local API Server"**, and copy the port and
* the authentication token from that screen.
*
* ```ts
* import { DonutClient } from "@donutbrowser/sdk";
*
* const client = new DonutClient({ token: "..." });
* await client.withProfile(profileId, { url: "https://example.com" }, async (session) => {
* console.log(session.cdpUrl);
* await client.agentClick(profileId, { locator: { role: "button", name: "Sign in" } });
* });
* ```
*/
export { DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession } from "./client.mts";
export type { DonutClientOptions, RunProfileOptions } from "./client.mts";
export { OMITTED, OPERATIONS } from "./coverage.mts";
export type { OperationKey } from "./coverage.mts";
export {
BadGateway,
Conflict,
DonutApiError,
DonutConnectionError,
DonutError,
errorForStatus,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
} from "./errors.mts";
export type { DonutApiErrorInit } from "./errors.mts";
export type * from "./types.mts";
+634
View File
@@ -0,0 +1,634 @@
/**
* Response shapes, spelled exactly the way the local API sends them.
*
* Every interface here mirrors a `ToSchema` struct in `src-tauri` field for
* field. A Rust `Option<T>` becomes an optional property.
*
* Two spellings live side by side because the app sends both. Most bodies are
* snake_case; the browser-facing agent types (`LocatorDescription`,
* `LocatorCandidate`, `PerceptionPage` and friends) carry the browser's own
* camelCase, because they are handed through from the browser rather than
* restated. `AgentClick` and `AgentTyping` are the exceptions inside the agent
* surface: they are snake_case with a single `match` key. These types follow
* the wire rather than tidying it, so a value read from one call can be passed
* straight into the next.
*/
/** The app's own JSON for a proxy's settings, declared `Object` in the spec. */
export type ProxySettings = Record<string, unknown>;
/** A Wayfern fingerprint/config blob, also declared `Object` in the spec. */
export type WayfernConfig = Record<string, unknown>;
/** Which implementation answered: the browser's native domains, or the fallback. */
export type Engine = "wayfern" | "fallback";
export interface ApiProfile {
id: string;
name: string;
browser: string;
version: string;
proxy_id?: string | null;
launch_hook?: string | null;
process_id?: number | null;
last_launch?: number | null;
release_type: string;
group_id?: string | null;
tags: string[];
is_running: boolean;
proxy_bypass_rules: string[];
vpn_id?: string | null;
extension_group_id?: string | null;
ephemeral: boolean;
temporary: boolean;
clear_on_close: boolean;
/** `"Disabled"`, `"Regular"` or `"Encrypted"`. */
sync_mode: string;
cloud_sync_enabled: boolean;
host_os?: string | null;
/** A profile from another OS can only ever run on a remote host of that OS. */
is_cross_os: boolean;
fingerprint_os?: string | null;
}
export interface ApiProfilesResponse {
profiles: ApiProfile[];
total: number;
}
export interface ApiProfileResponse {
profile: ApiProfile;
}
export interface ApiGroupResponse {
id: string;
name: string;
profile_count: number;
}
export interface ApiProxyResponse {
id: string;
name: string;
proxy_settings: ProxySettings;
}
export interface ApiVpnResponse {
id: string;
name: string;
/** Always `"WireGuard"`. */
vpn_type: string;
created_at: number;
last_used?: number | null;
}
export interface ApiVpnExportResponse {
id: string;
name: string;
vpn_type: string;
/** Raw, decrypted `.conf` content. Treat it as a secret. */
config_data: string;
}
export interface DownloadBrowserResponse {
browser: string;
version: string;
status: string;
}
export interface RunProfileResponse {
profile_id: string;
remote_debugging_port: number;
headless: boolean;
}
export interface RunRemoteResponse {
profile_id: string;
session_id: string;
/** Always the profile's own operating system. */
platform: string;
status: string;
}
export interface StopRemoteResponse {
session_id: string;
status: string;
billed_seconds: number;
}
export interface SetCloudSyncResponse {
profile_id: string;
mode: string;
remote_launchable: boolean;
remote_blocked_reason?: string | null;
}
export interface RemoteSessionState {
session_id: string;
profile_id?: string | null;
platform?: string | null;
/** `provisioning` | `ready` | `live` | `closed` | `error`. */
state: string;
cdp_ready?: boolean;
/** `interactive` or `cookie_bot`. */
kind?: string | null;
run_id?: string | null;
team_id?: string | null;
started_at?: string | null;
ended_at?: string | null;
close_reason?: string | null;
billed_seconds?: number | null;
}
export interface ApiRemoteSessionsResponse {
sessions: RemoteSessionState[];
}
export interface RemoteHoursBreakdown {
interactive_hours?: number;
bot_hours?: number;
}
export interface RemoteHoursMember {
user_id: string;
email: string;
role?: string | null;
used_hours?: number;
interactive_hours?: number;
bot_hours?: number;
}
export interface RemoteHoursQuota {
granted_hours: number;
remaining_hours: number;
used_hours?: number;
period_start?: string | null;
period_end?: string | null;
/** `user` or `team`. */
scope?: string | null;
team_id?: string | null;
seats?: number;
per_seat_hours?: number;
breakdown?: RemoteHoursBreakdown | null;
members?: RemoteHoursMember[];
}
export interface CookieBotSlot {
run_at_minute?: number;
days_mask?: number;
}
export interface CookieBotSchedule {
profile_id: string;
profile_name: string;
platform: string;
enabled: boolean;
run_at_minute: number;
days_mask: number;
/**
* Every time-of-day this enrolment fires. An older server sends only the
* mirrored `run_at_minute`/`days_mask` pair above, so an empty list means
* "fall back to the pair", never "fires at no time".
*/
slots?: CookieBotSlot[];
timezone: string;
preset: string;
template_id?: string | null;
max_minutes: number;
sites?: string[];
jitter_seconds?: number;
sync_enabled?: boolean;
encrypted_sync?: boolean;
has_proxy?: boolean;
proxy_remote_reachable?: boolean;
touch_fingerprint?: boolean;
sticky_exit?: boolean;
profile_state_at?: string | null;
/** Why tonight would be refused, or absent. */
blocked_by?: string | null;
next_run_at?: string | null;
last_run_at?: string | null;
last_run_id?: string | null;
owner_user_id?: string | null;
owner_email?: string | null;
updated_at?: string | null;
}
export interface CookieBotScheduleList {
schedules?: CookieBotSchedule[];
team_id?: string | null;
scope?: string | null;
}
export interface CookieBotConflict {
user_id: string;
email: string;
run_at_minute: number;
timezone: string;
days_mask: number;
enabled: boolean;
overlaps?: boolean;
}
export interface CookieBotScheduleSaved {
schedule: CookieBotSchedule;
conflicts?: CookieBotConflict[];
}
export interface CookieBotConflictCheck {
profile_id: string;
conflicts?: CookieBotConflict[];
}
export interface CookieBotScheduleDeleted {
profile_id: string;
deleted: boolean;
}
export interface CookieBotRun {
id: string;
profile_id: string;
profile_name?: string | null;
user_id?: string | null;
email?: string | null;
team_id?: string | null;
/** `schedule` or `manual`. */
trigger: string;
/** `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` | `cancelled`. */
status: string;
scheduled_for: string;
dispatch_after?: string | null;
started_at?: string | null;
ended_at?: string | null;
max_minutes?: number;
chunks_total?: number;
chunk_index?: number;
sites_total?: number;
sites_visited?: number;
sites_failed?: number;
consent_dismissed?: number;
billed_seconds?: number;
outcome_code?: string | null;
session_id?: string | null;
}
export interface CookieBotRunPage {
runs?: CookieBotRun[];
/** Keyset cursor; absent on the last page. */
next_before?: string | null;
}
export interface CookieBotRunStarted {
run: CookieBotRun;
session_id?: string | null;
}
export interface CookieBotPreset {
id: string;
typical_minutes?: number | null;
recommended?: boolean;
name?: string | null;
description?: string | null;
}
export interface CookieBotPresetList {
presets?: CookieBotPreset[];
default_preset?: string | null;
/** Whatever the server publishes; the app forwards it without narrowing. */
templates?: Record<string, unknown>[];
limits?: Record<string, unknown> | null;
}
export interface CookieBotUsageMember {
user_id: string;
email: string;
role?: string | null;
interactive_hours?: number;
bot_hours?: number;
used_hours?: number;
sessions?: number;
bot_runs?: number;
bot_runs_failed?: number;
}
export interface CookieBotUsageProfile {
profile_id: string;
profile_name?: string | null;
owner_email?: string | null;
bot_hours?: number;
runs?: number;
runs_failed?: number;
last_run_at?: string | null;
last_status?: string | null;
}
export interface CookieBotUsage {
period: string;
period_start?: string | null;
period_end?: string | null;
team_id?: string | null;
seats?: number;
granted_hours?: number;
used_hours?: number;
remaining_hours?: number;
members?: CookieBotUsageMember[];
profiles?: CookieBotUsageProfile[];
}
export interface BatchRunResult {
profile_id: string;
ok: boolean;
remote_debugging_port?: number | null;
error?: string | null;
}
export interface BatchRunResponse {
results: BatchRunResult[];
}
export interface BatchStopResult {
profile_id: string;
ok: boolean;
error?: string | null;
}
export interface BatchStopResponse {
results: BatchStopResult[];
}
/** One profile, one proxy. The distribution applies exactly these pairs. */
export interface ProxyPair {
profile_id: string;
proxy_id: string;
}
export interface ProxyAssignmentResult {
profile_id: string;
proxy_id: string;
ok: boolean;
/** A `{"code": ...}` payload when `ok` is false, otherwise null. */
error?: string | null;
}
export interface DistributeProxiesResponse {
results: ProxyAssignmentResult[];
}
export interface ImportCookiesResponse {
cookies_imported: number;
cookies_replaced: number;
errors: string[];
}
export interface ImportProxiesResponse {
imported_count: number;
skipped_count: number;
errors: string[];
proxies: ApiProxyResponse[];
}
export interface DetectedProfile {
browser: string;
mapped_browser: string;
name: string;
path: string;
description: string;
}
export interface DetectedProfilesResponse {
profiles: DetectedProfile[];
total: number;
}
export interface ImportProfileItem {
source_path: string;
/**
* The source browser family (`chromium`, `brave`, `edge`, ...). Load-bearing:
* it picks which keychain entry unlocks the source's cookies and passwords.
*/
browser_type?: string;
new_profile_name: string;
proxy_id?: string | null;
vpn_id?: string | null;
allow_running?: boolean | null;
}
export interface ProfileImportItemResult {
name: string;
source_path: string;
/** `"imported"` | `"skipped"` | `"failed"`. */
status: string;
profile_id?: string | null;
error?: string | null;
report?: Record<string, unknown> | null;
}
export interface ProfileImportBatchResult {
imported_count: number;
skipped_count: number;
failed_count: number;
results: ProfileImportItemResult[];
}
export interface Extension {
id: string;
name: string;
manifest_name?: string | null;
file_name: string;
file_type: string;
browser_compatibility: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number | null;
version?: string | null;
description?: string | null;
author?: string | null;
homepage_url?: string | null;
/** `archive` or `unpacked`. */
source_kind: string;
/** Set when the extension is loaded from a folder in place. Never synced. */
linked_path?: string | null;
}
export interface ExtensionGroup {
id: string;
name: string;
extension_ids: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number | null;
}
export interface LocatorAttribute {
name: string;
value: string;
}
/**
* How an element is named without a CSS selector.
*
* At least one property must be set. Keys are the browser's own camelCase; the
* app also accepts `name_contains` and `text_contains` on input, but a locator
* handed back by `agentPick` uses the spellings below, so reusing one verbatim
* is the reliable path.
*/
export interface LocatorDescription {
/** AX role token, matched case- and separator-insensitively. */
role?: string;
/** Computed accessible name, exact after whitespace collapse. */
name?: string;
nameContains?: string;
/** Visible text content, from the live layout. */
text?: string;
textContains?: string;
attributes?: LocatorAttribute[];
}
export interface LocatorBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface LocatorCandidate {
/** Absent on the fallback engine, which has no DOM agent behind it. */
backendNodeId?: number;
role: string;
name: string;
text: string;
/** Omitted, never blanked, for a control the page marked protected. */
value?: string;
url?: string;
/** Per-profile deterministic identifier for the node's structural position. */
signature: string;
attributes?: LocatorAttribute[];
bounds: LocatorBounds;
}
export interface LocatorResolution {
backendNodeId?: number;
/** Always 1: present so a caller can assert it rather than infer it. */
matchCount: number;
match: LocatorCandidate;
locator: LocatorDescription;
engine: Engine;
}
export interface PerceptionNode {
/** Short, stable, frame-qualified handle. */
id: string;
frameId: string;
role: string;
x: number;
y: number;
width: number;
height: number;
inViewport: boolean;
visible: boolean;
focused: boolean;
disabled: boolean;
parentId?: string;
name?: string;
text?: string;
value?: string;
/** `"true"`, `"false"` or `"mixed"`; absent for anything not checkable. */
checked?: string;
expanded?: boolean;
scrollable?: boolean;
scrollContainerId?: string;
}
export interface PerceptionFrame {
frameId: string;
url: string;
crossOrigin: boolean;
parentFrameId?: string;
}
export interface PerceptionStats {
totalNodes: number;
returnedNodes: number;
bytes: number;
elapsedMs: number;
framesVisited: number;
/** Frames whose renderer did not answer within the budget. */
framesFailed: number;
}
export interface PerceptionPage {
snapshotId: string;
nodes: PerceptionNode[];
frames: PerceptionFrame[];
/** Readable text for exactly the nodes returned. */
text: string;
truncated: boolean;
stats: PerceptionStats;
/** Present when `truncated`: pass it back to continue. */
cursor?: string;
engine: Engine;
}
export interface ExtractionField {
/** The key this column appears under in each row's values. */
key: string;
/** Evaluated inside each container; the first match wins. */
locator: LocatorDescription;
/** `"text"`, `"attribute"` or `"link"`. */
source: string;
/** Required when `source` is `"attribute"`. */
attribute?: string;
}
export interface ExtractionRow {
/** Global across pages. */
index: number;
/** Zero-based page this row came from. */
page: number;
values: Record<string, unknown>;
}
export interface Extraction {
rows: ExtractionRow[];
rowCount: number;
pageCount: number;
byteSize: number;
truncated: boolean;
/**
* `complete` | `no-container` | `no-next` | `page-cap` | `row-cap` |
* `byte-cap` | `time-budget`. A missing container is `no-container`, not an
* error.
*/
stopReason: string;
engine: Engine;
}
export interface PickedElement {
backendNodeId: number;
/** The smallest description that still resolves to this node. */
locator: LocatorDescription;
matchCount: number;
node: LocatorCandidate;
engine: Engine;
}
/** What a click did. Note the snake_case body and the `match` key. */
export interface AgentClick {
clicked: boolean;
match: LocatorCandidate;
engine: Engine;
/** Whether a page load followed the click. */
navigated: boolean;
}
/** What a typing call did. */
export interface AgentTyping {
typed: boolean;
characters: number;
/** Absent on the fallback engine, which does not count its own mistypes. */
corrections?: number;
duration_ms: number;
engine: Engine;
match: LocatorCandidate;
}
+109
View File
@@ -0,0 +1,109 @@
/** Where the token and the port come from, and in what order. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError } from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
test("arguments are used as given", () => {
const client = new DonutClient({ token: "from-argument", port: 12345, env: {} });
assert.equal(client.token, "from-argument");
assert.equal(client.port, 12345);
assert.equal(client.host, DEFAULT_HOST);
assert.equal(client.baseUrl, "http://127.0.0.1:12345");
});
test("the environment fills in what was not passed", () => {
const client = new DonutClient({
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "13579" },
});
assert.equal(client.token, "from-env");
assert.equal(client.port, 13579);
});
test("arguments win over the environment", () => {
const client = new DonutClient({
token: "from-argument",
port: 111,
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "222" },
});
assert.equal(client.token, "from-argument");
assert.equal(client.port, 111);
});
test("the port falls back to the app default", () => {
const client = new DonutClient({ env: { DONUT_API_TOKEN: "t" } });
assert.equal(client.port, DEFAULT_PORT);
assert.equal(DEFAULT_PORT, 10108);
});
test("a baseUrl overrides host and port", () => {
const client = new DonutClient({
baseUrl: "http://127.0.0.1:9999/donut",
token: "t",
env: { DONUT_API_PORT: "222" },
});
assert.equal(client.port, 9999);
assert.equal(client.baseUrl, "http://127.0.0.1:9999/donut");
});
test("a baseUrl prefix is kept on every path", async () => {
const fake = await new FakeDonut().start();
try {
const client = new DonutClient({
baseUrl: `http://127.0.0.1:${fake.port}/donut`,
token: "t",
timeoutMs: 5_000,
env: {},
});
await client.listProfiles();
assert.equal(fake.last.path, "/donut/v1/profiles");
} finally {
await fake.stop();
}
});
test("an unusable port in the environment is reported", () => {
assert.throws(
() => new DonutClient({ env: { DONUT_API_TOKEN: "t", DONUT_API_PORT: "not-a-number" } }),
/DONUT_API_PORT/,
);
});
test("an unsupported scheme is refused", () => {
assert.throws(
() => new DonutClient({ baseUrl: "ftp://127.0.0.1:9999", token: "t", env: {} }),
DonutError,
);
});
test("the websocket address is built from the same base", () => {
const client = new DonutClient({ token: "t", port: 10108, env: {} });
assert.equal(
client.remoteSessionCdpUrl("s 1"),
"ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp",
);
});
test("an https base gives a wss websocket address", () => {
const client = new DonutClient({ baseUrl: "https://127.0.0.1:8443", token: "t", env: {} });
assert.equal(
client.remoteSessionCdpUrl("s1"),
"wss://127.0.0.1:8443/v1/remote-sessions/s1/cdp",
);
});
test("a supplied fetch is the one that is used", async () => {
const seen: string[] = [];
const client = new DonutClient({
token: "t",
env: {},
fetch: async (input) => {
seen.push(String(input));
return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } });
},
});
assert.deepEqual(await client.listTags(), []);
assert.deepEqual(seen, ["http://127.0.0.1:10108/v1/tags"]);
});
+97
View File
@@ -0,0 +1,97 @@
/**
* The SDK cannot silently drift from the app's API.
*
* `sdk/api-paths.json` is generated from `src-tauri/src/api_server.rs` and
* lists every operation the desktop app publishes. These tests hold it against
* the SDK's own table in both directions, so a new endpoint in the app fails
* here until it is wrapped or deliberately omitted with a reason.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { DonutClient, OMITTED, OPERATIONS } from "../src/index.mts";
const SNAPSHOT = fileURLToPath(new URL("../../api-paths.json", import.meta.url));
interface Snapshot {
source: string;
operation_count: number;
operations: { operation_id: string; method: string; path: string }[];
}
function snapshot(): Snapshot {
return JSON.parse(readFileSync(SNAPSHOT, "utf8")) as Snapshot;
}
function published(): Set<string> {
return new Set(snapshot().operations.map((entry) => `${entry.method} ${entry.path}`));
}
test("the snapshot is readable and not empty", () => {
const document = snapshot();
assert.equal(document.source, "src-tauri/src/api_server.rs");
assert.equal(document.operation_count, document.operations.length);
assert.ok(document.operation_count > 0);
assert.equal(
published().size,
document.operation_count,
"the app has two identical operations",
);
});
test("every published operation is wrapped or omitted", () => {
const known = new Set([...OPERATIONS.keys(), ...OMITTED.keys()]);
const missing = [...published()].filter((key) => !known.has(key)).sort();
assert.deepEqual(
missing,
[],
`the app publishes operations this SDK does not handle: ${missing.join(", ")}. ` +
"Wrap each one, or add it to OMITTED with a reason.",
);
});
test("the SDK claims nothing the app does not publish", () => {
const live = published();
const stale = [...OPERATIONS.keys(), ...OMITTED.keys()].filter((key) => !live.has(key)).sort();
assert.deepEqual(
stale,
[],
`this SDK handles operations the app no longer publishes: ${stale.join(", ")}. ` +
"Regenerate the snapshot with sdk/tools/extract-api-paths.py, then drop or fix each entry.",
);
});
test("an operation is either wrapped or omitted but not both", () => {
const both = [...OPERATIONS.keys()].filter((key) => OMITTED.has(key)).sort();
assert.deepEqual(both, [], `listed twice: ${both.join(", ")}`);
});
test("every omission gives a reason", () => {
for (const [operation, reason] of OMITTED) {
assert.ok(reason.trim().length > 40, `${operation} is omitted without a real reason`);
}
});
test("every wrapped operation names a real method", () => {
const prototype = DonutClient.prototype as unknown as Record<string, unknown>;
for (const [operation, name] of OPERATIONS) {
assert.equal(
typeof prototype[name],
"function",
`${operation} names ${name}, which is not a method`,
);
}
});
test("no two operations share a method", () => {
const names = [...OPERATIONS.values()];
const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
assert.deepEqual(
duplicates,
[],
`one method is claimed by several operations: ${duplicates.join(", ")}`,
);
});
+219
View File
@@ -0,0 +1,219 @@
/** Each status the app documents throws its own error. */
import assert from "node:assert/strict";
import { test } from "node:test";
import {
BadGateway,
Conflict,
DonutApiError,
DonutClient,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
} from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
import { withClient } from "./support.mts";
const STATUS_TO_ERROR: [number, new (...args: never[]) => DonutApiError][] = [
[400, ValidationError],
[401, Unauthorized],
[402, PaymentRequired],
[403, Forbidden],
[404, NotFound],
[408, RequestTimeout],
[409, Conflict],
[429, RateLimited],
[500, ServerError],
[502, BadGateway],
[503, ServiceUnavailable],
];
for (const [status, expected] of STATUS_TO_ERROR) {
test(`${status} maps to ${expected.name}`, async () => {
await withClient(async (client, fake) => {
fake.enqueueError(status, "something went wrong");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof expected, `expected ${expected.name}, got ${String(thrown)}`);
assert.equal(thrown.status, status);
assert.equal(thrown.body, "something went wrong");
assert.equal(thrown.method, "GET");
assert.equal(thrown.path, "/v1/profiles");
});
});
}
test("every error is a DonutError", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "PROFILE_NOT_FOUND");
await assert.rejects(client.getProfile("nope"), DonutError);
});
});
test("the five hundreds share one base", async () => {
await withClient(async (client, fake) => {
for (const status of [500, 502, 503]) {
fake.enqueueError(status, "upstream");
await assert.rejects(client.listProfiles(), ServerError);
}
});
});
test("rate limited carries retryAfter", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "automation request rate limit exceeded", { "Retry-After": "42" });
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, 42);
});
});
test("rate limited without the header is still thrown", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "slow down");
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, null);
});
});
test("an unreadable Retry-After does not break the error", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "slow down", { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" });
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, null);
});
});
test("a structured code body is decoded", async () => {
// The app shares `{"code": ...}` strings with its own frontend.
await withClient(async (client, fake) => {
fake.enqueueError(400, JSON.stringify({ code: "NAME_CANNOT_BE_EMPTY" }));
const thrown = await client.createGroup("").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof ValidationError);
assert.equal(thrown.code, "NAME_CANNOT_BE_EMPTY");
assert.deepEqual(thrown.params, {});
});
});
test("a structured code body keeps its params", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(
409,
JSON.stringify({ code: "PROFILE_LOCKED_BY_MEMBER", params: { n: "5" } }),
);
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof Conflict);
assert.equal(thrown.code, "PROFILE_LOCKED_BY_MEMBER");
assert.deepEqual(thrown.params, { n: "5" });
});
});
test("a plain text body leaves code unset", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(400, "invalid browser");
const thrown = await client.createProfile({ name: "x", browser: "chromium" }).then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof ValidationError);
assert.equal(thrown.code, null);
assert.equal(thrown.body, "invalid browser");
});
});
test("an undocumented status still throws something catchable", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(418, "teapot");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof DonutApiError);
assert.equal(thrown.status, 418);
});
});
test("an undocumented server status is a ServerError", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(504, "gateway timeout");
await assert.rejects(client.listProfiles(), ServerError);
});
});
test("the message names the call", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "Profile not found");
const thrown = await client.getProfile("missing").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof NotFound);
assert.match(thrown.message, /404/);
assert.match(thrown.message, /GET \/v1\/profiles\/missing/);
});
});
test("errors keep their class name", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "gone");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof NotFound);
assert.equal(thrown.name, "NotFound");
});
});
test("an unreachable app is not an API error", async () => {
const fake = await new FakeDonut().start();
const port = fake.port;
await fake.stop();
const client = new DonutClient({ token: "t", port, timeoutMs: 2_000, env: {} });
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof DonutConnectionError);
assert.match(thrown.message, /Local API/);
});
test("a missing token fails before any request", () => {
assert.throws(() => new DonutClient({ env: {} }), /DONUT_API_TOKEN/);
});
test("a non-JSON answer is reported as such", async () => {
await withClient(async (client, fake) => {
fake.enqueueRaw(200, "<html>nope</html>");
await assert.rejects(client.listProfiles(), /not\s+JSON/);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* A stand-in for the desktop app's local REST API.
*
* It records what the client sent, byte for byte, and answers with whatever
* the test queued. Nothing here reaches the network: it binds an ephemeral
* loopback port and is torn down with the test.
*/
import { createServer } from "node:http";
import type { IncomingMessage, Server, ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
export interface RecordedRequest {
method: string;
target: string;
path: string;
query: Record<string, string>;
headers: Record<string, string>;
rawBody: string;
json: unknown;
}
export interface QueuedResponse {
status: number;
body: string;
headers: Record<string, string>;
contentType: string;
}
export class FakeDonut {
requests: RecordedRequest[] = [];
responses: QueuedResponse[] = [];
#server: Server | undefined = undefined;
enqueueJson(payload: unknown, status = 200): void {
this.responses.push({
status,
body: JSON.stringify(payload),
headers: {},
contentType: "application/json",
});
}
enqueueEmpty(status = 204): void {
this.responses.push({ status, body: "", headers: {}, contentType: "application/json" });
}
enqueueError(status: number, body = "", headers: Record<string, string> = {}): void {
this.responses.push({ status, body, headers, contentType: "text/plain" });
}
enqueueRaw(status: number, body: string, contentType = "text/html"): void {
this.responses.push({ status, body, headers: {}, contentType });
}
get port(): number {
if (this.#server === undefined) {
throw new Error("the fake server is not running");
}
return (this.#server.address() as AddressInfo).port;
}
get last(): RecordedRequest {
const request = this.requests.at(-1);
if (request === undefined) {
throw new Error("the client sent nothing");
}
return request;
}
async start(): Promise<this> {
const server = createServer((incoming: IncomingMessage, outgoing: ServerResponse) => {
const chunks: Buffer[] = [];
incoming.on("data", (chunk: Buffer) => chunks.push(chunk));
incoming.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
const url = new URL(incoming.url ?? "/", "http://127.0.0.1");
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(incoming.headers)) {
headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : (value ?? "");
}
this.requests.push({
method: incoming.method ?? "",
target: incoming.url ?? "",
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
headers,
rawBody,
json: rawBody === "" ? null : JSON.parse(rawBody),
});
const queued = this.responses.shift() ?? {
status: 200,
body: "{}",
headers: {},
contentType: "application/json",
};
for (const [name, value] of Object.entries(queued.headers)) {
outgoing.setHeader(name, value);
}
if (queued.body !== "") {
outgoing.setHeader("Content-Type", queued.contentType);
}
outgoing.writeHead(queued.status);
outgoing.end(queued.body);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
this.#server = server;
return this;
}
async stop(): Promise<void> {
const server = this.#server;
if (server === undefined) {
return;
}
this.#server = undefined;
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
/** Start a fake server, hand it to `work`, and always shut it down again. */
export async function withFakeDonut<T>(work: (fake: FakeDonut) => Promise<T>): Promise<T> {
const fake = await new FakeDonut().start();
try {
return await work(fake);
} finally {
await fake.stop();
}
}
+768
View File
@@ -0,0 +1,768 @@
/**
* Every client method sends exactly the request the app documents.
*
* The table below is the whole public surface. Each row names a method, the
* arguments to call it with, and the request that must appear on the wire: the
* verb, the concrete path, the query string and the JSON body. `operation` is
* the path template the app publishes, which ties this file to
* `OPERATIONS` and, through it, to `sdk/api-paths.json`.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { OPERATIONS } from "../src/index.mts";
import { withClient } from "./support.mts";
interface Case {
method: string;
args: unknown[];
verb: string;
path: string;
body: unknown;
query?: Record<string, string>;
operation: string;
}
const LOCATOR = { role: "button", name: "Sign in" };
const CASES: Case[] = [
// -- profiles ------------------------------------------------------------
{
method: "listProfiles",
args: [],
verb: "GET",
path: "/v1/profiles",
body: null,
operation: "GET /v1/profiles",
},
{
method: "getProfile",
args: ["p1"],
verb: "GET",
path: "/v1/profiles/p1",
body: null,
operation: "GET /v1/profiles/{id}",
},
{
method: "createProfile",
args: [{ name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true }],
verb: "POST",
path: "/v1/profiles",
body: { name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true },
operation: "POST /v1/profiles",
},
{
method: "createProfile",
args: [{ name: "Bare", browser: "wayfern", version: undefined }],
verb: "POST",
path: "/v1/profiles",
body: { name: "Bare", browser: "wayfern" },
operation: "POST /v1/profiles",
},
{
method: "updateProfile",
args: ["p1", { name: "Renamed", proxy_id: "", clear_on_close: false }],
verb: "PUT",
path: "/v1/profiles/p1",
body: { name: "Renamed", proxy_id: "", clear_on_close: false },
operation: "PUT /v1/profiles/{id}",
},
{
method: "deleteProfile",
args: ["p1"],
verb: "DELETE",
path: "/v1/profiles/p1",
body: null,
operation: "DELETE /v1/profiles/{id}",
},
{
method: "runProfile",
args: ["p1", { url: "https://example.com", headless: true }],
verb: "POST",
path: "/v1/profiles/p1/run",
body: { url: "https://example.com", headless: true },
operation: "POST /v1/profiles/{id}/run",
},
{
method: "runProfileRemote",
args: ["p1", { url: "https://example.com" }],
verb: "POST",
path: "/v1/profiles/p1/run-remote",
body: { url: "https://example.com" },
operation: "POST /v1/profiles/{id}/run-remote",
},
{
method: "setProfileCloudSync",
args: ["p1", "Regular"],
verb: "POST",
path: "/v1/profiles/p1/cloud-sync",
body: { mode: "Regular" },
operation: "POST /v1/profiles/{id}/cloud-sync",
},
{
method: "openUrl",
args: ["p1", "https://example.com/page"],
verb: "POST",
path: "/v1/profiles/p1/open-url",
body: { url: "https://example.com/page" },
operation: "POST /v1/profiles/{id}/open-url",
},
{
method: "killProfile",
args: ["p1"],
verb: "POST",
path: "/v1/profiles/p1/kill",
body: null,
operation: "POST /v1/profiles/{id}/kill",
},
{
method: "batchRunProfiles",
args: [["p1", "p2"], { headless: false }],
verb: "POST",
path: "/v1/profiles/batch/run",
body: { profile_ids: ["p1", "p2"], headless: false },
operation: "POST /v1/profiles/batch/run",
},
{
method: "batchStopProfiles",
args: [["p1", "p2"]],
verb: "POST",
path: "/v1/profiles/batch/stop",
body: { profile_ids: ["p1", "p2"] },
operation: "POST /v1/profiles/batch/stop",
},
{
method: "distributeProxies",
args: [
[
{ profile_id: "p1", proxy_id: "x1" },
{ profile_id: "p2", proxy_id: "x2" },
],
],
verb: "POST",
path: "/v1/profiles/distribute-proxies",
body: {
pairs: [
{ profile_id: "p1", proxy_id: "x1" },
{ profile_id: "p2", proxy_id: "x2" },
],
},
operation: "POST /v1/profiles/distribute-proxies",
},
{
method: "detectImportProfiles",
args: [{ folder: "/Users/x/Chrome" }],
verb: "GET",
path: "/v1/profiles/import/detect",
body: null,
query: { folder: "/Users/x/Chrome" },
operation: "GET /v1/profiles/import/detect",
},
{
method: "detectImportProfiles",
args: [],
verb: "GET",
path: "/v1/profiles/import/detect",
body: null,
operation: "GET /v1/profiles/import/detect",
},
{
method: "importProfiles",
args: [
[{ source_path: "/tmp/src", new_profile_name: "Imported" }],
{ duplicate_strategy: "skip" },
],
verb: "POST",
path: "/v1/profiles/import",
body: {
items: [{ source_path: "/tmp/src", new_profile_name: "Imported" }],
duplicate_strategy: "skip",
},
operation: "POST /v1/profiles/import",
},
{
method: "importProfileCookies",
args: ["p1", "[]"],
verb: "POST",
path: "/v1/profiles/p1/cookies/import",
body: { content: "[]" },
operation: "POST /v1/profiles/{id}/cookies/import",
},
// -- agent ---------------------------------------------------------------
{
method: "agentPerceive",
args: ["p1", { viewport_only: true, max_bytes: 2048 }],
verb: "POST",
path: "/v1/profiles/p1/agent/perceive",
body: { viewport_only: true, max_bytes: 2048 },
operation: "POST /v1/profiles/{id}/agent/perceive",
},
{
method: "agentPerceive",
args: ["p1"],
verb: "POST",
path: "/v1/profiles/p1/agent/perceive",
body: {},
operation: "POST /v1/profiles/{id}/agent/perceive",
},
{
method: "agentResolveLocator",
args: ["p1", { locator: LOCATOR, candidate_limit: 5 }],
verb: "POST",
path: "/v1/profiles/p1/agent/resolve-locator",
body: { locator: LOCATOR, candidate_limit: 5 },
operation: "POST /v1/profiles/{id}/agent/resolve-locator",
},
{
method: "agentClick",
args: ["p1", { locator: LOCATOR, button: "right", click_count: 2 }],
verb: "POST",
path: "/v1/profiles/p1/agent/click",
body: { locator: LOCATOR, button: "right", click_count: 2 },
operation: "POST /v1/profiles/{id}/agent/click",
},
{
method: "agentType",
args: ["p1", { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 }],
verb: "POST",
path: "/v1/profiles/p1/agent/type",
body: { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 },
operation: "POST /v1/profiles/{id}/agent/type",
},
{
method: "agentExtract",
args: [
"p1",
{
container: { role: "listitem" },
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
max_pages: 3,
},
],
verb: "POST",
path: "/v1/profiles/p1/agent/extract",
body: {
container: { role: "listitem" },
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
max_pages: 3,
},
operation: "POST /v1/profiles/{id}/agent/extract",
},
{
method: "agentPick",
args: ["p1", { timeout_ms: 15000 }],
verb: "POST",
path: "/v1/profiles/p1/agent/pick",
body: { timeout_ms: 15000 },
operation: "POST /v1/profiles/{id}/agent/pick",
},
// -- remote sessions -----------------------------------------------------
{
method: "listRemoteSessions",
args: [],
verb: "GET",
path: "/v1/remote-sessions",
body: null,
operation: "GET /v1/remote-sessions",
},
{
method: "getRemoteSession",
args: ["s1"],
verb: "GET",
path: "/v1/remote-sessions/s1",
body: null,
operation: "GET /v1/remote-sessions/{id}",
},
{
method: "stopRemoteSession",
args: ["s1"],
verb: "DELETE",
path: "/v1/remote-sessions/s1",
body: null,
operation: "DELETE /v1/remote-sessions/{id}",
},
{
method: "getRemoteHours",
args: [],
verb: "GET",
path: "/v1/remote-hours",
body: null,
operation: "GET /v1/remote-hours",
},
// -- cookie bot ----------------------------------------------------------
{
method: "listCookieBotSchedules",
args: [{ scope: "team" }],
verb: "GET",
path: "/v1/cookie-bot/schedules",
body: null,
query: { scope: "team" },
operation: "GET /v1/cookie-bot/schedules",
},
{
method: "getCookieBotSchedule",
args: ["p1"],
verb: "GET",
path: "/v1/cookie-bot/schedules/p1",
body: null,
operation: "GET /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "setCookieBotSchedule",
args: [
"p1",
{
enabled: true,
run_at_minute: 120,
days_mask: 31,
timezone: "Europe/Berlin",
preset: "steady",
max_minutes: 45,
sites: ["https://example.com"],
acknowledge_conflict: true,
},
],
verb: "PUT",
path: "/v1/cookie-bot/schedules/p1",
body: {
enabled: true,
run_at_minute: 120,
days_mask: 31,
timezone: "Europe/Berlin",
preset: "steady",
max_minutes: 45,
sites: ["https://example.com"],
acknowledge_conflict: true,
},
operation: "PUT /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "deleteCookieBotSchedule",
args: ["p1"],
verb: "DELETE",
path: "/v1/cookie-bot/schedules/p1",
body: null,
operation: "DELETE /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "getCookieBotConflicts",
args: ["p1", { run_at_minute: 90, timezone: "UTC", days_mask: 7 }],
verb: "GET",
path: "/v1/cookie-bot/conflicts",
body: null,
query: { profile_id: "p1", run_at_minute: "90", timezone: "UTC", days_mask: "7" },
operation: "GET /v1/cookie-bot/conflicts",
},
{
method: "listCookieBotRuns",
args: [{ profile_id: "p1", limit: 10, before: "cursor-1" }],
verb: "GET",
path: "/v1/cookie-bot/runs",
body: null,
query: { profile_id: "p1", limit: "10", before: "cursor-1" },
operation: "GET /v1/cookie-bot/runs",
},
{
method: "startCookieBotRun",
args: [{ profile_id: "p1", max_minutes: 30 }],
verb: "POST",
path: "/v1/cookie-bot/runs",
body: { profile_id: "p1", max_minutes: 30 },
operation: "POST /v1/cookie-bot/runs",
},
{
method: "cancelCookieBotRun",
args: ["r1"],
verb: "DELETE",
path: "/v1/cookie-bot/runs/r1",
body: null,
operation: "DELETE /v1/cookie-bot/runs/{run_id}",
},
{
method: "listCookieBotPresets",
args: [],
verb: "GET",
path: "/v1/cookie-bot/presets",
body: null,
operation: "GET /v1/cookie-bot/presets",
},
{
method: "getCookieBotUsage",
args: [{ period: "2026-08" }],
verb: "GET",
path: "/v1/cookie-bot/usage",
body: null,
query: { period: "2026-08" },
operation: "GET /v1/cookie-bot/usage",
},
// -- groups and tags -----------------------------------------------------
{
method: "listGroups",
args: [],
verb: "GET",
path: "/v1/groups",
body: null,
operation: "GET /v1/groups",
},
{
method: "getGroup",
args: ["g1"],
verb: "GET",
path: "/v1/groups/g1",
body: null,
operation: "GET /v1/groups/{id}",
},
{
method: "createGroup",
args: ["Retail"],
verb: "POST",
path: "/v1/groups",
body: { name: "Retail" },
operation: "POST /v1/groups",
},
{
method: "updateGroup",
args: ["g1", "Retail EU"],
verb: "PUT",
path: "/v1/groups/g1",
body: { name: "Retail EU" },
operation: "PUT /v1/groups/{id}",
},
{
method: "deleteGroup",
args: ["g1"],
verb: "DELETE",
path: "/v1/groups/g1",
body: null,
operation: "DELETE /v1/groups/{id}",
},
{
method: "listTags",
args: [],
verb: "GET",
path: "/v1/tags",
body: null,
operation: "GET /v1/tags",
},
// -- proxies -------------------------------------------------------------
{
method: "listProxies",
args: [],
verb: "GET",
path: "/v1/proxies",
body: null,
operation: "GET /v1/proxies",
},
{
method: "getProxy",
args: ["x1"],
verb: "GET",
path: "/v1/proxies/x1",
body: null,
operation: "GET /v1/proxies/{id}",
},
{
method: "createProxy",
args: [{ name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } }],
verb: "POST",
path: "/v1/proxies",
body: { name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } },
operation: "POST /v1/proxies",
},
{
method: "updateProxy",
args: ["x1", { name: "EU 2" }],
verb: "PUT",
path: "/v1/proxies/x1",
body: { name: "EU 2" },
operation: "PUT /v1/proxies/{id}",
},
{
method: "deleteProxy",
args: ["x1"],
verb: "DELETE",
path: "/v1/proxies/x1",
body: null,
operation: "DELETE /v1/proxies/{id}",
},
{
method: "importProxies",
args: [{ format: "txt", content: "h:1:u:p", name_prefix: "EU" }],
verb: "POST",
path: "/v1/proxies/import",
body: { format: "txt", content: "h:1:u:p", name_prefix: "EU" },
operation: "POST /v1/proxies/import",
},
// -- vpns ----------------------------------------------------------------
{
method: "listVpns",
args: [],
verb: "GET",
path: "/v1/vpns",
body: null,
operation: "GET /v1/vpns",
},
{
method: "getVpn",
args: ["v1"],
verb: "GET",
path: "/v1/vpns/v1",
body: null,
operation: "GET /v1/vpns/{id}",
},
{
method: "exportVpn",
args: ["v1"],
verb: "GET",
path: "/v1/vpns/v1/export",
body: null,
operation: "GET /v1/vpns/{id}/export",
},
{
method: "importVpn",
args: [{ content: "[Interface]", filename: "eu.conf" }],
verb: "POST",
path: "/v1/vpns/import",
body: { content: "[Interface]", filename: "eu.conf" },
operation: "POST /v1/vpns/import",
},
{
method: "createVpn",
args: [{ name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" }],
verb: "POST",
path: "/v1/vpns",
body: { name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" },
operation: "POST /v1/vpns",
},
{
method: "updateVpn",
args: ["v1", "EU 2"],
verb: "PUT",
path: "/v1/vpns/v1",
body: { name: "EU 2" },
operation: "PUT /v1/vpns/{id}",
},
{
method: "deleteVpn",
args: ["v1"],
verb: "DELETE",
path: "/v1/vpns/v1",
body: null,
operation: "DELETE /v1/vpns/{id}",
},
// -- extensions ----------------------------------------------------------
{
method: "listExtensions",
args: [],
verb: "GET",
path: "/v1/extensions",
body: null,
operation: "GET /v1/extensions",
},
{
method: "getExtension",
args: ["e1"],
verb: "GET",
path: "/v1/extensions/e1",
body: null,
operation: "GET /v1/extensions/{id}",
},
{
method: "createExtension",
args: [{ name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" }],
verb: "POST",
path: "/v1/extensions",
body: { name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" },
operation: "POST /v1/extensions",
},
{
method: "updateExtension",
args: ["e1", { name: "Blocker 2", link: true }],
verb: "PUT",
path: "/v1/extensions/e1",
body: { name: "Blocker 2", link: true },
operation: "PUT /v1/extensions/{id}",
},
{
method: "deleteExtension",
args: ["e1"],
verb: "DELETE",
path: "/v1/extensions/e1",
body: null,
operation: "DELETE /v1/extensions/{id}",
},
{
method: "listExtensionGroups",
args: [],
verb: "GET",
path: "/v1/extension-groups",
body: null,
operation: "GET /v1/extension-groups",
},
{
method: "getExtensionGroup",
args: ["eg1"],
verb: "GET",
path: "/v1/extension-groups/eg1",
body: null,
operation: "GET /v1/extension-groups/{id}",
},
{
method: "createExtensionGroup",
args: ["Adblock set"],
verb: "POST",
path: "/v1/extension-groups",
body: { name: "Adblock set" },
operation: "POST /v1/extension-groups",
},
{
method: "updateExtensionGroup",
args: ["eg1", { extension_ids: ["e1", "e2"] }],
verb: "PUT",
path: "/v1/extension-groups/eg1",
body: { extension_ids: ["e1", "e2"] },
operation: "PUT /v1/extension-groups/{id}",
},
{
method: "deleteExtensionGroup",
args: ["eg1"],
verb: "DELETE",
path: "/v1/extension-groups/eg1",
body: null,
operation: "DELETE /v1/extension-groups/{id}",
},
{
method: "addExtensionToGroup",
args: ["eg1", "e1"],
verb: "POST",
path: "/v1/extension-groups/eg1/extensions/e1",
body: null,
operation: "POST /v1/extension-groups/{id}/extensions/{extension_id}",
},
{
method: "removeExtensionFromGroup",
args: ["eg1", "e1"],
verb: "DELETE",
path: "/v1/extension-groups/eg1/extensions/e1",
body: null,
operation: "DELETE /v1/extension-groups/{id}/extensions/{extension_id}",
},
// -- browsers ------------------------------------------------------------
{
method: "downloadBrowser",
args: [{ browser: "wayfern", version: "152.0.1" }],
verb: "POST",
path: "/v1/browsers/download",
body: { browser: "wayfern", version: "152.0.1" },
operation: "POST /v1/browsers/download",
},
{
method: "listBrowserVersions",
args: ["wayfern"],
verb: "GET",
path: "/v1/browsers/wayfern/versions",
body: null,
operation: "GET /v1/browsers/{browser}/versions",
},
{
method: "isBrowserDownloaded",
args: ["wayfern", "152.0.1"],
verb: "GET",
path: "/v1/browsers/wayfern/versions/152.0.1/downloaded",
body: null,
operation: "GET /v1/browsers/{browser}/versions/{version}/downloaded",
},
];
for (const [index, expected] of CASES.entries()) {
test(`${expected.method} sends the documented request [${index}]`, async () => {
await withClient(async (client, fake) => {
const callable = (client as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[
expected.method
];
assert.equal(typeof callable, "function", `${expected.method} is not a method`);
await callable.call(client, ...expected.args);
const sent = fake.last;
assert.equal(sent.method, expected.verb);
assert.equal(sent.path, expected.path);
assert.deepEqual(sent.query, expected.query ?? {});
assert.deepEqual(sent.json, expected.body);
assert.equal(OPERATIONS.get(expected.operation), expected.method);
});
});
}
test("every wrapped operation has a request test", () => {
const covered = new Set(CASES.map((entry) => entry.method));
const missing = [...OPERATIONS.values()].filter((name) => !covered.has(name)).sort();
assert.deepEqual(missing, [], `these wrapped operations have no request test: ${missing}`);
});
test("the token travels as a bearer header", async () => {
await withClient(async (client, fake) => {
await client.listProfiles();
assert.equal(fake.last.headers.authorization, "Bearer test-token-abc123");
assert.equal(fake.last.headers.accept, "application/json");
assert.equal(
fake.last.headers["content-type"],
undefined,
"a GET must not claim to carry JSON",
);
});
});
test("a body is sent as JSON", async () => {
await withClient(async (client, fake) => {
await client.createGroup("Retail");
assert.equal(fake.last.headers["content-type"], "application/json");
assert.equal(fake.last.rawBody, '{"name":"Retail"}');
});
});
test("path ids are escaped", async () => {
await withClient(async (client, fake) => {
await client.getProfile("a/b c?d");
assert.equal(fake.last.path, "/v1/profiles/a%2Fb%20c%3Fd");
});
});
test("undefined arguments are left out of the body", async () => {
await withClient(async (client, fake) => {
await client.updateProfile("p1", { name: "Only this", version: undefined });
assert.deepEqual(fake.last.json, { name: "Only this" });
});
});
test("an empty string still reaches the app", async () => {
// `proxy_id: ""` is how the app is told to detach a proxy, so it must survive.
await withClient(async (client, fake) => {
await client.updateProfile("p1", { proxy_id: "" });
assert.deepEqual(fake.last.json, { proxy_id: "" });
});
});
test("a no-content answer becomes undefined", async () => {
await withClient(async (client, fake) => {
fake.enqueueEmpty(204);
assert.equal(await client.deleteProfile("p1"), undefined);
});
});
test("a JSON answer is returned as sent", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson({ profiles: [{ id: "p1", name: "Shopper" }], total: 1 });
assert.deepEqual(await client.listProfiles(), {
profiles: [{ id: "p1", name: "Shopper" }],
total: 1,
});
});
});
test("a bare boolean answer is returned", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(true);
assert.equal(await client.isBrowserDownloaded("wayfern", "152.0.1"), true);
});
});
+134
View File
@@ -0,0 +1,134 @@
/** `withProfile` launches, hands over the CDP endpoint, and stops. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { Conflict, DonutError, RunSession } from "../src/index.mts";
import { withClient } from "./support.mts";
const RUN_BODY = { profile_id: "p1", remote_debugging_port: 9222, headless: true };
test("the callback gets the CDP endpoint", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueEmpty(204);
const seen = await client.withProfile(
"p1",
{ url: "https://example.com", headless: true },
(session) => {
assert.ok(session instanceof RunSession);
assert.equal(session.remoteDebuggingPort, 9222);
assert.equal(session.headless, true);
assert.equal(session.cdpUrl, "http://127.0.0.1:9222");
assert.deepEqual(session.response, RUN_BODY);
return session.cdpUrl;
},
);
assert.equal(seen, "http://127.0.0.1:9222");
assert.deepEqual(
fake.requests.map((sent) => `${sent.method} ${sent.path}`),
["POST /v1/profiles/p1/run", "POST /v1/profiles/p1/kill"],
);
assert.deepEqual(fake.requests[0]?.json, { url: "https://example.com", headless: true });
});
});
test("the browser is stopped when the callback throws", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueEmpty(204);
await assert.rejects(
client.withProfile("p1", {}, () => {
throw new RangeError("the body failed");
}),
RangeError,
);
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run", "/v1/profiles/p1/kill"],
);
});
});
test("a failed stop never hides why the callback failed", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueError(409, "PROFILE_LOCKED_ELSEWHERE");
let captured: RunSession | undefined;
await assert.rejects(
client.withProfile("p1", {}, (session) => {
captured = session;
throw new RangeError("the body failed");
}),
RangeError,
);
assert.ok(captured?.cleanupError instanceof Conflict);
});
});
test("a failed stop is thrown when the callback was fine", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueError(503, "the fleet could not be reached");
await assert.rejects(
client.withProfile("p1", {}, () => "done"),
DonutError,
);
});
});
test("a failed launch never runs the callback and stops nothing", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(409, "PROFILE_RUNNING");
await assert.rejects(
client.withProfile("p1", {}, () => {
throw new Error("the callback must not run when the launch failed");
}),
Conflict,
);
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run"],
);
});
});
test("an async callback is awaited before the browser is stopped", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueJson({ profiles: [], total: 0 });
fake.enqueueEmpty(204);
await client.withProfile("p1", {}, async () => {
await client.listProfiles();
});
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run", "/v1/profiles", "/v1/profiles/p1/kill"],
);
});
});
test("a session also disposes itself", async () => {
// `withProfile` is the portable form, but a runtime with `await using` can
// hold a RunSession directly.
await withClient(async (client, fake) => {
fake.enqueueEmpty(204);
const session = new RunSession(client, "p1", RUN_BODY);
await session[Symbol.asyncDispose]();
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/kill"],
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { DonutClient } from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
export const TOKEN = "test-token-abc123";
/** Start a fake app, point a client at it, and always shut the server down. */
export async function withClient<T>(
work: (client: DonutClient, fake: FakeDonut) => Promise<T>,
): Promise<T> {
const fake = await new FakeDonut().start();
try {
const client = new DonutClient({
token: TOKEN,
port: fake.port,
timeoutMs: 5_000,
env: {},
});
return await work(client, fake);
} finally {
await fake.stop();
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "ESNext.Disposable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": false,
"noUncheckedIndexedAccess": true,
"declaration": true,
"noEmitOnError": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
"outDir": "dist",
"rootDir": "src",
"types": [],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.mts"]
}
+37
View File
@@ -0,0 +1,37 @@
# donutbrowser
A thin Python client for the [Donut Browser](https://donutbrowser.com) local
REST API. Every method wraps exactly one documented endpoint; nothing is
invented, cached or retried.
The local API is off by default. Switch it on in the app under **Settings →
Integrations → Local API → "Enable Local API Server"**, then copy the port and
the authentication token from that screen.
```bash
pip install -e . # from this directory
```
```python
from donutbrowser import DonutClient
with DonutClient(token="...") as client:
with client.run(profile_id, url="https://example.com", headless=True) as session:
print(session.cdp_url)
```
The client reads `DONUT_API_TOKEN` and `DONUT_API_PORT` when the token and port
are not passed as arguments.
Full documentation, including the Node package and a worked agent example, is in
[`sdk/README.md`](../README.md).
## Tests
```bash
pip install -e ".[dev]"
pytest
```
The suite runs entirely against a fake HTTP server on loopback. It never reaches
the network and never needs the desktop app.
+40
View File
@@ -0,0 +1,40 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "donutbrowser"
version = "0.1.0"
description = "Thin client for the Donut Browser local REST API"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "AGPL-3.0" }
keywords = ["donut-browser", "browser-automation", "anti-detect", "cdp"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed",
]
# No runtime dependencies on purpose: this client talks to a loopback server on
# the same machine, so the standard library is enough and installing the SDK can
# never drag a transitive dependency into an automation environment.
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7"]
[project.urls]
Homepage = "https://donutbrowser.com"
Source = "https://github.com/zhom/donutbrowser"
[tool.hatch.build.targets.wheel]
packages = ["src/donutbrowser"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+59
View File
@@ -0,0 +1,59 @@
"""Donut Browser SDK: a thin client for the app's local REST API.
The local API is off by default. Switch it on in the app under **Settings,
Integrations, Local API, "Enable Local API Server"**, and copy the port and the
authentication token from that screen.
::
from donutbrowser import DonutClient
with DonutClient(token="...") as client:
with client.run(profile_id, url="https://example.com") as session:
client.agent_click(profile_id, locator={"role": "button", "name": "Sign in"})
"""
from .client import DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession
from .coverage import OMITTED, OPERATIONS
from .errors import (
BadGateway,
Conflict,
DonutAPIError,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
)
__version__ = "0.1.0"
__all__ = [
"DonutClient",
"RunSession",
"DEFAULT_HOST",
"DEFAULT_PORT",
"OPERATIONS",
"OMITTED",
"DonutError",
"DonutConnectionError",
"DonutAPIError",
"ValidationError",
"Unauthorized",
"PaymentRequired",
"Forbidden",
"NotFound",
"RequestTimeout",
"Conflict",
"RateLimited",
"ServerError",
"BadGateway",
"ServiceUnavailable",
"__version__",
]
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
"""Which app operation each client method wraps.
This table is the SDK's half of a two-sided check. ``sdk/api-paths.json`` holds
every operation the desktop app publishes, generated from
``src-tauri/src/api_server.rs``. The test suite asserts the two agree exactly in
both directions, so:
* an endpoint added to the app fails the SDK tests until it is wrapped here, or
listed in :data:`OMITTED` with a reason, and
* an entry here that the app no longer publishes fails too.
The same table is mirrored in the Node package, and the same snapshot proves it.
"""
from __future__ import annotations
from typing import Dict, Tuple
__all__ = ["OPERATIONS", "OMITTED"]
Operation = Tuple[str, str]
#: ``(method, path template)`` to the name of the :class:`~donutbrowser.DonutClient`
#: method that calls it.
OPERATIONS: Dict[Operation, str] = {
("POST", "/v1/browsers/download"): "download_browser",
("GET", "/v1/browsers/{browser}/versions"): "list_browser_versions",
("GET", "/v1/browsers/{browser}/versions/{version}/downloaded"): "is_browser_downloaded",
("GET", "/v1/cookie-bot/conflicts"): "get_cookie_bot_conflicts",
("GET", "/v1/cookie-bot/presets"): "list_cookie_bot_presets",
("GET", "/v1/cookie-bot/runs"): "list_cookie_bot_runs",
("POST", "/v1/cookie-bot/runs"): "start_cookie_bot_run",
("DELETE", "/v1/cookie-bot/runs/{run_id}"): "cancel_cookie_bot_run",
("GET", "/v1/cookie-bot/schedules"): "list_cookie_bot_schedules",
("DELETE", "/v1/cookie-bot/schedules/{profile_id}"): "delete_cookie_bot_schedule",
("GET", "/v1/cookie-bot/schedules/{profile_id}"): "get_cookie_bot_schedule",
("PUT", "/v1/cookie-bot/schedules/{profile_id}"): "set_cookie_bot_schedule",
("GET", "/v1/cookie-bot/usage"): "get_cookie_bot_usage",
("GET", "/v1/extension-groups"): "list_extension_groups",
("POST", "/v1/extension-groups"): "create_extension_group",
("DELETE", "/v1/extension-groups/{id}"): "delete_extension_group",
("GET", "/v1/extension-groups/{id}"): "get_extension_group",
("PUT", "/v1/extension-groups/{id}"): "update_extension_group",
(
"DELETE",
"/v1/extension-groups/{id}/extensions/{extension_id}",
): "remove_extension_from_group",
("POST", "/v1/extension-groups/{id}/extensions/{extension_id}"): "add_extension_to_group",
("GET", "/v1/extensions"): "list_extensions",
("POST", "/v1/extensions"): "create_extension",
("DELETE", "/v1/extensions/{id}"): "delete_extension",
("GET", "/v1/extensions/{id}"): "get_extension",
("PUT", "/v1/extensions/{id}"): "update_extension",
("GET", "/v1/groups"): "list_groups",
("POST", "/v1/groups"): "create_group",
("DELETE", "/v1/groups/{id}"): "delete_group",
("GET", "/v1/groups/{id}"): "get_group",
("PUT", "/v1/groups/{id}"): "update_group",
("GET", "/v1/profiles"): "list_profiles",
("POST", "/v1/profiles"): "create_profile",
("POST", "/v1/profiles/batch/run"): "batch_run_profiles",
("POST", "/v1/profiles/batch/stop"): "batch_stop_profiles",
("POST", "/v1/profiles/distribute-proxies"): "distribute_proxies",
("POST", "/v1/profiles/import"): "import_profiles",
("GET", "/v1/profiles/import/detect"): "detect_import_profiles",
("DELETE", "/v1/profiles/{id}"): "delete_profile",
("GET", "/v1/profiles/{id}"): "get_profile",
("PUT", "/v1/profiles/{id}"): "update_profile",
("POST", "/v1/profiles/{id}/agent/click"): "agent_click",
("POST", "/v1/profiles/{id}/agent/extract"): "agent_extract",
("POST", "/v1/profiles/{id}/agent/perceive"): "agent_perceive",
("POST", "/v1/profiles/{id}/agent/pick"): "agent_pick",
("POST", "/v1/profiles/{id}/agent/resolve-locator"): "agent_resolve_locator",
("POST", "/v1/profiles/{id}/agent/type"): "agent_type",
("POST", "/v1/profiles/{id}/cloud-sync"): "set_profile_cloud_sync",
("POST", "/v1/profiles/{id}/cookies/import"): "import_profile_cookies",
("POST", "/v1/profiles/{id}/kill"): "kill_profile",
("POST", "/v1/profiles/{id}/open-url"): "open_url",
("POST", "/v1/profiles/{id}/run"): "run_profile",
("POST", "/v1/profiles/{id}/run-remote"): "run_profile_remote",
("GET", "/v1/proxies"): "list_proxies",
("POST", "/v1/proxies"): "create_proxy",
("POST", "/v1/proxies/import"): "import_proxies",
("DELETE", "/v1/proxies/{id}"): "delete_proxy",
("GET", "/v1/proxies/{id}"): "get_proxy",
("PUT", "/v1/proxies/{id}"): "update_proxy",
("GET", "/v1/remote-hours"): "get_remote_hours",
("GET", "/v1/remote-sessions"): "list_remote_sessions",
("DELETE", "/v1/remote-sessions/{id}"): "stop_remote_session",
("GET", "/v1/remote-sessions/{id}"): "get_remote_session",
("GET", "/v1/tags"): "list_tags",
("GET", "/v1/vpns"): "list_vpns",
("POST", "/v1/vpns"): "create_vpn",
("POST", "/v1/vpns/import"): "import_vpn",
("DELETE", "/v1/vpns/{id}"): "delete_vpn",
("GET", "/v1/vpns/{id}"): "get_vpn",
("PUT", "/v1/vpns/{id}"): "update_vpn",
("GET", "/v1/vpns/{id}/export"): "export_vpn",
}
#: Operations this SDK deliberately does not call, and why.
OMITTED: Dict[Operation, str] = {
(
"GET",
"/v1/remote-sessions/{id}/cdp",
): (
"A WebSocket upgrade, not a request. An HTTP client cannot speak it, and "
"bundling a websocket implementation would end this package's zero-dependency "
"promise for one endpoint. DonutClient.remote_session_cdp_url() builds the "
"ws:// address so a websocket library of the caller's choosing can connect, "
"sending the same Authorization: Bearer header on the handshake."
),
}
+240
View File
@@ -0,0 +1,240 @@
"""Exceptions raised by the Donut Browser SDK.
The local REST API answers with a plain-text body and one of a small set of
statuses. Each status means one thing, so each gets its own exception and a
caller can branch on the class instead of on a number:
=== ========================== ==================================
403 ``Forbidden`` Terms not accepted, or not signed in
400 ``ValidationError`` Malformed request, duplicate name
401 ``Unauthorized`` Missing or wrong bearer token
402 ``PaymentRequired`` Automation needs an active paid plan
404 ``NotFound`` No such profile, group, proxy, ...
408 ``RequestTimeout`` ``agent/pick`` waited and nothing was picked
409 ``Conflict`` Something else holds the profile right now
429 ``RateLimited`` Automation quota spent; see ``retry_after``
500 ``ServerError`` Internal failure
502 ``BadGateway`` The browser or relay answered wrongly
503 ``ServiceUnavailable`` Cloud, fleet or lock service unreachable
=== ========================== ==================================
Some bodies are the structured ``{"code": ..., "params": {...}}`` strings the
desktop app shares with its own frontend. When one arrives, ``code`` and
``params`` are filled in; otherwise ``code`` is ``None`` and ``body`` holds the
diagnostic text as sent.
"""
from __future__ import annotations
import json
from typing import Any, Mapping, Optional
__all__ = [
"DonutError",
"DonutConnectionError",
"DonutAPIError",
"ValidationError",
"Unauthorized",
"PaymentRequired",
"Forbidden",
"NotFound",
"RequestTimeout",
"Conflict",
"RateLimited",
"ServerError",
"BadGateway",
"ServiceUnavailable",
"error_for_status",
]
class DonutError(Exception):
"""Base class for everything this package raises."""
class DonutConnectionError(DonutError):
"""The app could not be reached at all.
Usually means the local API is switched off, is listening on another port,
or the desktop app is not running.
"""
class DonutAPIError(DonutError):
"""The app answered, and the answer was an error status."""
#: HTTP status this class is raised for. ``None`` on the base class, which
#: catches every status without a more specific subclass.
status: Optional[int] = None
def __init__(
self,
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> None:
self.status = status
self.body = body
self.method = method
self.path = path
self.headers = dict(headers or {})
self.code: Optional[str] = None
self.params: dict[str, Any] = {}
stripped = body.strip()
if stripped.startswith("{"):
try:
decoded = json.loads(stripped)
except ValueError:
decoded = None
if isinstance(decoded, dict) and isinstance(decoded.get("code"), str):
self.code = decoded["code"]
params = decoded.get("params")
if isinstance(params, dict):
self.params = params
where = f"{method} {path}".strip()
detail = self.code or stripped or "(empty body)"
super().__init__(f"{status} on {where}: {detail}" if where else f"{status}: {detail}")
class ValidationError(DonutAPIError):
"""400: the request was malformed, duplicated a name, or named something unsupported."""
status = 400
class Unauthorized(DonutAPIError):
"""401: no bearer token, the wrong one, or the local API has no token stored."""
status = 401
class PaymentRequired(DonutAPIError):
"""402: this action needs an active paid plan, or the proxy behind it lapsed."""
status = 402
class Forbidden(DonutAPIError):
"""403: the Wayfern terms are not accepted, or this desktop is not signed in."""
status = 403
class NotFound(DonutAPIError):
"""404: no entity with that id."""
status = 404
class RequestTimeout(DonutAPIError):
"""408: ``agent/pick`` waited its whole timeout and nothing was picked."""
status = 408
class Conflict(DonutAPIError):
"""409: something else holds the profile — a browser, a teammate, a remote session."""
status = 409
class RateLimited(DonutAPIError):
"""429: the shared automation quota is spent.
``retry_after`` is the number of seconds the server asked the caller to
wait, taken from the ``Retry-After`` response header. It is ``None`` only
when the header is missing or unreadable.
"""
status = 429
def __init__(
self,
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> None:
super().__init__(status, body, method=method, path=path, headers=headers)
self.retry_after: Optional[int] = None
raw = next(
(value for key, value in self.headers.items() if key.lower() == "retry-after"),
None,
)
if raw is not None:
try:
self.retry_after = int(str(raw).strip())
except ValueError:
self.retry_after = None
class ServerError(DonutAPIError):
"""500 and the other 5xx: the app, the fleet or an upstream failed.
``BadGateway`` and ``ServiceUnavailable`` derive from this, so one
``except ServerError`` catches every server-side failure.
"""
status = 500
class BadGateway(ServerError):
"""502: the browser or the relay did not answer the way it documents."""
status = 502
class ServiceUnavailable(ServerError):
"""503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
Whatever was running keeps running: a 503 from ``kill`` or from stopping a
remote session means the browser is still up, not that it stopped.
"""
status = 503
_BY_STATUS: dict[int, type[DonutAPIError]] = {
cls.status: cls
for cls in (
ValidationError,
Unauthorized,
PaymentRequired,
Forbidden,
NotFound,
RequestTimeout,
Conflict,
RateLimited,
ServerError,
BadGateway,
ServiceUnavailable,
)
if cls.status is not None
}
def error_for_status(
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> DonutAPIError:
"""Build the exception that belongs to ``status``.
A status with no class of its own becomes a plain :class:`DonutAPIError`,
so a future status added to the app still raises something a caller can
catch rather than escaping as a decode failure.
"""
cls = _BY_STATUS.get(status)
if cls is None:
cls = ServerError if status >= 500 else DonutAPIError
return cls(status, body, method=method, path=path, headers=headers)
+763
View File
@@ -0,0 +1,763 @@
"""Response shapes, spelled exactly the way the local API sends them.
Every entry here mirrors a ``ToSchema`` struct in ``src-tauri`` field for field.
A Rust ``Option<T>`` becomes a key that may be absent, expressed with the
``total=False`` half of each pair of classes, so ``dict.get`` is the honest way
to read one.
Two spellings live side by side because the app sends both. Most bodies are
snake_case; the browser-facing agent types (``LocatorDescription``,
``LocatorCandidate``, ``PerceptionPage`` and friends) carry the browser's own
camelCase, because they are handed through from the browser rather than
restated. ``AgentClick`` and ``AgentTyping`` are the exceptions inside the
agent surface: they are snake_case with a single ``match`` key. The types below
follow the wire rather than tidying it, so a value read from one call can be
passed straight into the next.
"""
from __future__ import annotations
from typing import Any, Dict, List, TypedDict
__all__ = [
"ApiProfile",
"ApiProfilesResponse",
"ApiProfileResponse",
"ApiGroupResponse",
"ApiProxyResponse",
"ApiVpnResponse",
"ApiVpnExportResponse",
"DownloadBrowserResponse",
"RunProfileResponse",
"RunRemoteResponse",
"StopRemoteResponse",
"SetCloudSyncResponse",
"RemoteSessionState",
"ApiRemoteSessionsResponse",
"RemoteHoursBreakdown",
"RemoteHoursMember",
"RemoteHoursQuota",
"CookieBotSlot",
"CookieBotSchedule",
"CookieBotScheduleList",
"CookieBotConflict",
"CookieBotScheduleSaved",
"CookieBotConflictCheck",
"CookieBotScheduleDeleted",
"CookieBotRun",
"CookieBotRunPage",
"CookieBotRunStarted",
"CookieBotPreset",
"CookieBotPresetList",
"CookieBotUsageMember",
"CookieBotUsageProfile",
"CookieBotUsage",
"BatchRunResult",
"BatchRunResponse",
"BatchStopResult",
"BatchStopResponse",
"ProxyPair",
"ProxyAssignmentResult",
"DistributeProxiesResponse",
"ImportCookiesResponse",
"ImportProxiesResponse",
"DetectedProfile",
"DetectedProfilesResponse",
"ImportProfileItem",
"ProfileImportItemResult",
"ProfileImportBatchResult",
"Extension",
"ExtensionGroup",
"LocatorAttribute",
"LocatorDescription",
"LocatorBounds",
"LocatorCandidate",
"LocatorResolution",
"PerceptionNode",
"PerceptionFrame",
"PerceptionStats",
"PerceptionPage",
"ExtractionField",
"ExtractionRow",
"Extraction",
"PickedElement",
"AgentClick",
"AgentTyping",
]
# The app's own JSON for a proxy's settings. Declared `Object` in the OpenAPI
# document rather than a struct, so it is passed through untouched.
ProxySettings = Dict[str, Any]
# A Wayfern fingerprint/config blob. Also declared `Object` in the document.
WayfernConfig = Dict[str, Any]
class _ApiProfileRequired(TypedDict):
id: str
name: str
browser: str
version: str
release_type: str
tags: List[str]
is_running: bool
proxy_bypass_rules: List[str]
ephemeral: bool
temporary: bool
clear_on_close: bool
sync_mode: str
cloud_sync_enabled: bool
is_cross_os: bool
class ApiProfile(_ApiProfileRequired, total=False):
proxy_id: str
launch_hook: str
process_id: int
last_launch: int
group_id: str
vpn_id: str
extension_group_id: str
host_os: str
fingerprint_os: str
class ApiProfilesResponse(TypedDict):
profiles: List[ApiProfile]
total: int
class ApiProfileResponse(TypedDict):
profile: ApiProfile
class ApiGroupResponse(TypedDict):
id: str
name: str
profile_count: int
class ApiProxyResponse(TypedDict):
id: str
name: str
proxy_settings: ProxySettings
class _ApiVpnRequired(TypedDict):
id: str
name: str
vpn_type: str
created_at: int
class ApiVpnResponse(_ApiVpnRequired, total=False):
last_used: int
class ApiVpnExportResponse(TypedDict):
id: str
name: str
vpn_type: str
config_data: str
class DownloadBrowserResponse(TypedDict):
browser: str
version: str
status: str
class RunProfileResponse(TypedDict):
profile_id: str
remote_debugging_port: int
headless: bool
class RunRemoteResponse(TypedDict):
profile_id: str
session_id: str
platform: str
status: str
class StopRemoteResponse(TypedDict):
session_id: str
status: str
billed_seconds: int
class _SetCloudSyncRequired(TypedDict):
profile_id: str
mode: str
remote_launchable: bool
class SetCloudSyncResponse(_SetCloudSyncRequired, total=False):
remote_blocked_reason: str
class _RemoteSessionStateRequired(TypedDict):
session_id: str
state: str
class RemoteSessionState(_RemoteSessionStateRequired, total=False):
profile_id: str
platform: str
cdp_ready: bool
kind: str
run_id: str
team_id: str
started_at: str
ended_at: str
close_reason: str
billed_seconds: int
class ApiRemoteSessionsResponse(TypedDict):
sessions: List[RemoteSessionState]
class RemoteHoursBreakdown(TypedDict, total=False):
interactive_hours: float
bot_hours: float
class _RemoteHoursMemberRequired(TypedDict):
user_id: str
email: str
class RemoteHoursMember(_RemoteHoursMemberRequired, total=False):
role: str
used_hours: float
interactive_hours: float
bot_hours: float
class _RemoteHoursQuotaRequired(TypedDict):
granted_hours: float
remaining_hours: float
class RemoteHoursQuota(_RemoteHoursQuotaRequired, total=False):
used_hours: float
period_start: str
period_end: str
scope: str
team_id: str
seats: int
per_seat_hours: float
breakdown: RemoteHoursBreakdown
members: List[RemoteHoursMember]
class CookieBotSlot(TypedDict, total=False):
run_at_minute: int
days_mask: int
class _CookieBotScheduleRequired(TypedDict):
profile_id: str
profile_name: str
platform: str
enabled: bool
run_at_minute: int
days_mask: int
timezone: str
preset: str
max_minutes: int
class CookieBotSchedule(_CookieBotScheduleRequired, total=False):
slots: List[CookieBotSlot]
template_id: str
sites: List[str]
jitter_seconds: int
sync_enabled: bool
encrypted_sync: bool
has_proxy: bool
proxy_remote_reachable: bool
touch_fingerprint: bool
sticky_exit: bool
profile_state_at: str
blocked_by: str
next_run_at: str
last_run_at: str
last_run_id: str
owner_user_id: str
owner_email: str
updated_at: str
class CookieBotScheduleList(TypedDict, total=False):
schedules: List[CookieBotSchedule]
team_id: str
scope: str
class _CookieBotConflictRequired(TypedDict):
user_id: str
email: str
run_at_minute: int
timezone: str
days_mask: int
enabled: bool
class CookieBotConflict(_CookieBotConflictRequired, total=False):
overlaps: bool
class _CookieBotScheduleSavedRequired(TypedDict):
schedule: CookieBotSchedule
class CookieBotScheduleSaved(_CookieBotScheduleSavedRequired, total=False):
conflicts: List[CookieBotConflict]
class _CookieBotConflictCheckRequired(TypedDict):
profile_id: str
class CookieBotConflictCheck(_CookieBotConflictCheckRequired, total=False):
conflicts: List[CookieBotConflict]
class CookieBotScheduleDeleted(TypedDict):
profile_id: str
deleted: bool
class _CookieBotRunRequired(TypedDict):
id: str
profile_id: str
trigger: str
status: str
scheduled_for: str
class CookieBotRun(_CookieBotRunRequired, total=False):
profile_name: str
user_id: str
email: str
team_id: str
dispatch_after: str
started_at: str
ended_at: str
max_minutes: int
chunks_total: int
chunk_index: int
sites_total: int
sites_visited: int
sites_failed: int
consent_dismissed: int
billed_seconds: int
outcome_code: str
session_id: str
class CookieBotRunPage(TypedDict, total=False):
runs: List[CookieBotRun]
next_before: str
class _CookieBotRunStartedRequired(TypedDict):
run: CookieBotRun
class CookieBotRunStarted(_CookieBotRunStartedRequired, total=False):
session_id: str
class _CookieBotPresetRequired(TypedDict):
id: str
class CookieBotPreset(_CookieBotPresetRequired, total=False):
typical_minutes: int
recommended: bool
name: str
description: str
class CookieBotPresetList(TypedDict, total=False):
presets: List[CookieBotPreset]
default_preset: str
# `templates` and `limits` are whatever the server publishes; the app
# forwards them without narrowing, so neither is spelled out here.
templates: List[Dict[str, Any]]
limits: Dict[str, Any]
class _CookieBotUsageMemberRequired(TypedDict):
user_id: str
email: str
class CookieBotUsageMember(_CookieBotUsageMemberRequired, total=False):
role: str
interactive_hours: float
bot_hours: float
used_hours: float
sessions: int
bot_runs: int
bot_runs_failed: int
class _CookieBotUsageProfileRequired(TypedDict):
profile_id: str
class CookieBotUsageProfile(_CookieBotUsageProfileRequired, total=False):
profile_name: str
owner_email: str
bot_hours: float
runs: int
runs_failed: int
last_run_at: str
last_status: str
class _CookieBotUsageRequired(TypedDict):
period: str
class CookieBotUsage(_CookieBotUsageRequired, total=False):
period_start: str
period_end: str
team_id: str
seats: int
granted_hours: float
used_hours: float
remaining_hours: float
members: List[CookieBotUsageMember]
profiles: List[CookieBotUsageProfile]
class _BatchRunResultRequired(TypedDict):
profile_id: str
ok: bool
class BatchRunResult(_BatchRunResultRequired, total=False):
remote_debugging_port: int
error: str
class BatchRunResponse(TypedDict):
results: List[BatchRunResult]
class _BatchStopResultRequired(TypedDict):
profile_id: str
ok: bool
class BatchStopResult(_BatchStopResultRequired, total=False):
error: str
class BatchStopResponse(TypedDict):
results: List[BatchStopResult]
class _ProxyAssignmentResultRequired(TypedDict):
profile_id: str
proxy_id: str
ok: bool
class ProxyAssignmentResult(_ProxyAssignmentResultRequired, total=False):
"""``error`` is a ``{"code": ...}`` payload when ``ok`` is false."""
error: str
class ProxyPair(TypedDict):
"""One profile, one proxy. The distribution applies exactly these pairs."""
profile_id: str
proxy_id: str
class DistributeProxiesResponse(TypedDict):
results: List[ProxyAssignmentResult]
class ImportCookiesResponse(TypedDict):
cookies_imported: int
cookies_replaced: int
errors: List[str]
class ImportProxiesResponse(TypedDict):
imported_count: int
skipped_count: int
errors: List[str]
proxies: List[ApiProxyResponse]
class DetectedProfile(TypedDict):
browser: str
mapped_browser: str
name: str
path: str
description: str
class DetectedProfilesResponse(TypedDict):
profiles: List[DetectedProfile]
total: int
class _ImportProfileItemRequired(TypedDict):
source_path: str
new_profile_name: str
class ImportProfileItem(_ImportProfileItemRequired, total=False):
"""One item of ``import_profiles``.
``browser_type`` defaults to the app's own default when absent, and it is
load-bearing: it picks which keychain entry unlocks the source's cookies
and passwords.
"""
browser_type: str
proxy_id: str
vpn_id: str
allow_running: bool
class _ProfileImportItemResultRequired(TypedDict):
name: str
source_path: str
status: str
class ProfileImportItemResult(_ProfileImportItemResultRequired, total=False):
profile_id: str
error: str
report: Dict[str, Any]
class ProfileImportBatchResult(TypedDict):
imported_count: int
skipped_count: int
failed_count: int
results: List[ProfileImportItemResult]
class _ExtensionRequired(TypedDict):
id: str
name: str
file_name: str
file_type: str
browser_compatibility: List[str]
created_at: int
updated_at: int
source_kind: str
class Extension(_ExtensionRequired, total=False):
manifest_name: str
sync_enabled: bool
last_sync: int
version: str
description: str
author: str
homepage_url: str
linked_path: str
class _ExtensionGroupRequired(TypedDict):
id: str
name: str
extension_ids: List[str]
created_at: int
updated_at: int
class ExtensionGroup(_ExtensionGroupRequired, total=False):
sync_enabled: bool
last_sync: int
class LocatorAttribute(TypedDict):
name: str
value: str
class LocatorDescription(TypedDict, total=False):
"""How an element is named without a CSS selector.
At least one key must be set. Keys are the browser's own camelCase; the
app also accepts ``name_contains`` and ``text_contains`` on input, but a
locator handed back by ``agent_pick`` uses the spellings below, so reusing
one verbatim is the reliable path.
"""
role: str
name: str
nameContains: str
text: str
textContains: str
attributes: List[LocatorAttribute]
class LocatorBounds(TypedDict):
x: float
y: float
width: float
height: float
class _LocatorCandidateRequired(TypedDict):
role: str
name: str
text: str
signature: str
bounds: LocatorBounds
class LocatorCandidate(_LocatorCandidateRequired, total=False):
backendNodeId: int
value: str
url: str
attributes: List[LocatorAttribute]
class _LocatorResolutionRequired(TypedDict):
matchCount: int
# `match` is the key the app sends. It is a soft keyword in Python, so it
# is spelled here exactly as it arrives.
match: LocatorCandidate
locator: LocatorDescription
engine: str
class LocatorResolution(_LocatorResolutionRequired, total=False):
backendNodeId: int
class _PerceptionNodeRequired(TypedDict):
id: str
frameId: str
role: str
x: float
y: float
width: float
height: float
inViewport: bool
visible: bool
focused: bool
disabled: bool
class PerceptionNode(_PerceptionNodeRequired, total=False):
parentId: str
name: str
text: str
value: str
checked: str
expanded: bool
scrollable: bool
scrollContainerId: str
class _PerceptionFrameRequired(TypedDict):
frameId: str
url: str
crossOrigin: bool
class PerceptionFrame(_PerceptionFrameRequired, total=False):
parentFrameId: str
class PerceptionStats(TypedDict):
totalNodes: int
returnedNodes: int
bytes: int
elapsedMs: int
framesVisited: int
framesFailed: int
class _PerceptionPageRequired(TypedDict):
snapshotId: str
nodes: List[PerceptionNode]
frames: List[PerceptionFrame]
text: str
truncated: bool
stats: PerceptionStats
engine: str
class PerceptionPage(_PerceptionPageRequired, total=False):
cursor: str
class _ExtractionFieldRequired(TypedDict):
key: str
locator: LocatorDescription
source: str
class ExtractionField(_ExtractionFieldRequired, total=False):
"""One output column. ``attribute`` is required when ``source`` is ``"attribute"``."""
attribute: str
class ExtractionRow(TypedDict):
index: int
page: int
values: Dict[str, Any]
class Extraction(TypedDict):
rows: List[ExtractionRow]
rowCount: int
pageCount: int
byteSize: int
truncated: bool
stopReason: str
engine: str
class PickedElement(TypedDict):
backendNodeId: int
locator: LocatorDescription
matchCount: int
node: LocatorCandidate
engine: str
class AgentClick(TypedDict):
"""What a click did. Note the snake_case body and the ``match`` key."""
clicked: bool
match: LocatorCandidate
engine: str
navigated: bool
class _AgentTypingRequired(TypedDict):
typed: bool
characters: int
duration_ms: float
engine: str
match: LocatorCandidate
class AgentTyping(_AgentTypingRequired, total=False):
"""What a typing call did.
``corrections`` is absent on the fallback engine, which does not count its
own mistypes.
"""
corrections: int
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Iterator
import pytest
# Run against the working tree without an install step, so `pytest` works
# straight after a checkout.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from donutbrowser import DonutClient # noqa: E402
from fake_donut import FakeDonut # noqa: E402
TOKEN = "test-token-abc123"
@pytest.fixture
def fake() -> Iterator[FakeDonut]:
server = FakeDonut().start()
try:
yield server
finally:
server.stop()
@pytest.fixture
def client(fake: FakeDonut) -> Iterator[DonutClient]:
with DonutClient(token=TOKEN, port=fake.port, timeout=5.0, env={}) as connected:
yield connected
+144
View File
@@ -0,0 +1,144 @@
"""A stand-in for the desktop app's local REST API.
It records what the client sent, byte for byte, and answers with whatever the
test queued. Nothing here reaches the network: it binds an ephemeral loopback
port and is torn down with the test.
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlsplit
@dataclass
class RecordedRequest:
method: str
target: str
headers: Dict[str, str]
body: bytes
@property
def path(self) -> str:
return urlsplit(self.target).path
@property
def query(self) -> Dict[str, str]:
return dict(parse_qsl(urlsplit(self.target).query, keep_blank_values=True))
@property
def json(self) -> Any:
if not self.body:
return None
return json.loads(self.body.decode("utf-8"))
def header(self, name: str) -> Optional[str]:
for key, value in self.headers.items():
if key.lower() == name.lower():
return value
return None
@dataclass
class QueuedResponse:
status: int = 200
body: str = ""
headers: Tuple[Tuple[str, str], ...] = ()
content_type: str = "application/json"
@dataclass
class FakeDonut:
"""Queue responses, then read :attr:`requests` back."""
requests: List[RecordedRequest] = field(default_factory=list)
responses: List[QueuedResponse] = field(default_factory=list)
_server: Optional[ThreadingHTTPServer] = None
_thread: Optional[threading.Thread] = None
def enqueue_json(self, payload: Any, status: int = 200) -> None:
self.responses.append(QueuedResponse(status=status, body=json.dumps(payload)))
def enqueue_empty(self, status: int = 204) -> None:
self.responses.append(QueuedResponse(status=status, body=""))
def enqueue_error(
self,
status: int,
body: str = "",
headers: Tuple[Tuple[str, str], ...] = (),
) -> None:
self.responses.append(
QueuedResponse(status=status, body=body, headers=headers, content_type="text/plain")
)
@property
def port(self) -> int:
assert self._server is not None, "the fake server is not running"
return self._server.server_address[1]
@property
def last(self) -> RecordedRequest:
assert self.requests, "the client sent nothing"
return self.requests[-1]
def start(self) -> "FakeDonut":
fake = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_args: Any) -> None:
"""Keep the test output clean."""
def _handle(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
fake.requests.append(
RecordedRequest(
method=self.command,
target=self.path,
headers={key: value for key, value in self.headers.items()},
body=body,
)
)
queued = fake.responses.pop(0) if fake.responses else QueuedResponse(body="{}")
payload = queued.body.encode("utf-8")
self.send_response(queued.status)
for name, value in queued.headers:
self.send_header(name, value)
if payload:
self.send_header("Content-Type", queued.content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
if payload:
self.wfile.write(payload)
do_GET = _handle
do_POST = _handle
do_PUT = _handle
do_DELETE = _handle
do_PATCH = _handle
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
# A short poll interval so `shutdown()` returns promptly: the default
# 0.5s would add half a second to the teardown of every single test.
self._thread = threading.Thread(
target=self._server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
)
self._thread.start()
return self
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._thread is not None:
self._thread.join(timeout=5)
self._thread = None
+83
View File
@@ -0,0 +1,83 @@
"""Where the token and the port come from, and in what order."""
from __future__ import annotations
import pytest
from fake_donut import FakeDonut
from donutbrowser import DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError
def test_arguments_are_used_as_given() -> None:
client = DonutClient(token="from-argument", port=12345, env={})
assert client.token == "from-argument"
assert client.port == 12345
assert client.host == DEFAULT_HOST
assert client.base_url == "http://127.0.0.1:12345"
def test_the_environment_fills_in_what_was_not_passed() -> None:
client = DonutClient(env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "13579"})
assert client.token == "from-env"
assert client.port == 13579
def test_arguments_win_over_the_environment() -> None:
client = DonutClient(
token="from-argument",
port=111,
env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "222"},
)
assert client.token == "from-argument"
assert client.port == 111
def test_the_port_falls_back_to_the_app_default() -> None:
client = DonutClient(env={"DONUT_API_TOKEN": "t"})
assert client.port == DEFAULT_PORT == 10108
def test_a_base_url_overrides_host_and_port() -> None:
client = DonutClient(
base_url="http://127.0.0.1:9999/donut",
token="t",
env={"DONUT_API_PORT": "222"},
)
assert client.port == 9999
assert client.base_url == "http://127.0.0.1:9999/donut"
def test_a_base_url_prefix_is_kept_on_every_path(fake: FakeDonut) -> None:
with DonutClient(
base_url=f"http://127.0.0.1:{fake.port}/donut", token="t", timeout=5.0, env={}
) as client:
client.list_profiles()
assert fake.last.path == "/donut/v1/profiles"
def test_an_unusable_port_in_the_environment_is_reported() -> None:
with pytest.raises(DonutError) as raised:
DonutClient(env={"DONUT_API_TOKEN": "t", "DONUT_API_PORT": "not-a-number"})
assert "DONUT_API_PORT" in str(raised.value)
def test_an_unsupported_scheme_is_refused() -> None:
with pytest.raises(DonutError):
DonutClient(base_url="ftp://127.0.0.1:9999", token="t", env={})
def test_the_websocket_address_is_built_from_the_same_base() -> None:
client = DonutClient(token="t", port=10108, env={})
assert (
client.remote_session_cdp_url("s 1")
== "ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp"
)
def test_a_reopened_client_still_works(fake: FakeDonut) -> None:
"""`close()` drops the socket; the next call has to open a new one."""
with DonutClient(token="t", port=fake.port, timeout=5.0, env={}) as client:
client.list_profiles()
client.close()
client.list_profiles()
assert len(fake.requests) == 2
+73
View File
@@ -0,0 +1,73 @@
"""The SDK cannot silently drift from the app's API.
``sdk/api-paths.json`` is generated from ``src-tauri/src/api_server.rs`` and
lists every operation the desktop app publishes. These tests hold it against
the SDK's own table in both directions, so a new endpoint in the app fails here
until it is wrapped or deliberately omitted with a reason.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Set, Tuple
from donutbrowser import DonutClient
from donutbrowser.coverage import OMITTED, OPERATIONS
SNAPSHOT = Path(__file__).resolve().parents[2] / "api-paths.json"
def published() -> Set[Tuple[str, str]]:
document: Dict[str, Any] = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
return {
(operation["method"], operation["path"]) for operation in document["operations"]
}
def test_the_snapshot_is_readable_and_not_empty() -> None:
document = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
assert document["source"] == "src-tauri/src/api_server.rs"
assert document["operation_count"] == len(document["operations"])
assert document["operation_count"] > 0
assert len(published()) == document["operation_count"], "the app has two identical operations"
def test_every_published_operation_is_wrapped_or_omitted() -> None:
known = set(OPERATIONS) | set(OMITTED)
missing = sorted(published() - known)
assert not missing, (
"the app publishes operations this SDK does not handle: "
f"{missing}. Wrap each one, or add it to coverage.OMITTED with a reason."
)
def test_the_sdk_claims_nothing_the_app_does_not_publish() -> None:
stale = sorted((set(OPERATIONS) | set(OMITTED)) - published())
assert not stale, (
"this SDK handles operations the app no longer publishes: "
f"{stale}. Regenerate the snapshot with sdk/tools/extract-api-paths.py, "
"then drop or fix each entry."
)
def test_an_operation_is_either_wrapped_or_omitted_but_not_both() -> None:
both = sorted(set(OPERATIONS) & set(OMITTED))
assert not both, f"listed twice: {both}"
def test_every_omission_gives_a_reason() -> None:
for operation, reason in OMITTED.items():
assert len(reason.strip()) > 40, f"{operation} is omitted without a real reason"
def test_every_wrapped_operation_names_a_real_method() -> None:
for operation, method_name in OPERATIONS.items():
attribute = getattr(DonutClient, method_name, None)
assert callable(attribute), f"{operation} names {method_name}, which is not a method"
def test_no_two_operations_share_a_method() -> None:
names = list(OPERATIONS.values())
duplicates = sorted({name for name in names if names.count(name) > 1})
assert not duplicates, f"one method is claimed by several operations: {duplicates}"
+168
View File
@@ -0,0 +1,168 @@
"""Each status the app documents raises its own exception."""
from __future__ import annotations
import json
import pytest
from fake_donut import FakeDonut, QueuedResponse
from donutbrowser import (
BadGateway,
Conflict,
DonutAPIError,
DonutClient,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
)
STATUS_TO_ERROR = [
(400, ValidationError),
(401, Unauthorized),
(402, PaymentRequired),
(403, Forbidden),
(404, NotFound),
(408, RequestTimeout),
(409, Conflict),
(429, RateLimited),
(500, ServerError),
(502, BadGateway),
(503, ServiceUnavailable),
]
@pytest.mark.parametrize("status,expected", STATUS_TO_ERROR)
def test_status_maps_to_its_exception(
client: DonutClient, fake: FakeDonut, status: int, expected: type
) -> None:
fake.enqueue_error(status, "something went wrong")
with pytest.raises(expected) as raised:
client.list_profiles()
assert raised.value.status == status
assert raised.value.body == "something went wrong"
assert raised.value.method == "GET"
assert raised.value.path == "/v1/profiles"
def test_every_error_is_a_donut_error(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(404, "PROFILE_NOT_FOUND")
with pytest.raises(DonutError):
client.get_profile("nope")
def test_the_five_hundreds_share_one_base(client: DonutClient, fake: FakeDonut) -> None:
"""`except ServerError` has to catch 502 and 503 as well as 500."""
for status in (500, 502, 503):
fake.enqueue_error(status, "upstream")
with pytest.raises(ServerError):
client.list_profiles()
def test_rate_limited_carries_retry_after(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(
429,
"automation request rate limit exceeded",
headers=(("Retry-After", "42"),),
)
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after == 42
def test_rate_limited_without_the_header_is_still_raised(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(429, "slow down")
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after is None
def test_an_unreadable_retry_after_does_not_break_the_error(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(429, "slow down", headers=(("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT"),))
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after is None
def test_a_structured_code_body_is_decoded(client: DonutClient, fake: FakeDonut) -> None:
"""The app shares `{"code": ...}` strings with its own frontend."""
fake.enqueue_error(400, json.dumps({"code": "NAME_CANNOT_BE_EMPTY"}))
with pytest.raises(ValidationError) as raised:
client.create_group(name="")
assert raised.value.code == "NAME_CANNOT_BE_EMPTY"
assert raised.value.params == {}
def test_a_structured_code_body_keeps_its_params(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(409, json.dumps({"code": "PROFILE_LOCKED_BY_MEMBER", "params": {"n": "5"}}))
with pytest.raises(Conflict) as raised:
client.run_profile("p1")
assert raised.value.code == "PROFILE_LOCKED_BY_MEMBER"
assert raised.value.params == {"n": "5"}
def test_a_plain_text_body_leaves_code_unset(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(400, "invalid browser")
with pytest.raises(ValidationError) as raised:
client.create_profile(name="x", browser="chromium")
assert raised.value.code is None
assert raised.value.body == "invalid browser"
def test_an_undocumented_status_still_raises_something_catchable(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(418, "teapot")
with pytest.raises(DonutAPIError) as raised:
client.list_profiles()
assert raised.value.status == 418
def test_an_undocumented_server_status_is_a_server_error(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(504, "gateway timeout")
with pytest.raises(ServerError):
client.list_profiles()
def test_the_message_names_the_call(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(404, "Profile not found")
with pytest.raises(NotFound) as raised:
client.get_profile("missing")
assert "404" in str(raised.value)
assert "GET /v1/profiles/missing" in str(raised.value)
def test_an_unreachable_app_is_not_an_api_error(fake: FakeDonut) -> None:
port = fake.port
fake.stop()
with DonutClient(token="t", port=port, timeout=2.0, env={}) as client:
with pytest.raises(DonutConnectionError) as raised:
client.list_profiles()
assert "Local API" in str(raised.value)
def test_a_missing_token_fails_before_any_request() -> None:
with pytest.raises(DonutError) as raised:
DonutClient(env={})
assert "DONUT_API_TOKEN" in str(raised.value)
def test_a_non_json_answer_is_reported_as_such(client: DonutClient, fake: FakeDonut) -> None:
fake.responses.append(QueuedResponse(status=200, body="<html>nope</html>"))
with pytest.raises(DonutError) as raised:
client.list_profiles()
assert "not JSON" in str(raised.value)
+721
View File
@@ -0,0 +1,721 @@
"""Every client method sends exactly the request the app documents.
The table below is the whole public surface. Each row names a method, the
arguments to call it with, and the request that must appear on the wire: the
verb, the concrete path, the query string and the JSON body. ``operation`` is
the path template the app publishes, which ties this file to
``donutbrowser.coverage.OPERATIONS`` and, through it, to ``sdk/api-paths.json``.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Tuple
import pytest
from fake_donut import FakeDonut
from donutbrowser import DonutClient
from donutbrowser.coverage import OPERATIONS
Case = Tuple[
str, # client method
Tuple[Any, ...], # positional arguments
Dict[str, Any], # keyword arguments
str, # expected verb
str, # expected concrete path
Optional[Dict[str, Any]], # expected JSON body, or None for no body
Dict[str, str], # expected query string
str, # operation template, as published by the app
]
LOCATOR = {"role": "button", "name": "Sign in"}
CASES: List[Case] = [
# -- profiles ----------------------------------------------------------
("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"),
("get_profile", ("p1",), {}, "GET", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"create_profile",
(),
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
"POST",
"/v1/profiles",
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
{},
"/v1/profiles",
),
(
"create_profile",
(),
{"name": "Bare", "browser": "wayfern"},
"POST",
"/v1/profiles",
{"name": "Bare", "browser": "wayfern"},
{},
"/v1/profiles",
),
(
"update_profile",
("p1",),
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
"PUT",
"/v1/profiles/p1",
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
{},
"/v1/profiles/{id}",
),
("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"run_profile",
("p1",),
{"url": "https://example.com", "headless": True},
"POST",
"/v1/profiles/p1/run",
{"url": "https://example.com", "headless": True},
{},
"/v1/profiles/{id}/run",
),
(
"run_profile_remote",
("p1",),
{"url": "https://example.com"},
"POST",
"/v1/profiles/p1/run-remote",
{"url": "https://example.com"},
{},
"/v1/profiles/{id}/run-remote",
),
(
"set_profile_cloud_sync",
("p1",),
{"mode": "Regular"},
"POST",
"/v1/profiles/p1/cloud-sync",
{"mode": "Regular"},
{},
"/v1/profiles/{id}/cloud-sync",
),
(
"open_url",
("p1", "https://example.com/page"),
{},
"POST",
"/v1/profiles/p1/open-url",
{"url": "https://example.com/page"},
{},
"/v1/profiles/{id}/open-url",
),
(
"kill_profile",
("p1",),
{},
"POST",
"/v1/profiles/p1/kill",
None,
{},
"/v1/profiles/{id}/kill",
),
(
"batch_run_profiles",
(["p1", "p2"],),
{"headless": False},
"POST",
"/v1/profiles/batch/run",
{"profile_ids": ["p1", "p2"], "headless": False},
{},
"/v1/profiles/batch/run",
),
(
"batch_stop_profiles",
(["p1", "p2"],),
{},
"POST",
"/v1/profiles/batch/stop",
{"profile_ids": ["p1", "p2"]},
{},
"/v1/profiles/batch/stop",
),
(
"distribute_proxies",
([{"profile_id": "p1", "proxy_id": "x1"}, {"profile_id": "p2", "proxy_id": "x2"}],),
{},
"POST",
"/v1/profiles/distribute-proxies",
{
"pairs": [
{"profile_id": "p1", "proxy_id": "x1"},
{"profile_id": "p2", "proxy_id": "x2"},
]
},
{},
"/v1/profiles/distribute-proxies",
),
(
"detect_import_profiles",
(),
{"folder": "/Users/x/Chrome"},
"GET",
"/v1/profiles/import/detect",
None,
{"folder": "/Users/x/Chrome"},
"/v1/profiles/import/detect",
),
(
"detect_import_profiles",
(),
{},
"GET",
"/v1/profiles/import/detect",
None,
{},
"/v1/profiles/import/detect",
),
(
"import_profiles",
([{"source_path": "/tmp/src", "new_profile_name": "Imported"}],),
{"duplicate_strategy": "skip"},
"POST",
"/v1/profiles/import",
{
"items": [{"source_path": "/tmp/src", "new_profile_name": "Imported"}],
"duplicate_strategy": "skip",
},
{},
"/v1/profiles/import",
),
(
"import_profile_cookies",
("p1",),
{"content": "[]"},
"POST",
"/v1/profiles/p1/cookies/import",
{"content": "[]"},
{},
"/v1/profiles/{id}/cookies/import",
),
# -- agent -------------------------------------------------------------
(
"agent_perceive",
("p1",),
{"viewport_only": True, "max_bytes": 2048},
"POST",
"/v1/profiles/p1/agent/perceive",
{"max_bytes": 2048, "viewport_only": True},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_perceive",
("p1",),
{},
"POST",
"/v1/profiles/p1/agent/perceive",
{},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_resolve_locator",
("p1",),
{"locator": LOCATOR, "candidate_limit": 5},
"POST",
"/v1/profiles/p1/agent/resolve-locator",
{"locator": LOCATOR, "candidate_limit": 5},
{},
"/v1/profiles/{id}/agent/resolve-locator",
),
(
"agent_click",
("p1",),
{"locator": LOCATOR, "button": "right", "click_count": 2},
"POST",
"/v1/profiles/p1/agent/click",
{"locator": LOCATOR, "button": "right", "click_count": 2},
{},
"/v1/profiles/{id}/agent/click",
),
(
"agent_type",
("p1",),
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
"POST",
"/v1/profiles/p1/agent/type",
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
{},
"/v1/profiles/{id}/agent/type",
),
(
"agent_extract",
("p1",),
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
"POST",
"/v1/profiles/p1/agent/extract",
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
{},
"/v1/profiles/{id}/agent/extract",
),
(
"agent_pick",
("p1",),
{"timeout_ms": 15000},
"POST",
"/v1/profiles/p1/agent/pick",
{"timeout_ms": 15000},
{},
"/v1/profiles/{id}/agent/pick",
),
# -- remote sessions ---------------------------------------------------
(
"list_remote_sessions",
(),
{},
"GET",
"/v1/remote-sessions",
None,
{},
"/v1/remote-sessions",
),
(
"get_remote_session",
("s1",),
{},
"GET",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
(
"stop_remote_session",
("s1",),
{},
"DELETE",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
("get_remote_hours", (), {}, "GET", "/v1/remote-hours", None, {}, "/v1/remote-hours"),
# -- cookie bot --------------------------------------------------------
(
"list_cookie_bot_schedules",
(),
{"scope": "team"},
"GET",
"/v1/cookie-bot/schedules",
None,
{"scope": "team"},
"/v1/cookie-bot/schedules",
),
(
"get_cookie_bot_schedule",
("p1",),
{},
"GET",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"set_cookie_bot_schedule",
("p1",),
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
"PUT",
"/v1/cookie-bot/schedules/p1",
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"delete_cookie_bot_schedule",
("p1",),
{},
"DELETE",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"get_cookie_bot_conflicts",
("p1",),
{"run_at_minute": 90, "timezone": "UTC", "days_mask": 7},
"GET",
"/v1/cookie-bot/conflicts",
None,
{"profile_id": "p1", "run_at_minute": "90", "timezone": "UTC", "days_mask": "7"},
"/v1/cookie-bot/conflicts",
),
(
"list_cookie_bot_runs",
(),
{"profile_id": "p1", "limit": 10, "before": "cursor-1"},
"GET",
"/v1/cookie-bot/runs",
None,
{"profile_id": "p1", "limit": "10", "before": "cursor-1"},
"/v1/cookie-bot/runs",
),
(
"start_cookie_bot_run",
(),
{"profile_id": "p1", "max_minutes": 30},
"POST",
"/v1/cookie-bot/runs",
{"profile_id": "p1", "max_minutes": 30},
{},
"/v1/cookie-bot/runs",
),
(
"cancel_cookie_bot_run",
("r1",),
{},
"DELETE",
"/v1/cookie-bot/runs/r1",
None,
{},
"/v1/cookie-bot/runs/{run_id}",
),
(
"list_cookie_bot_presets",
(),
{},
"GET",
"/v1/cookie-bot/presets",
None,
{},
"/v1/cookie-bot/presets",
),
(
"get_cookie_bot_usage",
(),
{"period": "2026-08"},
"GET",
"/v1/cookie-bot/usage",
None,
{"period": "2026-08"},
"/v1/cookie-bot/usage",
),
# -- groups and tags ---------------------------------------------------
("list_groups", (), {}, "GET", "/v1/groups", None, {}, "/v1/groups"),
("get_group", ("g1",), {}, "GET", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("create_group", (), {"name": "Retail"}, "POST", "/v1/groups", {"name": "Retail"}, {}, "/v1/groups"),
(
"update_group",
("g1",),
{"name": "Retail EU"},
"PUT",
"/v1/groups/g1",
{"name": "Retail EU"},
{},
"/v1/groups/{id}",
),
("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"),
# -- proxies -----------------------------------------------------------
("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"),
("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"create_proxy",
(),
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
"POST",
"/v1/proxies",
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
{},
"/v1/proxies",
),
(
"update_proxy",
("x1",),
{"name": "EU 2"},
"PUT",
"/v1/proxies/x1",
{"name": "EU 2"},
{},
"/v1/proxies/{id}",
),
("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"import_proxies",
(),
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
"POST",
"/v1/proxies/import",
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
{},
"/v1/proxies/import",
),
# -- vpns --------------------------------------------------------------
("list_vpns", (), {}, "GET", "/v1/vpns", None, {}, "/v1/vpns"),
("get_vpn", ("v1",), {}, "GET", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
("export_vpn", ("v1",), {}, "GET", "/v1/vpns/v1/export", None, {}, "/v1/vpns/{id}/export"),
(
"import_vpn",
(),
{"content": "[Interface]", "filename": "eu.conf"},
"POST",
"/v1/vpns/import",
{"content": "[Interface]", "filename": "eu.conf"},
{},
"/v1/vpns/import",
),
(
"create_vpn",
(),
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
"POST",
"/v1/vpns",
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
{},
"/v1/vpns",
),
(
"update_vpn",
("v1",),
{"name": "EU 2"},
"PUT",
"/v1/vpns/v1",
{"name": "EU 2"},
{},
"/v1/vpns/{id}",
),
("delete_vpn", ("v1",), {}, "DELETE", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
# -- extensions --------------------------------------------------------
("list_extensions", (), {}, "GET", "/v1/extensions", None, {}, "/v1/extensions"),
("get_extension", ("e1",), {}, "GET", "/v1/extensions/e1", None, {}, "/v1/extensions/{id}"),
(
"create_extension",
(),
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
"POST",
"/v1/extensions",
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
{},
"/v1/extensions",
),
(
"update_extension",
("e1",),
{"name": "Blocker 2", "link": True},
"PUT",
"/v1/extensions/e1",
{"name": "Blocker 2", "link": True},
{},
"/v1/extensions/{id}",
),
(
"delete_extension",
("e1",),
{},
"DELETE",
"/v1/extensions/e1",
None,
{},
"/v1/extensions/{id}",
),
(
"list_extension_groups",
(),
{},
"GET",
"/v1/extension-groups",
None,
{},
"/v1/extension-groups",
),
(
"get_extension_group",
("eg1",),
{},
"GET",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"create_extension_group",
(),
{"name": "Adblock set"},
"POST",
"/v1/extension-groups",
{"name": "Adblock set"},
{},
"/v1/extension-groups",
),
(
"update_extension_group",
("eg1",),
{"extension_ids": ["e1", "e2"]},
"PUT",
"/v1/extension-groups/eg1",
{"extension_ids": ["e1", "e2"]},
{},
"/v1/extension-groups/{id}",
),
(
"delete_extension_group",
("eg1",),
{},
"DELETE",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"add_extension_to_group",
("eg1", "e1"),
{},
"POST",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
(
"remove_extension_from_group",
("eg1", "e1"),
{},
"DELETE",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
# -- browsers ----------------------------------------------------------
(
"download_browser",
(),
{"browser": "wayfern", "version": "152.0.1"},
"POST",
"/v1/browsers/download",
{"browser": "wayfern", "version": "152.0.1"},
{},
"/v1/browsers/download",
),
(
"list_browser_versions",
("wayfern",),
{},
"GET",
"/v1/browsers/wayfern/versions",
None,
{},
"/v1/browsers/{browser}/versions",
),
(
"is_browser_downloaded",
("wayfern", "152.0.1"),
{},
"GET",
"/v1/browsers/wayfern/versions/152.0.1/downloaded",
None,
{},
"/v1/browsers/{browser}/versions/{version}/downloaded",
),
]
@pytest.mark.parametrize(
"case", CASES, ids=[f"{case[0]}[{index}]" for index, case in enumerate(CASES)]
)
def test_method_sends_the_documented_request(
client: DonutClient, fake: FakeDonut, case: Case
) -> None:
name, args, kwargs, verb, path, body, query, operation = case
getattr(client, name)(*args, **kwargs)
sent = fake.last
assert sent.method == verb
assert sent.path == path
assert sent.query == query
assert sent.json == body
assert OPERATIONS[(verb, operation)] == name
def test_every_client_method_is_exercised_here() -> None:
"""No method may be added to the table of operations without a case above."""
covered = {case[0] for case in CASES}
missing = sorted(set(OPERATIONS.values()) - covered)
assert not missing, f"these wrapped operations have no request test: {missing}"
def test_the_token_travels_as_a_bearer_header(client: DonutClient, fake: FakeDonut) -> None:
client.list_profiles()
sent = fake.last
assert sent.header("Authorization") == "Bearer test-token-abc123"
assert sent.header("Accept") == "application/json"
assert sent.header("Content-Type") is None, "a GET must not claim to carry JSON"
def test_a_body_is_sent_as_json(client: DonutClient, fake: FakeDonut) -> None:
client.create_group(name="Retail")
sent = fake.last
assert sent.header("Content-Type") == "application/json"
assert json.loads(sent.body.decode()) == {"name": "Retail"}
def test_path_ids_are_escaped(client: DonutClient, fake: FakeDonut) -> None:
"""An id can never break out of its own path segment."""
client.get_profile("a/b c?d")
assert fake.last.path == "/v1/profiles/a%2Fb%20c%3Fd"
def test_none_arguments_are_left_out_of_the_body(client: DonutClient, fake: FakeDonut) -> None:
client.update_profile("p1", name="Only this")
assert fake.last.json == {"name": "Only this"}
def test_an_empty_string_still_reaches_the_app(client: DonutClient, fake: FakeDonut) -> None:
"""`proxy_id=""` is how the app is told to detach a proxy, so it must survive."""
client.update_profile("p1", proxy_id="")
assert fake.last.json == {"proxy_id": ""}
def test_a_no_content_answer_becomes_none(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_empty(204)
assert client.delete_profile("p1") is None
def test_a_json_answer_is_returned_as_sent(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json({"profiles": [{"id": "p1", "name": "Shopper"}], "total": 1})
assert client.list_profiles() == {
"profiles": [{"id": "p1", "name": "Shopper"}],
"total": 1,
}
def test_a_bare_boolean_answer_is_returned(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json(True)
assert client.is_browser_downloaded("wayfern", "152.0.1") is True
+92
View File
@@ -0,0 +1,92 @@
"""`with client.run(...)` launches, hands over the CDP endpoint, and stops."""
from __future__ import annotations
import pytest
from fake_donut import FakeDonut
from donutbrowser import Conflict, DonutClient, DonutError
RUN_BODY = {"profile_id": "p1", "remote_debugging_port": 9222, "headless": True}
def test_the_block_gets_the_cdp_endpoint(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_empty(204)
with client.run("p1", url="https://example.com", headless=True) as session:
assert session.remote_debugging_port == 9222
assert session.headless is True
assert session.cdp_url == "http://127.0.0.1:9222"
assert session.response == RUN_BODY
assert [(sent.method, sent.path) for sent in fake.requests] == [
("POST", "/v1/profiles/p1/run"),
("POST", "/v1/profiles/p1/kill"),
]
assert fake.requests[0].json == {"url": "https://example.com", "headless": True}
def test_nothing_launches_until_the_block_is_entered(
client: DonutClient, fake: FakeDonut
) -> None:
session = client.run("p1")
assert session.remote_debugging_port is None
assert fake.requests == []
def test_the_browser_is_stopped_when_the_block_raises(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_empty(204)
with pytest.raises(ZeroDivisionError):
with client.run("p1"):
raise ZeroDivisionError("the body failed")
assert [sent.path for sent in fake.requests] == [
"/v1/profiles/p1/run",
"/v1/profiles/p1/kill",
]
def test_a_failed_stop_never_hides_why_the_block_failed(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_error(409, "PROFILE_LOCKED_ELSEWHERE")
session = client.run("p1")
with pytest.raises(ZeroDivisionError):
with session:
raise ZeroDivisionError("the body failed")
assert isinstance(session.cleanup_error, Conflict)
def test_a_failed_stop_is_raised_when_the_block_was_fine(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_error(503, "the fleet could not be reached")
with pytest.raises(DonutError):
with client.run("p1"):
pass
def test_a_failed_launch_stops_nothing(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(409, "PROFILE_RUNNING")
with pytest.raises(Conflict):
with client.run("p1"):
pytest.fail("the block must not run when the launch failed")
assert [sent.path for sent in fake.requests] == ["/v1/profiles/p1/run"]
def test_the_cdp_url_is_refused_before_the_block(client: DonutClient) -> None:
session = client.run("p1")
with pytest.raises(DonutError):
_ = session.cdp_url
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Regenerate sdk/api-paths.json from the Rust REST server.
The served /openapi.json comes from the hand-maintained `ApiDoc` derive in
`src-tauri/src/api_server.rs`, not from the axum router, so this script reads
the same two things the document is built from:
* every `#[utoipa::path(...)]` annotation (its verb and path), and
* the `paths(...)` list inside `#[openapi(...)]`.
An annotation that is not in `paths(...)` never reaches the served document, so
the two lists are compared here and a difference fails the run. The result is a
snapshot both SDK test suites read to prove they cover the whole API.
Usage (from anywhere):
python3 sdk/tools/extract-api-paths.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SOURCE = REPO_ROOT / "src-tauri" / "src" / "api_server.rs"
SNAPSHOT = REPO_ROOT / "sdk" / "api-paths.json"
VERBS = ("get", "post", "put", "delete", "patch", "head", "options")
def read_annotations(lines: list[str]) -> list[dict[str, str]]:
"""Every `#[utoipa::path(...)]` block, paired with the fn it decorates."""
operations: list[dict[str, str]] = []
index = 0
while index < len(lines):
if lines[index].strip() != "#[utoipa::path(":
index += 1
continue
depth = 0
end = index
while end < len(lines):
depth += lines[end].count("(") - lines[end].count(")")
if depth == 0 and end > index:
break
end += 1
block = lines[index : end + 1]
method = next(
(line.strip().rstrip(",") for line in block if line.strip().rstrip(",") in VERBS),
None,
)
path_match = next(
(re.search(r'path\s*=\s*"([^"]+)"', line) for line in block if "path = " in line),
None,
)
name_match = None
for line in lines[end + 1 : end + 4]:
name_match = re.search(r"\bfn\s+(\w+)\s*\(", line)
if name_match:
break
if method is None or path_match is None or name_match is None:
raise SystemExit(
f"{SOURCE}:{index + 1}: could not read a verb, a path and a fn name "
"out of this #[utoipa::path] block"
)
operations.append(
{
"operation_id": name_match.group(1),
"method": method.upper(),
"path": path_match.group(1),
}
)
index = end + 1
return operations
def read_apidoc_paths(text: str) -> list[str]:
"""The operation ids listed in `#[openapi(paths(...))]`."""
start = text.index("#[openapi(")
listed = text.index("paths(", start) + len("paths(")
depth = 1
end = listed
while depth:
if text[end] == "(":
depth += 1
elif text[end] == ")":
depth -= 1
if depth == 0:
break
end += 1
body = re.sub(r"//[^\n]*", "", text[listed:end])
return [item.strip() for item in body.split(",") if item.strip()]
def main() -> int:
text = SOURCE.read_text(encoding="utf-8")
annotated = read_annotations(text.split("\n"))
listed = read_apidoc_paths(text)
annotated_ids = {operation["operation_id"] for operation in annotated}
listed_ids = set(listed)
unpublished = sorted(annotated_ids - listed_ids)
unknown = sorted(listed_ids - annotated_ids)
if unpublished or unknown:
for name in unpublished:
print(
f"error: {name} carries a #[utoipa::path] but is missing from "
"ApiDoc paths(...), so it is absent from the served spec",
file=sys.stderr,
)
for name in unknown:
print(
f"error: ApiDoc paths(...) lists {name}, which has no "
"#[utoipa::path] annotation in this file",
file=sys.stderr,
)
return 1
operations = sorted(annotated, key=lambda op: (op["path"], op["method"]))
snapshot = {
"source": "src-tauri/src/api_server.rs",
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
"description": (
"Every operation the desktop app publishes in its /openapi.json. The "
"SDK test suites assert this list and their own coverage tables match "
"exactly, so an endpoint added to the app fails the SDK tests until it "
"is either wrapped or deliberately listed as omitted."
),
"operation_count": len(operations),
"operations": operations,
}
SNAPSHOT.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
print(f"wrote {SNAPSHOT.relative_to(REPO_ROOT)} with {len(operations)} operations")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+795 -844
View File
File diff suppressed because it is too large Load Diff
+40 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.29.5"
version = "0.30.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -26,10 +26,12 @@ path = "src/bin/proxy_server.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.47"
resvg = "0.48"
[dependencies]
serde_json = "1"
# Runtime as well as build time: the per-profile window badge is rendered at launch.
resvg = "0.48"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
@@ -47,11 +49,25 @@ env_logger = "0.11"
directories = "6"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
# The `httpstls` upstream type wraps the hop to the proxy in TLS before a single
# byte of CONNECT or Proxy-Authorization is written. native-tls (not rustls) on
# purpose: reqwest above already terminates its proxy TLS through native-tls, so
# both the browser tunnel and the check-button probe consult the same platform
# trust store. A rustls tunnel plus a native-tls probe would mean a proxy that
# passes the check and then fails in the browser. Both crates already build
# today as transitive deps of reqwest and tokio-tungstenite; this adds an edge,
# not a crate.
native-tls = "0.2"
tokio-native-tls = "0.3"
tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7"
sysinfo = "0.39"
lazy_static = "1.5"
base64 = "0.22"
# 0.23 turns on `simd-unsafe` by default, decoding via hand-written unsafe
# AVX2/NEON. This crate decodes attacker-influenced input (proxy CONNECT auth,
# extension payloads, os_crypt key blobs), and none of those paths are hot
# enough to be worth it, so stay on the scalar engine.
base64 = { version = "0.23", default-features = false, features = ["std"] }
libc = "0.2"
async-trait = "0.1"
futures-util = "0.3"
@@ -77,14 +93,18 @@ tower-http = { version = "0.7", features = ["cors"] }
rand = "0.10.2"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
argon2 = "0.6"
aes-gcm = "0.11"
aes = "0.9"
cbc = "0.2"
ring = "0.17"
subtle = "2"
sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
# Held below 1.25 on purpose. From 1.25 the `aead-cipher` feature hard-enables the
# aws-lc crypto backend (a C/assembly library needing cmake, and NASM on Windows)
# alongside the `ring` this crate already ships through boringtun. Moving up is a
# build-toolchain decision for every platform, not a routine dependency refresh.
shadowsocks = { version = ">=1.24, <1.25", default-features = false, features = ["aead-cipher"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
@@ -93,25 +113,26 @@ async-socks5 = "0.6"
# Wayfern CDP integration
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
tokio-tungstenite = { version = "0.30", features = ["native-tls"] }
rusqlite = { version = "0.40", features = ["bundled"] }
serde_yaml = "0.9"
toml = "1.1"
thiserror = "2.0"
regex-lite = "0.1"
tempfile = "3"
maxminddb = "0.29"
quick-xml = { version = "0.41", features = ["serialize"] }
maxminddb = "0.30"
quick-xml = { version = "0.42", 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", "socket-dns"] }
smoltcp = { version = "0.14", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp", "socket-dns"] }
# Tray icon decoding (main-process system tray)
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
sys-locale = "0.3"
jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] }
toml_edit = "0.25"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal", "process"] }
@@ -143,7 +164,15 @@ windows = { version = "0.62", features = [
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Registry",
# CoInitializeEx, so the `ms-settings:` hand-off in default_browser.rs has a
# COM apartment. ShellExecuteW activates the URI through a shell extension,
# and it runs on a `spawn_blocking` thread that has no apartment of its own.
"Win32_System_Com",
"Win32_UI_Shell",
# SendMessageTimeoutW, for the association-change broadcast in
# default_browser.rs. Going through the crate rather than a hand-written
# `extern "system"` block is what keeps `lpdwResult` typed as DWORD_PTR.
"Win32_UI_WindowsAndMessaging",
# CryptUnprotectData, for unwrapping the source browser's os_crypt key from
# `Local State` during profile import.
"Win32_Security_Cryptography",
@@ -158,7 +187,7 @@ http-body-util = "0.1"
tower = "0.5"
tower-http = { version = "0.7", features = ["fs", "trace"] }
futures-util = "0.3"
serial_test = "3"
serial_test = "4"
# Integration test configuration
[[test]]
+15 -8
View File
@@ -37,13 +37,20 @@ fn main() {
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
}
// Inject vault password at build time
if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") {
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}");
} else {
// Use default password if environment variable is not set
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
}
// The sealing password of every build before the per-install vault key.
// Still compiled in so an update can open the files those builds sealed
// and re-seal them under the installation's own key (see `src/vault.rs`).
// It reaches the crate through a file in OUT_DIR rather than a rustc-env
// line, so the build log never carries it.
let legacy_vault_password = std::env::var("DONUT_BROWSER_VAULT_PASSWORD")
.unwrap_or_else(|_| "donutbrowser-api-vault-password".to_string());
let out_dir = std::env::var("OUT_DIR").expect("cargo sets OUT_DIR for build scripts");
std::fs::write(
std::path::Path::new(&out_dir).join("legacy_vault_password.txt"),
legacy_vault_password,
)
.expect("write the legacy vault password for include_str!");
println!("cargo:rerun-if-env-changed=DONUT_BROWSER_VAULT_PASSWORD");
// Tell Cargo to rebuild if the proxy binary source changes
println!("cargo:rerun-if-changed=src/bin/proxy_server.rs");
@@ -188,7 +195,7 @@ fn generate_tray_icons() {
// macOS will automatically handle light/dark mode by inverting the icon
// For template icons: RGB should be 0,0,0 (black) and alpha controls visibility
let data = pixmap.data_mut();
for pixel in data.chunks_exact_mut(4) {
for pixel in data.as_chunks_mut::<4>().0 {
// Keep the original alpha (shows where icon content is)
// but make the color black for template icon format
pixel[0] = 0; // R
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -103
View File
@@ -768,42 +768,6 @@ impl AppAutoUpdater {
.map(|a| a.browser_download_url.clone())
}
/// Extract the hex digest for `filename` from standard `sha256sum` output
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
checksums_text.lines().find_map(|line| {
let (hash, rest) = line.split_once(char::is_whitespace)?;
let name = rest.trim_start().trim_start_matches('*');
if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
Some(hash.to_ascii_lowercase())
} else {
None
}
})
}
fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1024 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write;
let _ = write!(hex, "{byte:02x}");
}
Ok(hex)
}
/// Fetch the release's SHA256SUMS.txt and return the expected digest for
/// `filename`. Called BEFORE the (large) asset download so an unverifiable
/// release is rejected without wasting the transfer. Every failure mode
@@ -857,7 +821,7 @@ impl AppAutoUpdater {
}
};
let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else {
let Some(expected) = crate::checksum::find_checksum_for_file(&checksums_text, filename) else {
log::warn!(
"No checksum entry for {filename} in {}",
Self::CHECKSUMS_ASSET_NAME
@@ -877,7 +841,7 @@ impl AppAutoUpdater {
expected: &str,
asset_digest: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let actual = Self::sha256_file(file_path)?;
let actual = crate::checksum::sha256_file(file_path)?;
let mut mismatch = !actual.eq_ignore_ascii_case(expected);
@@ -1908,36 +1872,34 @@ rm "{}"
parameters
);
// windows-sys is not a direct dep, so use the raw FFI via the
// windows crate that Tauri pulls in. ShellExecuteW returns an
// HINSTANCE > 32 on success.
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(
hwnd: *mut std::ffi::c_void,
operation: *const u16,
file: *const u16,
parameters: *const u16,
directory: *const u16,
show_cmd: i32,
) -> isize;
}
const SW_SHOWNORMAL: i32 = 1;
let open: Vec<u16> = "open\0".encode_utf16().collect();
// Take the binding from the `windows` crate rather than writing the
// declaration here. A hand-written one is what put the wrong width on
// `SendMessageTimeoutA`'s out-parameter in `default_browser.rs`, and
// that killed the process on every click of "Set as default browser".
// No compiler and no lint can see such a mistake. The generated binding
// cannot drift from the real ABI, so there is nothing to get wrong.
use windows::core::{w, PCWSTR};
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
open.as_ptr(),
file_w.as_ptr(),
params_w.as_ptr(),
std::ptr::null(),
None,
w!("open"),
PCWSTR(file_w.as_ptr()),
PCWSTR(params_w.as_ptr()),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
if result as usize <= 32 {
return Err(format!("ShellExecuteW failed with code {result}").into());
// ShellExecuteW reports success as a value above 32. Anything at or
// below that is an error code wearing a handle's type. Read it as a
// signed value: the old `as usize` turned every negative code into a
// very large number, which read as success.
let code = result.0 as isize;
if code <= 32 {
return Err(format!("ShellExecuteW failed with code {code}").into());
}
} else {
// No pending installer — just restart the app. Use a minimal
@@ -2226,48 +2188,6 @@ mod tests {
);
}
#[test]
fn test_find_checksum_for_file() {
let sums = "\
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
not-a-hash Donut_0.29.0_amd64.deb
";
// Plain entry.
assert_eq!(
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
);
// Binary-mode marker is stripped; hash is normalized to lowercase.
assert_eq!(
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
);
// Entries with malformed hashes are rejected rather than trusted.
assert_eq!(
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"),
None
);
// Missing file.
assert_eq!(
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"),
None
);
}
#[test]
fn test_sha256_file_matches_known_digest() {
let temp_dir = tempfile::TempDir::new().unwrap();
let path = temp_dir.path().join("data.bin");
std::fs::write(&path, b"hello world").unwrap();
assert_eq!(
AppAutoUpdater::sha256_file(&path).unwrap(),
// sha256 of "hello world"
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn test_find_checksums_url() {
let assets = vec![
+319 -9
View File
@@ -61,10 +61,121 @@ fn log_dir_for(root: Option<PathBuf>, portable: Option<&PathBuf>) -> Option<Path
/// File name `tauri-plugin-window-state` persists geometry under.
pub const WINDOW_STATE_FILENAME: &str = ".window-state.json";
/// File name of the pointer that records a data directory the user chose in
/// Settings.
pub const DATA_ROOT_POINTER_FILENAME: &str = "data-root.json";
static CUSTOM_DATA_ROOT: OnceLock<Option<PathBuf>> = OnceLock::new();
/// Where the pointer to a user-chosen data directory lives.
///
/// It must never sit inside `data_dir()` itself: a move deletes the old
/// directory once the copy verifies, which would take the pointer with it and
/// send the next start back to the platform default. Every branch below
/// therefore resolves OUTSIDE the data directory it points at.
///
/// - With `DONUTBROWSER_DATA_ROOT` set, `<root>/data-root.json`, a sibling of
/// `<root>/data`. An isolated run (the E2E harness) then keeps its own
/// pointer and can never read, or write, the real machine's.
/// - In portable mode, `<exe dir>/data-root.json`, beside `<exe dir>/data`, so
/// the choice travels with the install.
/// - Otherwise the platform preference directory, which is a different root
/// from `data_local_dir` on macOS, Linux and Windows alike.
pub fn data_root_pointer_file() -> PathBuf {
data_root_pointer_file_for(
data_root(),
portable_dir(),
base_dirs().preference_dir().join(app_name()),
)
}
/// Split out from `data_root_pointer_file` so the precedence is testable
/// without a `.portable` marker or process-wide environment mutation.
fn data_root_pointer_file_for(
root: Option<PathBuf>,
portable: Option<&PathBuf>,
preference_dir: PathBuf,
) -> PathBuf {
if let Some(root) = root {
return root.join(DATA_ROOT_POINTER_FILENAME);
}
if let Some(dir) = portable {
return dir.join(DATA_ROOT_POINTER_FILENAME);
}
preference_dir.join(DATA_ROOT_POINTER_FILENAME)
}
/// Read a pointer file written by a previous "move data directory".
///
/// A missing, unreadable, malformed, empty or relative entry resolves to
/// `None`. Falling back to the platform default is always better than
/// resolving every profile, binary and setting to a path that cannot exist.
pub fn read_data_root_pointer(file: &std::path::Path) -> Option<PathBuf> {
let content = std::fs::read_to_string(file).ok()?;
let parsed: serde_json::Value = match serde_json::from_str(&content) {
Ok(value) => value,
Err(e) => {
log::warn!(
"Ignoring the data directory pointer at {}: it is not valid JSON ({e})",
file.display()
);
return None;
}
};
let path = PathBuf::from(parsed.get("path")?.as_str()?);
if path.as_os_str().is_empty() || !path.is_absolute() {
log::warn!(
"Ignoring the data directory pointer at {}: {} is not an absolute path",
file.display(),
path.display()
);
return None;
}
Some(path)
}
/// Record a data directory for the next start. Written atomically, because a
/// truncated pointer read at startup would silently drop the user back onto
/// the platform default with an empty profile list.
pub fn write_data_root_pointer(
file: &std::path::Path,
path: &std::path::Path,
) -> std::io::Result<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::json!({ "path": path.to_string_lossy() }).to_string();
let temp = file.with_extension("json.tmp");
std::fs::write(&temp, body.as_bytes())?;
std::fs::rename(&temp, file)
}
/// Forget a recorded data directory, returning the app to the default.
pub fn clear_data_root_pointer(file: &std::path::Path) -> std::io::Result<()> {
match std::fs::remove_file(file) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
/// The data directory a previous move chose, read once per process.
///
/// Cached deliberately. Every open handle, cached path and loaded manager in a
/// running app points at the directory it started on, so a move must take
/// effect at the NEXT start and never mid-session.
pub fn custom_data_root() -> Option<&'static PathBuf> {
CUSTOM_DATA_ROOT
.get_or_init(|| read_data_root_pointer(&data_root_pointer_file()))
.as_ref()
}
/// True when app state has been moved off the platform default location, by
/// portable mode or by either directory override.
/// portable mode, either directory override, or a data directory the user
/// chose in Settings.
fn state_is_relocated() -> bool {
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
|| custom_data_root().is_some()
|| data_root().is_some()
|| portable_dir().is_some()
}
@@ -80,8 +191,34 @@ fn state_is_relocated() -> bool {
/// host machine. If a future plugin version sanitises the name to a bare file
/// component this silently reverts to the default directory, which is why the
/// first-run probe in `lib.rs` reads this same function rather than assuming.
///
/// A relocation that resolves to a relative path is rejected: see
/// `window_state_override_for`.
pub fn window_state_path_override() -> Option<PathBuf> {
state_is_relocated().then(|| data_dir().join(WINDOW_STATE_FILENAME))
window_state_override_for(state_is_relocated(), data_dir())
}
/// Split out from `window_state_path_override` so the absolute-path rule is
/// testable without mutating process-wide environment variables.
///
/// A relative override is worse than no override: the plugin would resolve it
/// against `app_config_dir` and write into an intermediate directory it never
/// creates, so every save fails with ENOENT and is swallowed by the plugin's
/// fire-and-forget exit handler. Falling back to the platform default at least
/// persists geometry.
fn window_state_override_for(relocated: bool, data_dir: PathBuf) -> Option<PathBuf> {
if !relocated {
return None;
}
let path = data_dir.join(WINDOW_STATE_FILENAME);
if !path.is_absolute() {
log::warn!(
"Ignoring relative window-state override {}: the plugin resolves its filename against app_config_dir, so geometry would never persist. Set DONUTBROWSER_DATA_DIR/DONUTBROWSER_DATA_ROOT to an absolute path.",
path.display()
);
return None;
}
Some(path)
}
/// Where the window-state file actually is, override or not. Used for the
@@ -115,19 +252,66 @@ pub fn data_dir() -> PathBuf {
}
}
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
return PathBuf::from(dir);
}
data_dir_for(
std::env::var_os("DONUTBROWSER_DATA_DIR")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
custom_data_root(),
data_root(),
portable_dir(),
base_dirs().data_local_dir().join(app_name()),
)
}
if let Some(root) = data_root() {
/// The data directory resolution order, split out so it can be tested without
/// mutating process-wide environment variables.
///
/// `DONUTBROWSER_DATA_DIR` stays on top: it names an exact directory and is the
/// bluntest override there is. The directory the user picked in Settings comes
/// next, ahead of `DONUTBROWSER_DATA_ROOT` and portable mode, because both of
/// those are defaults for where state *would* live and an explicit choice
/// outranks a default. It cannot break an isolated run, because the pointer it
/// is read from lives under that same `DONUTBROWSER_DATA_ROOT`.
fn data_dir_for(
env_data_dir: Option<PathBuf>,
custom_root: Option<&PathBuf>,
env_data_root: Option<PathBuf>,
portable: Option<&PathBuf>,
platform_default: PathBuf,
) -> PathBuf {
if let Some(dir) = env_data_dir {
return dir;
}
if let Some(dir) = custom_root {
return dir.clone();
}
if let Some(root) = env_data_root {
return root.join("data");
}
if let Some(dir) = portable_dir() {
if let Some(dir) = portable {
return dir.join("data");
}
platform_default
}
base_dirs().data_local_dir().join(app_name())
/// Where the data directory would resolve with no user choice recorded. Shown
/// in Settings so a person can see what they moved away from.
pub fn default_data_dir() -> PathBuf {
data_dir_for(
std::env::var_os("DONUTBROWSER_DATA_DIR")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
None,
data_root(),
portable_dir(),
base_dirs().data_local_dir().join(app_name()),
)
}
/// True when an environment override decides the data directory, so a
/// directory chosen in Settings would be recorded but not used.
pub fn data_dir_forced_by_environment() -> bool {
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
}
pub fn cache_dir() -> PathBuf {
@@ -389,6 +573,27 @@ mod tests {
}
}
#[test]
fn window_state_override_rejects_a_relative_data_dir() {
// `DONUTBROWSER_DATA_ROOT=don-state` (or a relative DATA_DIR) would hand the
// plugin a relative filename it resolves against app_config_dir, into a
// directory nothing creates. Falling back to the default keeps geometry.
assert_eq!(
window_state_override_for(true, PathBuf::from("don-state/data")),
None
);
assert_eq!(window_state_override_for(true, PathBuf::from("")), None);
// temp_dir is absolute on every platform; a hard-coded "/tmp/..." is not
// absolute on Windows, where these tests also run.
let relocated = std::env::temp_dir().join("donut-relocated");
assert_eq!(
window_state_override_for(true, relocated.clone()),
Some(relocated.join(WINDOW_STATE_FILENAME))
);
assert_eq!(window_state_override_for(false, relocated), None);
}
#[test]
fn window_state_follows_a_relocated_data_dir() {
let tmp = PathBuf::from("/tmp/donut-relocated");
@@ -413,6 +618,111 @@ mod tests {
assert!(portable.join("cache").starts_with(&portable));
}
#[test]
fn data_dir_resolution_order_puts_the_chosen_directory_under_the_exact_override() {
let env_dir = PathBuf::from("/env/exact");
let chosen = PathBuf::from("/Volumes/Big/DonutBrowser");
let env_root = PathBuf::from("/env/root");
let portable = PathBuf::from("/stick");
let default = PathBuf::from("/home/user/.local/share/DonutBrowser");
// DONUTBROWSER_DATA_DIR names an exact directory and outranks everything.
assert_eq!(
data_dir_for(
Some(env_dir.clone()),
Some(&chosen),
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
env_dir
);
// The directory the user picked beats both defaults-for-where-state-lives.
assert_eq!(
data_dir_for(
None,
Some(&chosen),
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
chosen
);
// With nothing chosen the existing order is untouched.
assert_eq!(
data_dir_for(
None,
None,
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
env_root.join("data")
);
assert_eq!(
data_dir_for(None, None, None, Some(&portable), default.clone()),
portable.join("data")
);
assert_eq!(
data_dir_for(None, None, None, None, default.clone()),
default
);
}
#[test]
fn the_pointer_never_lives_inside_the_directory_it_points_at() {
let root = PathBuf::from("/tmp/donut-root");
let portable = PathBuf::from("/tmp/donut-portable");
let preference = PathBuf::from("/home/user/.config/DonutBrowser");
// With DONUTBROWSER_DATA_ROOT the data dir is <root>/data, so a sibling
// file survives deleting it — and an isolated run reads only its own.
let with_root =
data_root_pointer_file_for(Some(root.clone()), Some(&portable), preference.clone());
assert_eq!(with_root, root.join(DATA_ROOT_POINTER_FILENAME));
assert!(!with_root.starts_with(root.join("data")));
let with_portable = data_root_pointer_file_for(None, Some(&portable), preference.clone());
assert_eq!(with_portable, portable.join(DATA_ROOT_POINTER_FILENAME));
assert!(!with_portable.starts_with(portable.join("data")));
assert_eq!(
data_root_pointer_file_for(None, None, preference.clone()),
preference.join(DATA_ROOT_POINTER_FILENAME)
);
}
#[test]
fn a_written_pointer_reads_back_and_a_broken_one_falls_back() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("nested").join(DATA_ROOT_POINTER_FILENAME);
let target = std::env::temp_dir().join("donut-moved-root");
assert_eq!(read_data_root_pointer(&file), None, "missing file");
write_data_root_pointer(&file, &target).unwrap();
assert_eq!(read_data_root_pointer(&file), Some(target.clone()));
// A relative path would be resolved against whatever the working directory
// happens to be, which is not a place app state can live.
write_data_root_pointer(&file, std::path::Path::new("relative/root")).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "relative path");
std::fs::write(&file, b"not json at all").unwrap();
assert_eq!(read_data_root_pointer(&file), None, "malformed file");
std::fs::write(&file, br#"{"other":"key"}"#).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "no path entry");
write_data_root_pointer(&file, &target).unwrap();
clear_data_root_pointer(&file).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "cleared");
// Clearing an absent pointer is not an error; the caller has nothing to fix.
clear_data_root_pointer(&file).unwrap();
}
#[test]
fn test_subdirectory_helpers() {
assert!(profiles_dir().ends_with("profiles"));
+14
View File
@@ -309,6 +309,19 @@ impl AutoUpdater {
// Check if profile is currently running
if profile.process_id.is_some() {
// A pending entry is matched on the profile's current version alone,
// so recording one for an older build would downgrade the profile the
// moment it closes, and would pin that older binary against cleanup.
if !self.is_version_newer(new_version, &profile.version) {
log::debug!(
"Not queuing {} for running profile {}: not newer than {}",
new_version,
profile.name,
profile.version
);
continue;
}
// Store as pending update so it gets applied when browser closes
log::info!(
"Profile {} is running, storing pending update {} -> {}",
@@ -662,6 +675,7 @@ mod tests {
last_sync: None,
host_os: None,
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
+1 -1
View File
@@ -102,7 +102,7 @@ async fn main() {
.arg(
Arg::new("type")
.long("type")
.help("Proxy type (http, https, socks4, socks5, ss)"),
.help("Proxy type (http, https, httpstls, socks4, socks5, ss)"),
)
.arg(
Arg::new("port")
+9
View File
@@ -25,6 +25,14 @@ impl BrowserType {
}
}
/// Brand name for user-facing strings. `as_str` is the internal id and is
/// the wrong thing to put in a message the user reads.
pub fn display_name(&self) -> &'static str {
match self {
BrowserType::Wayfern => "Wayfern",
}
}
pub fn from_str(s: &str) -> Result<Self, String> {
match s {
"wayfern" => Ok(BrowserType::Wayfern),
@@ -719,6 +727,7 @@ mod tests {
last_sync: None,
host_os: None,
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
+200 -43
View File
@@ -2,6 +2,7 @@ use crate::browser::ProxySettings;
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::log_redaction::ShortId;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
@@ -33,6 +34,18 @@ async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<(
lock.lock_owned().await
}
fn emit_launch_stage(profile: &BrowserProfile, stage: &str, error: Option<&str>) {
let _ = events::emit(
"profile-launch-stage",
serde_json::json!({
"id": profile.id.to_string(),
"stage": stage,
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64,
"error": error,
}),
);
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -208,6 +221,10 @@ impl BrowserRunner {
.map_err(|e| format!("Failed to get executable path for {}: {e}", profile.browser).into())
}
/// One argument per thing a launch decides, and they are all independent:
/// grouping them into a struct would only move the same list one level out,
/// and the one caller shape that repeats already has `LaunchOptions`.
#[allow(clippy::too_many_arguments)]
async fn launch_browser_internal(
&self,
app_handle: tauri::AppHandle,
@@ -215,6 +232,7 @@ impl BrowserRunner {
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
kind: crate::wayfern_manager::LaunchKind,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Wayfern profiles using WayfernManager
@@ -228,6 +246,7 @@ impl BrowserRunner {
WayfernConfig::default()
});
emit_launch_stage(profile, "network", None);
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
let mut upstream_proxy = self
.resolve_launch_proxy(profile)
@@ -292,9 +311,16 @@ impl BrowserRunner {
vpn_id: String,
created: bool,
profile_name: String,
/// This launch's own hold on the worker, kept until the guard goes out
/// of scope so a sibling launch cannot stop the worker while this one
/// is still between adoption and publishing its browser PID.
claim: Option<crate::vpn_worker_runner::VpnLaunchClaim>,
}
impl Drop for VpnLaunchGuard {
fn drop(&mut self) {
// Released before anything reads the claims, or this launch would
// count itself as a reason to keep the worker it just failed to use.
drop(self.claim.take());
let Some(worker_id) = self.worker_id.take() else {
return;
};
@@ -333,6 +359,7 @@ impl BrowserRunner {
vpn_id: vpn_id.clone(),
created: started.created,
profile_name: profile.name.clone(),
claim: Some(started.claim),
});
if let Some(port) = started.config.local_port {
upstream_proxy = Some(ProxySettings {
@@ -361,6 +388,12 @@ impl BrowserRunner {
// unpack, and the browser process, so a blocked launch has nothing to
// undo beyond the two workers whose guards are already armed above.
//
// The group's bookmarks are written before the gate rather than inside
// it: the gate returns early for several kinds of profile and answers a
// question ("may this launch proceed"), while this is a preparation step
// every spawn needs, including a profile with no route to check.
crate::group_bookmarks::sync_for_launch(profile);
// Run concurrently with the blocklist compile so the added wall clock is
// max(), not sum().
let (blocklist, gate_result) = tokio::join!(
@@ -458,38 +491,125 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Check if we need to generate a new fingerprint on every launch
// Check if we need to generate a device for this launch.
//
// Three cases share the block: the user asked for a fresh device on
// every launch, the profile stores none at all, or the profile is legacy
// — a whole device payload and no identity — on a browser that speaks
// the identity API. The second is how a clone arrives here, since
// cloning clears both the payload and the identity so the clone gets an
// independent device instead of the browser's default.
//
// The third is the migration to identity-only storage: donutbrowser
// holds no device on disk, and a payload cannot become an identity
// locally, because only the browser mints an id and the id it mints
// derives its own device. That one-time rotation is the cost of the
// payload leaving disk, and it happens once because the minted id is
// persisted below.
let mut updated_profile = profile.clone();
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
// A profile that stores a whole device BESIDE an identity needs no new
// device, only its payload folded into overrides and location. This runs
// before the launch reads the config, and the migrated shape is what
// gets persisted below.
if crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut wayfern_config) {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut cfg);
updated_profile.wayfern_config = Some(cfg);
log::info!(
"Generating random fingerprint for Wayfern profile: {}",
"Migrated Wayfern profile {} to identity-only storage",
profile.name
);
}
let randomize_requested = wayfern_config.randomize_fingerprint_on_launch == Some(true);
let migrating_payload = wayfern_config.identity_id.is_none()
&& wayfern_config.fingerprint.is_some()
&& crate::wayfern_manager::supports_identity_api(&profile.version);
let needs_device = migrating_payload
|| (wayfern_config.fingerprint.is_none() && wayfern_config.identity_id.is_none());
if randomize_requested || needs_device {
if migrating_payload && !randomize_requested {
log::info!(
"Migrating Wayfern profile {} from a stored device to an identity",
profile.name
);
} else if needs_device && !randomize_requested {
log::info!(
"No stored device for Wayfern profile {}; generating one",
profile.name
);
} else {
log::info!(
"Generating random fingerprint for Wayfern profile: {}",
profile.name
);
}
// Create a config copy without the existing fingerprint to force generation of a new one
let mut config_for_generation = wayfern_config.clone();
config_for_generation.fingerprint = None;
// Generate a new fingerprint
let (new_fingerprint, geolocation_applied) = self
// A failed generation fails the launch on purpose: continuing would
// start the browser on whatever device it drew for itself, unmanaged
// and unrecorded, while the UI still reports a successful launch. For
// an anti-detect product a silently wrong device is worse than no
// launch at all, because nothing tells the user to stop using it.
//
// Structured rather than prose, because the most common failure is the
// browser refusing a generation outright. That has to reach the user as
// an explanation; a raw CDP string is not one, and the frontend only
// translates a coded error.
let generated = self
.wayfern_manager
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
.await
.map_err(|e| format!("Failed to generate random fingerprint: {e}"))?;
.map_err(|e| {
let detail = e.to_string();
// BOTH refusal texts, because a profile may be on either browser
// version. Older builds word the generation-limit refusal
// differently, and matching only one wording leaves those users
// staring at a raw CDP string, which is the exact defect this
// mapping exists to remove.
if detail.contains("generation limit reached") || detail.contains("Too many profiles") {
crate::backend_error_with_detail("WAYFERN_GENERATION_LIMIT_REACHED", detail)
} else {
crate::backend_error_with_detail("WAYFERN_FINGERPRINT_GENERATION_FAILED", detail)
}
})?;
let geolocation_applied = generated.geolocation_applied;
log::info!(
"New fingerprint generated, length: {} chars",
new_fingerprint.len()
"New fingerprint generated, length: {} chars, identity: {:?}",
generated.fingerprint.len(),
generated.identity_id
);
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
// Update the config with the new device for launching. An identity
// stores the id and the location only; a legacy browser stores the
// whole payload.
let is_identity = generated.identity_id.is_some();
wayfern_config.identity_id = generated.identity_id.clone();
wayfern_config.location = generated.location.clone();
wayfern_config.identity_baseline = None;
wayfern_config.fingerprint = if is_identity {
None
} else {
Some(generated.fingerprint.clone())
};
// Save the updated fingerprint to the profile so it persists.
// Save the updated device 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);
updated_wayfern_config.identity_id = generated.identity_id;
updated_wayfern_config.location = generated.location;
updated_wayfern_config.identity_baseline = None;
updated_wayfern_config.fingerprint = if is_identity {
None
} else {
Some(generated.fingerprint)
};
// Preserve the randomize flag so it persists across launches
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
// 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();
@@ -562,6 +682,7 @@ impl BrowserRunner {
let profile_path_str = profile_data_path.to_string_lossy().to_string();
// Install extensions if an extension group is assigned
emit_launch_stage(profile, "extensions", None);
let mut extension_paths = Vec::new();
if updated_profile.extension_group_id.is_some() {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
@@ -585,6 +706,7 @@ impl BrowserRunner {
// Get proxy URL from config
let proxy_url = wayfern_config.proxy.as_deref();
emit_launch_stage(profile, "starting", None);
let wayfern_result = self
.wayfern_manager
.launch_wayfern(
@@ -598,10 +720,15 @@ impl BrowserRunner {
&extension_paths,
remote_debugging_port,
headless,
kind,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to launch Wayfern: {e}").into()
// A refused apply reports itself as a structured error so the dialog
// can name the cause. Prefixing it would put English in front of the
// JSON the frontend parses, and the whole thing would reach the user
// as raw machine output.
crate::wrap_backend_error(e, "Failed to launch Wayfern").into()
})?;
// Get the process ID from launch result
@@ -653,24 +780,6 @@ impl BrowserRunner {
guard.worker_id = None;
}
// 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());
@@ -800,6 +909,7 @@ impl BrowserRunner {
url,
remote_debugging_port,
headless,
crate::wayfern_manager::LaunchKind::Automation,
gate,
)
.await
@@ -892,7 +1002,15 @@ impl BrowserRunner {
} else {
log::info!("Launching new browser instance - browser not running");
self
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
None,
false,
crate::wayfern_manager::LaunchKind::Interactive,
gate,
)
.await
}
}
@@ -929,7 +1047,7 @@ impl BrowserRunner {
// "Stop this profile" has to mean the browser that is actually running, and
// for a profile on the leased fleet that browser is not on this machine.
// Without this, stopping reported success, killed nothing, and left the
// session running to its two-hour cap — billing the user for every minute
// session running to its maximum duration — spending the user's allowance
// and holding their profile lock the whole time.
if self.stop_remote_session_for(&app_handle, profile).await? {
return Ok(());
@@ -959,17 +1077,20 @@ impl BrowserRunner {
};
log::info!(
"Stopping remote session {session_id} for profile {} ({profile_id})",
"Stopping remote session {} for profile {} ({profile_id})",
ShortId(&session_id),
profile.name
);
crate::remote_session::end_remote_session(&session_id)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
// Surfaced rather than swallowed. The backend refuses to retire a
// session it could not stop on the fleet, so a failure here means the
// browser is STILL RUNNING; reporting success would tell the user their
// profile is free when a host is still writing to it.
log::warn!("Failed to stop remote session {session_id}: {e}");
// Surfaced rather than swallowed. A failure here means the browser is
// STILL RUNNING; reporting success would tell the user their profile is
// free when a remote host is still writing to it.
log::warn!(
"Failed to stop remote session {}: {e}",
ShortId(&session_id)
);
e.to_error_json().into()
})?;
@@ -977,8 +1098,8 @@ impl BrowserRunner {
// the profile into "pending sync" and starts the pull, so the user is not
// handed back a profile directory that predates the session they just ran.
//
// The session's own profile lock is released by the backend when it retires
// the row; nothing is released from here, because this client never held it.
// The session's own profile lock is released by the server; nothing is
// released from here, because this client never held it.
crate::remote_session::note_session_stopped(app_handle, &session_id);
Ok(true)
}
@@ -1335,6 +1456,22 @@ impl BrowserRunner {
&profile.id.to_string(),
);
// A temporary profile exists for one automation run, so the run ending
// is what ends it. Destroyed rather than trashed: nothing here is worth
// restoring, and a trash full of automation leftovers is its own bug.
if profile.temporary {
match self
.profile_manager
.delete_profile_permanently(&app_handle, &profile.id.to_string())
{
Ok(()) => log::info!(
"Deleted temporary profile {} now that its browser has stopped",
profile.name
),
Err(e) => log::warn!("Could not delete temporary profile {}: {e}", profile.name),
}
}
log::info!(
"Wayfern process cleanup completed for profile: {} (ID: {})",
profile.name,
@@ -1558,6 +1695,26 @@ pub async fn launch_browser_profile_impl(
profile: BrowserProfile,
url: Option<String>,
options: LaunchOptions,
) -> Result<BrowserProfile, String> {
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
emit_launch_stage(&profile, "queued", None);
let result = launch_browser_profile_tracked(app_handle, profile.clone(), url, options).await;
match &result {
Ok(_) => emit_launch_stage(&profile, "running", None),
Err(error) => emit_launch_stage(
&profile,
"failed",
Some(&crate::wrap_backend_error(error, "Browser launch failed")),
),
}
result
}
async fn launch_browser_profile_tracked(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
options: LaunchOptions,
) -> Result<BrowserProfile, String> {
let LaunchOptions {
remote_debugging_port,
@@ -1570,7 +1727,7 @@ pub async fn launch_browser_profile_impl(
profile.name,
profile.id
);
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
emit_launch_stage(&profile, "preparing", None);
if profile.is_cross_os() {
return Err(format!(
+250 -76
View File
@@ -2,22 +2,21 @@
//!
//! Until this module existed, every automation tool answered "where is this
//! browser?" by reading a LOCAL debugging port out of the LOCAL profile
//! directory. A profile launched on a leased host has no local port and no
//! directory. A profile launched on a remote host has no local port and no
//! local process, so a customer who paid for remote execution could start a
//! session and then do nothing with it — the one thing the feature exists for.
//!
//! There is exactly one resolver here, [`resolve`], and one connection type,
//! [`CdpConnection`]. Tools ask for a target and get either a page socket on
//! this machine or a relayed socket to the fleet; nothing above this module
//! branches on which. That is deliberate: a parallel set of remote-only tools
//! would drift from the local ones within a release.
//! this machine or a relayed socket to a remote browser; nothing above this
//! module branches on which. That is deliberate: a parallel set of remote-only
//! tools would drift from the local ones within a release.
//!
//! The remote arm reaches donutbrowser-infra with the USER's own access token.
//! The desktop holds no fleet credential and knows no fleet hostname — infra
//! verifies the session belongs to the caller and relays onward with its own
//! service credential. That boundary is why this is a relay and not a direct
//! connection.
//! The remote arm reaches the cloud API with the USER's own access token, and
//! never holds any credential or hostname belonging to the machine the browser
//! runs on. That boundary is why this is a relay and not a direct connection.
use crate::log_redaction::ShortId;
use crate::profile::types::BrowserProfile;
use serde_json::Value;
use std::time::Duration;
@@ -30,16 +29,15 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
/// How long the WebSocket handshake may take.
///
/// A remote attach crosses desktop → infra → wayfern → agent → the VM, so this
/// is far longer than a loopback connect needs. It matches the relay's own
/// upstream handshake budget: waiting longer than the server does can only
/// report a timeout the server already reported.
/// A remote attach crosses several networks before it reaches the browser, so
/// this is far longer than a loopback connect needs. Waiting longer than the
/// server does can only report a timeout the server already reported.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// How long one CDP command may wait for its reply.
///
/// Without a cap, a browser that never answers holds the caller until the
/// socket dies — 90 seconds on the relay, indefinitely on loopback. An
/// socket dies — a bounded wait remotely, indefinitely on loopback. An
/// automation client that hangs is worse than one that fails.
const COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
@@ -51,10 +49,9 @@ const CONNECT_RETRY_BASE: Duration = Duration::from_millis(400);
/// Ceiling on a relayed CDP message.
///
/// Matches the relay's client-facing cap, which matches the fleet's upstream
/// frame cap. Lower, and a screenshot the server was willing to carry is
/// dropped on arrival; higher buys nothing, because the frame never crosses the
/// relay in the first place.
/// Matches the frame cap the remote endpoint enforces. Lower, and a screenshot
/// the server was willing to carry is dropped on arrival; higher buys nothing,
/// because the frame never crosses the network in the first place.
const REMOTE_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
/// Command ids for the two messages the remote arm sends before any tool does.
@@ -72,9 +69,9 @@ pub enum CdpTarget {
/// A browser on this machine. The URL is a PAGE-level socket, so commands
/// carry no CDP session id.
Local { ws_url: String },
/// A browser on the fleet, reached through the infra relay. The relay bridges
/// a BROWSER-level socket, so the connection attaches to a page and stamps
/// every subsequent message with the resulting session id.
/// A browser running remotely, reached through the cloud API. The remote
/// endpoint exposes a BROWSER-level socket, so the connection attaches to a
/// page and stamps every subsequent message with the resulting session id.
Remote {
ws_url: String,
bearer: String,
@@ -83,7 +80,7 @@ pub enum CdpTarget {
}
impl CdpTarget {
/// True when this browser is on the leased fleet rather than this machine.
/// True when this browser is on a remote host rather than this machine.
pub fn is_remote(&self) -> bool {
matches!(self, Self::Remote { .. })
}
@@ -92,7 +89,7 @@ impl CdpTarget {
pub fn describe(&self) -> String {
match self {
Self::Local { .. } => "local browser".to_string(),
Self::Remote { session_id, .. } => format!("remote session {session_id}"),
Self::Remote { session_id, .. } => format!("remote session {}", ShortId(session_id)),
}
}
}
@@ -105,9 +102,9 @@ impl CdpTarget {
/// broken one.
#[derive(Debug)]
pub enum CdpError {
/// Nothing is listening, or the relay could not reach the browser.
/// Nothing is listening, or the browser could not be reached.
Unreachable(String),
/// The relay refused the credential.
/// The credential was refused.
Unauthorized(String),
/// The session exists but is not in a state that can be driven.
NotDrivable(String),
@@ -133,9 +130,9 @@ impl CdpError {
/// Whether a fresh connection attempt could plausibly succeed.
///
/// A refused credential and a session that is still provisioning are answers,
/// not failures. Retrying either spends the caller's time and, on the relay,
/// burns one of the four attachments a session is allowed — so the retry can
/// make the next honest attempt fail too.
/// not failures. Retrying either spends the caller's time and counts against
/// the session's attachment budget — so the retry can make the next honest
/// attempt fail too.
fn is_retryable(&self) -> bool {
matches!(self, Self::Unreachable(_) | Self::Transport(_))
}
@@ -185,11 +182,11 @@ impl Patience {
/// one crosses two networks.
///
/// The local check is deliberately split in two. One cheap probe decides the
/// arm, so a profile running on the fleet is not held behind twenty-five
/// seconds of local retries; only once remote has been ruled out does the local
/// probe spend its full budget waiting for a browser that is still starting.
/// The same split covers a stale `process_id` left by a crash — nothing answers
/// on the recorded port, so the fleet session is found instead of a dead one.
/// arm, so a profile running remotely is not held behind twenty-five seconds of
/// local retries; only once remote has been ruled out does the local probe
/// spend its full budget waiting for a browser that is still starting. The same
/// split covers a stale `process_id` left by a crash — nothing answers on the
/// recorded port, so the remote session is found instead of a dead one.
pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError> {
if profile.browser != "wayfern" {
return Err(ResolveError::Unsupported(format!(
@@ -216,7 +213,7 @@ pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError
log::info!(
"Driving profile '{}' through remote session {}",
profile.name,
session.session_id
ShortId(&session.session_id)
);
return Ok(CdpTarget::Remote {
ws_url: endpoint.ws_url,
@@ -304,12 +301,30 @@ async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Opti
}
/// Pick a drivable page from what `/json` lists on a local browser.
///
/// DRIVABLE, not merely first. This used to take the first `type == "page"` and
/// then reach for its socket, so a first entry without a
/// `webSocketDebuggerUrl`, a page another client is already attached to, which
/// Chromium omits the field for, made the whole call answer None and every
/// browser tool fail, while a perfectly drivable second tab sat right behind it.
/// The user sees "no page target found in browser" on a browser plainly showing
/// pages.
///
/// `devtools://` is excluded for the same reason [`pick_remote_page_target`]
/// excludes it: attaching there drives the inspector rather than the site, which
/// reports success and moves nothing. The two functions answer the same question
/// off different payload shapes, so they must not disagree about what counts.
pub fn pick_local_page_socket(targets: &[Value]) -> Option<String> {
targets
.iter()
.find(|t| t.get("type").and_then(Value::as_str) == Some("page"))
.and_then(|t| t.get("webSocketDebuggerUrl"))
.and_then(Value::as_str)
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
.filter(|t| {
!t.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.starts_with("devtools://")
})
.find_map(|t| t.get("webSocketDebuggerUrl").and_then(Value::as_str))
.map(str::to_string)
}
@@ -414,10 +429,9 @@ impl CdpConnection {
/// Turn a hang-up into the error it means.
///
/// The relay's close codes are its whole vocabulary: 1008 is "that credential
/// is no good", 1013 is "come back when the session is up". Reporting either
/// as a generic transport failure throws away the only actionable thing the
/// server said.
/// The close codes carry the only actionable thing the server says: 1008
/// means the credential was refused, 1013 means the session is not up yet.
/// Reporting either as a generic transport failure throws that away.
pub fn closed_error(&self, context: &str) -> CdpError {
match &self.closed {
Some(info) if info.reason.is_empty() => {
@@ -476,20 +490,20 @@ impl CdpConnection {
/// Hang up politely so the peer releases its side immediately.
///
/// On the relay every open socket costs a real stream on the leased host and
/// counts against the session's attachment cap, so dropping the TCP
/// connection and letting it time out is not good enough.
/// A remote socket that is not closed keeps consuming the session's
/// attachment budget, so dropping the TCP connection and letting it time out
/// is not good enough.
pub async fn close(mut self) {
let _ = self.stream.close(None).await;
}
/// Move a browser-level socket onto a page.
///
/// The relay bridges `/devtools/browser/<id>`. Every tool here speaks
/// `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers with
/// `'Page.navigate' wasn't found`. Attaching flat, and stamping the resulting
/// session id onto everything after it, is what makes the tools this app
/// already has work remotely without a single per-tool change.
/// The remote endpoint exposes `/devtools/browser/<id>`. Every tool here
/// speaks `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers
/// with `'Page.navigate' wasn't found`. Attaching flat, and stamping the
/// resulting session id onto everything after it, is what makes the tools
/// this app already has work remotely without a single per-tool change.
async fn attach_to_page(&mut self) -> Result<(), CdpError> {
let targets = self
.call(
@@ -589,7 +603,13 @@ pub async fn run_command_awaiting_load(
let text = match tokio::time::timeout(remaining, connection.next_text()).await {
Ok(Some(Ok(text))) => text,
Ok(Some(Err(e))) => {
failure = Some(e);
// A dropped connection cannot unsay a command the browser already
// answered: the navigation was accepted, and the command's result is
// the best answer available even if the load event never arrived.
// Only when nothing was answered yet is the lost socket a failure.
if command_result.is_none() {
failure = Some(e);
}
break;
}
// The peer hung up, or the wait expired. Either way whatever the command
@@ -635,8 +655,8 @@ pub async fn run_command_awaiting_load(
///
/// This is what "open a URL in that profile" means once the browser is already
/// up, wherever it is. A remote session navigates its existing page rather than
/// opening a tab: a tab opened on a leased host that nobody can see or close is
/// not a feature, it is litter on hardware the user is paying for by the hour.
/// opening a tab: a tab opened on a remote host that nobody can see or close is
/// not a feature, it is litter on time the user is paying for by the hour.
pub async fn navigate(target: &CdpTarget, url: &str, timeout_secs: u64) -> Result<(), CdpError> {
run_command_awaiting_load(
target,
@@ -701,7 +721,10 @@ impl CdpTarget {
} => {
let mut connection = dial_relay(ws_url, bearer).await?;
if let Err(e) = connection.attach_to_page().await {
log::warn!("Could not attach to a page in remote session {session_id}: {e}");
log::warn!(
"Could not attach to a page in remote session {}: {e}",
ShortId(session_id)
);
return Err(e);
}
Ok(connection)
@@ -724,9 +747,9 @@ pub type RelaySocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
/// target from it and stamp a session id onto messages it did not address.
///
/// This is what makes a remote session usable from outside the app at all. The
/// relay only accepts the user's cloud credential, which no API consumer holds
/// and none should — so the socket is opened here, with the credential this
/// process already has, and proxied to the caller.
/// endpoint only accepts the user's cloud credential, which no API consumer
/// holds and none should — so the socket is opened here, with the credential
/// this process already has, and proxied to the caller.
pub async fn open_relay_socket(session_id: &str) -> Result<RelaySocket, CdpError> {
let endpoint = crate::remote_session::cdp_endpoint(session_id)
.await
@@ -776,7 +799,7 @@ fn endpoint_lookup_error(err: crate::remote_session::RemoteSessionError) -> CdpE
}
}
/// Frame limits for a relay socket. Matches the relay's own client-facing cap.
/// Frame limits for a relay socket. Matches the cap the remote endpoint sets.
pub fn relay_socket_config() -> WebSocketConfig {
WebSocketConfig::default()
.max_message_size(Some(REMOTE_MAX_MESSAGE_BYTES))
@@ -929,12 +952,61 @@ mod tests {
assert!(pick_local_page_socket(&[]).is_none());
}
#[test]
fn a_page_that_cannot_be_driven_does_not_hide_the_one_that_can() {
// Chromium omits `webSocketDebuggerUrl` for a page another client is
// already attached to. Committing to the FIRST page and then reaching for
// its socket answered None for the whole browser, so every browser tool
// failed with "no page target found" while a drivable tab sat behind it.
let attached_first = vec![
serde_json::json!({ "type": "page", "url": "https://example.com/" }),
serde_json::json!({
"type": "page",
"url": "https://example.com/two",
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/B"
}),
];
assert_eq!(
pick_local_page_socket(&attached_first).as_deref(),
Some("ws://127.0.0.1:1/devtools/page/B")
);
// And the inspector is not a site. Attaching here drives DevTools itself -
// the failure `pick_remote_page_target` already documents, which reports
// success and moves nothing. The two pickers answer the same question off
// different payloads and must not disagree.
let devtools_first = vec![
serde_json::json!({
"type": "page",
"url": "devtools://devtools/bundled/devtools_app.html",
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/DEVTOOLS"
}),
serde_json::json!({
"type": "page",
"url": "https://example.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/REAL"
}),
];
assert_eq!(
pick_local_page_socket(&devtools_first).as_deref(),
Some("ws://127.0.0.1:1/devtools/page/REAL")
);
// A listing with pages but nothing drivable still answers None rather than
// handing back a non-page socket.
let nothing_drivable = vec![
serde_json::json!({ "type": "page", "url": "https://example.com/" }),
serde_json::json!({ "type": "worker", "webSocketDebuggerUrl": "ws://x/w" }),
];
assert!(pick_local_page_socket(&nothing_drivable).is_none());
}
#[test]
fn a_remote_frame_addresses_the_page_and_a_local_one_does_not() {
// A page-level command sent on the relay's BROWSER socket comes back as
// A page-level command sent on a BROWSER-level socket comes back as
// "'Page.navigate' wasn't found". One missing sessionId on one message is
// enough to make a single tool fail while every other tool works — a
// partial failure that reads as a flaky VM.
// partial failure that reads as a flaky remote browser.
let remote = cdp_frame(
Some("SESSION-42"),
7,
@@ -953,9 +1025,9 @@ mod tests {
#[test]
fn a_relay_close_says_what_the_caller_should_do_about_it() {
// These codes are the relay's entire vocabulary. Collapsing them into one
// transport failure is how "your session is still provisioning" and "you
// are signed out" both become "something went wrong".
// These codes carry the whole answer. Collapsing them into one transport
// failure is how "your session is still provisioning" and "you are signed
// out" both become "something went wrong".
assert!(matches!(
classify_close(1008, "x".into()),
CdpError::Unauthorized(_)
@@ -1032,9 +1104,9 @@ mod tests {
#[test]
fn a_session_that_is_over_is_not_reported_as_a_broken_gateway() {
// Observed against the real backend: attaching to a session the user had
// just stopped answered 502, so a CDP client read "this is finished" as
// "the gateway is down" and retried it.
// A session the user has already stopped, or one that is not theirs, must
// read as a 404: there is no browser at this address. Collapsing it into
// "unreachable" makes an automation client retry a finished session.
use crate::remote_session::RemoteSessionError;
assert!(matches!(
endpoint_lookup_error(RemoteSessionError::Other(
@@ -1084,7 +1156,7 @@ mod tests {
#[test]
fn a_hasty_probe_tries_once_and_a_patient_one_waits() {
// The split is what stops a profile running on the fleet from being held
// The split is what stops a profile running remotely from being held
// behind twenty-five seconds of local retries before anyone looks remote.
assert_eq!(Patience::Immediate.attempts(10), 1);
assert_eq!(Patience::WaitForLaunch.attempts(10), 10);
@@ -1114,12 +1186,21 @@ mod tests {
Cooperative,
/// Hang up the way a session that is not yet up does.
RefuseAsNotDrivable,
/// Answer the navigation, then drop the socket before the load event,
/// the way the remote endpoint does when its browser dies mid-navigation.
DropAfterNavigateReply,
/// Drop the socket without answering the navigation.
DropBeforeNavigateReply,
/// Answer the navigation with a CDP error object.
AnswerNavigateWithError,
/// Answer commands but never emit the load event, so the wait expires.
NeverLoadEvent,
}
/// The CDP session id the fake relay hands out for a flat attach.
const FAKE_CDP_SESSION: &str = "CDP-SESSION-1";
/// A stand-in for the infra relay bridged onto a browser-level socket.
/// A stand-in for the remote endpoint bridged onto a browser-level socket.
///
/// Answers `Target.getTargets` and `Target.attachToTarget` exactly as a real
/// browser endpoint does, then echoes each command back so the test can read
@@ -1185,18 +1266,32 @@ mod tests {
};
log.received.push(request.clone());
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
// A reply dropped mid-flight must not look like the command answered.
if behaviour == RelayBehaviour::DropBeforeNavigateReply && method == "Page.navigate" {
break;
}
let id = request.get("id").cloned().unwrap_or(Value::Null);
let reply = match request.get("method").and_then(Value::as_str) {
Some("Target.getTargets") => serde_json::json!({
let reply = match method {
"Target.getTargets" => serde_json::json!({
"id": id,
"result": { "targetInfos": [
{ "targetId": "page-1", "type": "page", "url": "https://example.com/" }
]}
}),
Some("Target.attachToTarget") => serde_json::json!({
"Target.attachToTarget" => serde_json::json!({
"id": id,
"result": { "sessionId": FAKE_CDP_SESSION }
}),
"Page.navigate" if behaviour == RelayBehaviour::AnswerNavigateWithError => {
serde_json::json!({
"id": id,
"sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null),
"error": { "code": -32000, "message": "fake navigation error" }
})
}
// Everything else is handed straight back, so the test can assert on
// the exact frame the client put on the wire.
_ => serde_json::json!({
@@ -1213,10 +1308,17 @@ mod tests {
break;
}
// The browser died mid-navigation: drop the socket without a close
// frame, the way the remote endpoint does when the browser behind it
// goes away. The command's reply is already in the client's hands.
if behaviour == RelayBehaviour::DropAfterNavigateReply && method == "Page.navigate" {
break;
}
// A real browser follows a navigation with the load event, flattened
// onto the same socket. Emitting it here is what proves the wait
// actually terminates on the event rather than on its timeout.
if request.get("method").and_then(Value::as_str) == Some("Page.navigate") {
if behaviour != RelayBehaviour::NeverLoadEvent && method == "Page.navigate" {
let loaded = serde_json::json!({
"method": "Page.loadEventFired",
"sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null),
@@ -1239,10 +1341,10 @@ mod tests {
#[tokio::test]
async fn a_relayed_page_command_is_attached_and_stamped_with_its_session() {
// This is the whole feature. The relay bridges /devtools/browser/<id>, so
// without the flat attach and the sessionId stamp every existing tool
// answers "'Page.navigate' wasn't found" and a paid remote session cannot
// be used for anything.
// This is the whole feature. The remote endpoint exposes
// /devtools/browser/<id>, so without the flat attach and the sessionId
// stamp every existing tool answers "'Page.navigate' wasn't found" and a
// paid remote session cannot be used for anything.
let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await;
let target = CdpTarget::Remote {
ws_url,
@@ -1332,7 +1434,7 @@ mod tests {
#[tokio::test]
async fn a_session_that_is_not_up_yet_is_reported_as_such_not_as_a_broken_one() {
// 1013 is the relay saying "come back when it is live". Surfacing it as a
// 1013 means "come back when it is live". Surfacing it as a
// transport failure would send an automation client into a retry loop
// against a session that is doing exactly what it should.
let (ws_url, _server) = fake_relay(RelayBehaviour::RefuseAsNotDrivable).await;
@@ -1377,4 +1479,76 @@ mod tests {
assert_eq!(methods, vec!["Runtime.evaluate"]);
assert!(log.received[0].get("sessionId").is_none());
}
#[tokio::test]
async fn a_navigation_result_survives_a_connection_dropped_before_the_load_event() {
// The race this fix targets: a fast navigation can kill the connection
// between the command reply and the load event. The browser already
// accepted the navigation, so the reply is the answer — losing the socket
// afterwards must not turn the answered command into a failure.
let (ws_url, server) = fake_relay(RelayBehaviour::DropAfterNavigateReply).await;
let target = CdpTarget::Local { ws_url };
navigate(&target, "https://example.com", 30)
.await
.expect("an answered navigation must survive a dropped connection");
let _log = server.await.expect("the fake relay must finish");
}
#[tokio::test]
async fn a_connection_dropped_before_the_reply_is_still_reported_as_a_failure() {
// The flip side of the race: if the socket dies before the command
// answered, there is nothing to prefer — the navigation may never have
// happened, so the call must still fail.
let (ws_url, server) = fake_relay(RelayBehaviour::DropBeforeNavigateReply).await;
let target = CdpTarget::Local { ws_url };
let error = navigate(&target, "https://example.com", 30)
.await
.expect_err("a navigation that never answered must not look like a success");
assert!(
matches!(error, CdpError::Transport(_)),
"expected Transport, got {error:?}"
);
let _log = server.await.expect("the fake relay must finish");
}
#[tokio::test]
async fn a_navigation_error_from_the_browser_is_still_reported() {
// A CDP error object is the browser saying no — that answer must not be
// swallowed by the load wait.
let (ws_url, server) = fake_relay(RelayBehaviour::AnswerNavigateWithError).await;
let target = CdpTarget::Local { ws_url };
let error = navigate(&target, "https://example.com", 30)
.await
.expect_err("a CDP error reply must surface as a failure");
assert!(
matches!(error, CdpError::Protocol(_)),
"expected Protocol, got {error:?}"
);
let _log = server.await.expect("the fake relay must finish");
}
#[tokio::test]
async fn a_navigation_without_a_load_event_returns_the_command_result() {
// When nothing navigates there is no load event; the wait expires and the
// command's own result is the answer, not a failure.
let (ws_url, server) = fake_relay(RelayBehaviour::NeverLoadEvent).await;
let target = CdpTarget::Local { ws_url };
let started = std::time::Instant::now();
navigate(&target, "https://example.com", 1)
.await
.expect("an answered command with no load event must still resolve");
assert!(
started.elapsed() >= Duration::from_secs(1),
"the wait must run to its deadline when no load event arrives"
);
let _log = server.await.expect("the fake relay must finish");
}
}
+178
View File
@@ -0,0 +1,178 @@
//! SHA256 helpers shared by the app self-updater and the browser downloader.
//!
//! Both verify a downloaded artifact against a digest published beside it, so
//! the hashing and the `sha256sum` parsing live here instead of in either
//! caller.
use std::path::Path;
/// Stream `path` through SHA256 and return the lowercase hex digest. Reads in
/// 1 MiB blocks so a multi-gigabyte browser archive never lands in memory.
pub fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1024 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write;
let _ = write!(hex, "{byte:02x}");
}
Ok(hex)
}
/// Extract the hex digest for `filename` from standard `sha256sum` output
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
pub fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
checksums_text.lines().find_map(|line| {
let (hash, rest) = line.split_once(char::is_whitespace)?;
let name = rest.trim_start().trim_start_matches('*');
if name == filename && is_sha256_hex(hash) {
Some(hash.to_ascii_lowercase())
} else {
None
}
})
}
/// Digest from a single-asset `<file>.sha256` sidecar.
///
/// Prefers the entry named `filename`, because a name binds the digest to the
/// asset it covers. Falls back to a lone digest only when the sidecar holds
/// exactly one: `sha256sum < file` writes `-` as the name and some publishers
/// emit the bare hash, and neither is ambiguous when it stands alone. A
/// sidecar listing several assets always needs the name to match.
pub fn parse_sidecar_digest(text: &str, filename: &str) -> Option<String> {
if let Some(named) = find_checksum_for_file(text, filename) {
return Some(named);
}
let mut digests = text
.lines()
.filter_map(|line| line.split_whitespace().next())
.filter(|token| is_sha256_hex(token));
let only = digests.next()?;
if digests.next().is_some() {
return None;
}
Some(only.to_ascii_lowercase())
}
fn is_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
const HELLO_WORLD_SHA256: &str =
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
#[test]
fn test_find_checksum_for_file() {
let sums = "\
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
not-a-hash Donut_0.29.0_amd64.deb
";
// Plain entry.
assert_eq!(
find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
);
// Binary-mode marker is stripped; hash is normalized to lowercase.
assert_eq!(
find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
);
// Entries with malformed hashes are rejected rather than trusted.
assert_eq!(find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"), None);
// Missing file.
assert_eq!(find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"), None);
}
#[test]
fn test_sha256_file_matches_known_digest() {
let temp_dir = tempfile::TempDir::new().unwrap();
let path = temp_dir.path().join("data.bin");
std::fs::write(&path, b"hello world").unwrap();
assert_eq!(sha256_file(&path).unwrap(), HELLO_WORLD_SHA256);
}
#[test]
fn test_parse_sidecar_digest_prefers_the_named_entry() {
// The real Wayfern sidecar shape: `<hex> <name>`, one asset per file.
let sidecar = format!("{HELLO_WORLD_SHA256} wayfern-151.0.7922.71_windows_x64.zip\n");
assert_eq!(
parse_sidecar_digest(&sidecar, "wayfern-151.0.7922.71_windows_x64.zip").as_deref(),
Some(HELLO_WORLD_SHA256)
);
}
#[test]
fn test_parse_sidecar_digest_accepts_an_unnamed_lone_digest() {
// `sha256sum < file` writes `-` as the name, and some publishers emit the
// bare hash. Both cover the one asset the sidecar sits beside.
for sidecar in [
format!("{HELLO_WORLD_SHA256} -\n"),
format!("{HELLO_WORLD_SHA256}\n"),
format!(" {HELLO_WORLD_SHA256} \n"),
] {
assert_eq!(
parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(),
Some(HELLO_WORLD_SHA256),
"should accept lone digest in {sidecar:?}"
);
}
}
#[test]
fn test_parse_sidecar_digest_normalizes_case() {
let sidecar = format!("{} -\n", HELLO_WORLD_SHA256.to_ascii_uppercase());
assert_eq!(
parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(),
Some(HELLO_WORLD_SHA256)
);
}
#[test]
fn test_parse_sidecar_digest_rejects_an_ambiguous_multi_entry_sidecar() {
let sidecar = format!(
"{HELLO_WORLD_SHA256} other.zip\n\
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 another.zip\n"
);
// Two candidates and neither is named `wayfern.zip`: guessing would defeat
// the point of the check.
assert_eq!(parse_sidecar_digest(&sidecar, "wayfern.zip"), None);
}
#[test]
fn test_parse_sidecar_digest_rejects_junk() {
assert_eq!(parse_sidecar_digest("", "wayfern.zip"), None);
assert_eq!(
parse_sidecar_digest("not-a-hash wayfern.zip", "wayfern.zip"),
None
);
// An HTML error page served with HTTP 200 must not read as a digest.
assert_eq!(
parse_sidecar_digest("<!doctype html><title>404</title>", "wayfern.zip"),
None
);
// Right shape, wrong length.
assert_eq!(
parse_sidecar_digest("abc123 wayfern.zip", "wayfern.zip"),
None
);
}
}
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,4 +1,4 @@
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
//! Turning a cloud API HTTP failure into a stable, translatable code.
//!
//! Every cloud transport in this crate flattens its failures through
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
@@ -55,7 +55,7 @@ pub struct FailureCodes {
/// The desktop has no cloud session at all.
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
/// The request never reached donutbrowser-infra.
/// The request never reached the cloud API.
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
/// The backend answered, but with nothing the user can act on.
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
@@ -243,8 +243,8 @@ mod tests {
#[test]
fn the_backends_own_code_wins_over_the_status_default() {
// The status table is a fallback for gateway pages. When infra names the
// failure, that name is the one the user's locale has a string for.
// The status table is a fallback for gateway pages. When the server names
// the failure, that name is the one the user's locale has a string for.
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
assert_eq!(failure.status, 403);
@@ -261,8 +261,8 @@ mod tests {
#[test]
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
// so a busy fleet is normal and must not look like an outage.
// 503 is "come back in a minute" — remote capacity is finite, so a busy
// period is normal and must not look like an outage.
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
}
@@ -298,10 +298,10 @@ mod tests {
#[test]
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
// which Nest serialises verbatim. Reading only the top level dropped every
// interpolated value: the timezone the user typed, the site limit, the
// hours a team had actually spent.
// The cookie-bot routes send every interpolated value nested under
// `params`. Reading only the top level dropped every one of them: the
// timezone the user typed, the site limit, the hours a team had actually
// spent.
let failure = classify(
400,
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
+120 -74
View File
@@ -1,10 +1,8 @@
//! Cookie-bot transport.
//!
//! The bot warms a profile's cookies overnight by driving it on a leased
//! remote host. NONE of that lives here: the schedule, the calendar maths, the
//! preset expansion, the site ordering, the dwell and scroll model, the pooled
//! budget and the nightly dispatcher are all held by donutbrowser-infra and
//! the Wayfern manager.
//! The bot warms a profile's cookies overnight by driving it on a remote host.
//! None of that behaviour lives here: the schedule and everything the bot
//! actually does are owned by the cloud API.
//!
//! This module is the wire only. It sends the user's own scalars — when to
//! run, for how long, which of their sites, which server-issued preset id —
@@ -19,10 +17,10 @@ use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::Duration;
/// Operating systems the fleet can lease. Linux is refused by the manager, so
/// refusing it here turns a nightly failure at 02:00 into a refusal at the
/// moment the user picks the profile.
pub const BOT_PLATFORMS: [&str; 2] = ["windows", "macos"];
/// Operating systems a remote run can be scheduled on. Anything else (a mobile
/// OS, a typo) has no host, so it is refused here rather than as a failed run
/// at 02:00.
pub const BOT_PLATFORMS: [&str; 3] = ["windows", "macos", "linux"];
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -139,7 +137,7 @@ pub struct CookieBotSchedule {
#[serde(default)]
pub slots: Vec<CookieBotSlot>,
pub timezone: String,
/// Server-issued preset id. Opaque here — what it expands to is infra's.
/// Server-issued preset id. Opaque here — what it expands to is the server's.
pub preset: String,
/// The template the sites came from, or `None` for the user's own list.
///
@@ -164,9 +162,9 @@ pub struct CookieBotSchedule {
pub encrypted_sync: bool,
#[serde(default)]
pub has_proxy: bool,
/// Whether that exit is one a leased fleet host could dial. Defaults to false
/// on an older server that does not send it, which reads as "not reachable"
/// and is the safe direction.
/// Whether that exit is one a remote host could dial. Defaults to false on an
/// older server that does not send it, which reads as "not reachable" and is
/// the safe direction.
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
@@ -252,7 +250,7 @@ pub struct CookieBotScheduleInput {
// Defaulting is safe in exactly one direction: `bool::default()` is false, so
// an unstamped input reads as "no sync, no proxy" and is REFUSED. The failure
// this must never have is the opposite one, a defaulted `has_proxy: true`
// warming a profile out of the fleet's own datacenter address.
// warming a profile out of the remote host's own address.
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
@@ -344,8 +342,8 @@ pub struct CookieBotRun {
#[serde(default)]
pub max_minutes: u32,
/// How many browser sessions this night is split into, and which one is
/// running. A night longer than one session's cap is checkpointed at each
/// boundary, and "chunk 2 of 3" is the only honest way to report that.
/// running. "chunk 2 of 3" is the only honest way to report a night the
/// server split.
#[serde(default)]
pub chunks_total: u32,
#[serde(default)]
@@ -403,10 +401,8 @@ pub struct CookieBotPreset {
/// A server-owned browsing template: a named answer to "what is this profile
/// for", which the user picks INSTEAD of typing a site list.
///
/// Carries no URLs, and must not gain any. The pool a template draws from is
/// server-side for the same reason a preset's browsing model is: a published
/// list is one a retailer can filter, and each profile is given its own sample
/// so the template never becomes a fleet-wide fingerprint.
/// Carries no URLs, and must not gain any: the site pool a template draws from
/// is server-owned, and a published list is one a retailer can filter.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotTemplate {
pub id: String,
@@ -506,8 +502,8 @@ pub struct RemoteHoursMember {
pub bot_hours: f64,
}
/// The single pooled remote-hour budget. Bot and interactive hours share it;
/// the breakdown is reporting, never a sub-cap.
/// The remote-hour budget as the server reports it, with the bot/interactive
/// breakdown it sends.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteHoursQuota {
pub granted_hours: f64,
@@ -606,15 +602,15 @@ pub struct CookieBotUsage {
///
/// The server is authoritative — it re-checks all of this and owns the parts
/// the client cannot see — but a profile that can never qualify should never
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
/// reach a confirm dialog, an hour of quota or a remote host. Returns the
/// `{"code":…}` string a Tauri command surfaces directly.
pub fn bot_precondition(
profile: &BrowserProfile,
exit: &crate::remote_exit::ExitReachability,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
// The host materialises the profile by pulling it from donut-sync. A
// local-only profile has nothing there, so there is no path to a run.
// A remote run obtains the profile through sync, so a local-only profile
// has nothing there and there is no path to a run.
return Err(error("COOKIE_BOT_REQUIRES_CLOUD_SYNC", &[]));
}
if profile.is_encrypted_sync() {
@@ -632,16 +628,16 @@ pub fn bot_precondition(
));
}
if profile.proxy_id.is_none() && profile.vpn_id.is_none() {
// Without one the run egresses from the fleet's own datacenter address.
// Hours of traffic from a hosting ASN is worse for the profile's identity
// than not warming it at all.
// Without one the run egresses from the remote host's own address instead
// of the user's exit, which is worse for the profile's identity than not
// warming it at all.
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
}
// ...and the exit has to be one the leased host can reach. The profile and its
// proxy record are pulled onto the fleet with no address rewriting, so
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
// run fails an hour has been leased and billed.
// ...and the exit has to be one a remote host can reach. Addresses are not
// rewritten in transit, so a proxy recorded as 127.0.0.1 arrives meaning THAT
// machine's own loopback — an ordinary mistake (an SSH tunnel, a local MITM
// proxy, a locally-run SOCKS client) that costs the user an hour of quota
// before it fails.
//
// Taken as an ARGUMENT rather than resolved here, for the same reason
// `ProfileState` is required rather than defaulted: resolving it needs the
@@ -649,6 +645,18 @@ pub fn bot_precondition(
// no test can set up and every caller silently depends on. `exit_reachability`
// is the one place that resolution happens; this stays a pure predicate over
// facts it is handed.
// A protocol a remote host cannot speak is a PERMANENT refusal, and it has to
// say so in its own words. A VLESS server is publicly routable, so the
// reachability question answers "yes" and the older message ("use a proxy with
// a public address") sends the user to fix an address that was never wrong; an
// enrolment accepted on that answer then fails remotely, once per scheduled
// run, until someone notices.
if let crate::remote_exit::ExitReachability::UnsupportedKind { kind, .. } = exit {
return Err(error(
"COOKIE_BOT_PROXY_KIND_UNSUPPORTED",
&[("kind", kind.as_str())],
));
}
if !exit.is_remote() {
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
}
@@ -660,8 +668,8 @@ pub fn bot_precondition(
/// The server holds the schedule; the PROFILE lives in the user's sync
/// namespace, so `sync_enabled`, `has_proxy` and the rest are only knowable
/// here. It requires them on every write rather than defaulting them, because
/// a defaulted `has_proxy` is a profile warmed out of the fleet's own
/// datacenter address.
/// a defaulted `has_proxy` is a profile warmed out of the remote host's own
/// address.
///
/// Derived in one place so the Tauri, REST and MCP call sites cannot drift into
/// three different answers about the same profile.
@@ -674,21 +682,20 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
// ...and, separately, whether anyone OTHER than this machine could use it.
// `has_proxy` answers "did the user bring an exit"; this answers "is that
// exit an address a leased host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail on the
// fleet. See `remote_exit`.
// exit an address a remote host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail remotely.
// See `remote_exit`.
proxy_remote_reachable: exit_reachability(profile).is_remote(),
// Always false: this data model has no mobile/touch profile. `resolved_os`
// yields only windows, macos or linux, and `bot_precondition` already
// refuses everything but the first two. Reported rather than omitted so the
// server keeps one required shape, and it stays authoritative it sees the
// real fingerprint on the host and can still refuse a run this cannot know
// to reject.
// yields only windows, macos or linux, and all three are supported
// remotely. Reported rather than omitted so the server keeps one
// required shape, and it stays authoritative: it sees the real fingerprint
// on the host and can still refuse a run this cannot know to reject.
touch_fingerprint: false,
// A VPN is one persistent tunnel, so the night's chunks share an exit. A
// stored proxy may rotate per connection, and claiming stickiness we cannot
// guarantee is worse than declining it: the server's fallback is to run the
// night as a single chunk, which is the safe answer either way.
// guarantee is worse than declining it, so the conservative answer is the
// safe one either way.
sticky_exit: profile.vpn_id.is_some(),
}
}
@@ -700,7 +707,7 @@ pub struct ProfileState {
pub sync_enabled: bool,
pub encrypted_sync: bool,
pub has_proxy: bool,
/// Whether that exit is an address a leased fleet host can dial.
/// Whether that exit is an address a remote host can dial.
pub proxy_remote_reachable: bool,
pub touch_fingerprint: bool,
pub sticky_exit: bool,
@@ -713,7 +720,8 @@ pub struct ProfileState {
/// even then would have to re-derive what the browser will actually dial.
///
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
/// what the browser is pointed at, and it is the address the fleet has to reach.
/// what the browser is pointed at, and it is the address a remote host has to
/// reach.
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
@@ -916,7 +924,7 @@ pub async fn update_profile_state(
///
/// The server refuses a run on the copy the desktop last declared —
/// `has_proxy: false` is `proxy_required`, and that check exists because a run
/// without an exit node egresses from the leased host's own datacenter address.
/// without an exit node egresses from the remote host's own address.
/// Nothing but a full schedule write refreshed that copy, so detaching a proxy
/// from an enrolled profile left `has_proxy: true` on the row and the night ran
/// anyway. This closes that gap at the moment the profile changes.
@@ -1046,10 +1054,10 @@ pub async fn run_now(
/// Stop a run that is still going.
///
/// A 503 here means the fleet could not be reached and the browser is still
/// up, so the run stays `running` rather than being marked cancelled under a
/// live browser — retiring a row while something is still writing the cookie
/// jar is the two-writer case the profile lock exists to prevent.
/// A 503 here means the remote host could not be reached and the browser is
/// still up, so the run stays `running` rather than being marked cancelled
/// under a live browser — retiring a row while something is still writing the
/// cookie jar is the two-writer case the profile lock exists to prevent.
pub async fn cancel_run(run_id: &str) -> Result<CookieBotRun, CookieBotError> {
let envelope: RunEnvelope = request(
reqwest::Method::DELETE,
@@ -1294,7 +1302,7 @@ fn http() -> &'static reqwest::Client {
///
/// Built here rather than left to the HTTP client so a profile id or a keyset
/// cursor containing a `&` cannot smuggle a second parameter into the request.
fn with_query(url: &str, query: &[(String, String)]) -> String {
pub(crate) fn with_query(url: &str, query: &[(String, String)]) -> String {
if query.is_empty() {
return url.to_string();
}
@@ -1430,9 +1438,9 @@ mod tests {
#[test]
fn a_local_only_profile_has_no_path_to_a_run() {
// The host obtains the profile from donut-sync. Without sync there is
// nothing to pull, so the run would warm an empty browser and then push
// that emptiness over the user's real profile.
// A remote run obtains the profile through sync, so a local-only profile
// has nothing there: the run would warm an empty browser and then push that
// emptiness over the user's real profile.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Disabled;
let err = bot_precondition(&profile, &ExitReachability::Remote)
@@ -1452,19 +1460,37 @@ mod tests {
}
#[test]
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
fn an_os_the_fleet_cannot_lease_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
profile.host_os = Some("android".to_string());
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("linux has no host to lease");
.expect_err("android has no host to lease");
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
assert_eq!(
parsed["params"]["platform"], "linux",
parsed["params"]["platform"], "android",
"the message must name the platform that cannot run"
);
}
#[test]
fn a_linux_profile_passes_the_platform_check() {
// Linux is a supported remote platform, so a linux profile is judged on
// the same preconditions as the other two rather than refused for its OS.
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
// ...and it reaches the NEXT precondition when it fails one: the refusal a
// linux profile with no exit gets is the exit-node code, not the platform
// code.
profile.proxy_id = None;
profile.vpn_id = None;
let err = bot_precondition(&profile, &ExitReachability::None)
.expect_err("datacenter egress must be refused for linux as for any OS");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
}
#[test]
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
let mut profile = eligible_profile();
@@ -1522,10 +1548,32 @@ mod tests {
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
#[test]
fn a_proxy_kind_the_fleet_cannot_dial_gets_its_own_refusal() {
// The repeated-nightly-failure case. This must NOT collapse into
// REQUIRES_REMOTE_EXIT_NODE: that sentence tells the user their proxy's
// address is unreachable, and a VLESS server's address is perfectly
// reachable — the fix is a different protocol, not a different address.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::UnsupportedKind {
kind: "VLESS".to_string(),
source: "proxy",
},
)
.expect_err("no fleet host runs the xray sidecar VLESS needs");
assert_eq!(code_of(&err), "COOKIE_BOT_PROXY_KIND_UNSUPPORTED");
// The protocol travels in `params` so the sentence can name it rather than
// saying "this proxy type" and leaving the user to guess which one.
let parsed: serde_json::Value = serde_json::from_str(&err).expect("an error envelope");
assert_eq!(parsed["params"]["kind"], "VLESS");
}
#[test]
fn an_exit_we_could_not_read_is_refused_too() {
// Fails closed. Refusing a working setup costs one support question;
// accepting a broken one burns a leased hour and damages an identity.
// accepting a broken one burns an hour of quota and damages an identity.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::Unknown {
@@ -1538,8 +1586,7 @@ mod tests {
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
/// A verbatim schedule payload, field for field, as the cloud API sends it.
const SERVER_SCHEDULE_VIEW: &str = r#"{
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
"run_at_minute":120,"days_mask":127,
@@ -1555,10 +1602,9 @@ mod tests {
#[test]
fn the_schedule_payload_matches_what_the_backend_sends() {
// Pinned against the Schedule shape in donutbrowser-infra's
// cookie-bot controller. A field name that drifts makes every read fail
// at the decode step, which surfaces as "something went wrong" with no
// hint that the contract moved.
// Pinned against the schedule shape the cloud API serves. A field name
// that drifts makes every read fail at the decode step, which surfaces as
// "something went wrong" with no hint that the contract moved.
let schedule: CookieBotSchedule = serde_json::from_str(SERVER_SCHEDULE_VIEW)
.expect("the backend's schedule payload must deserialize");
@@ -1737,7 +1783,7 @@ mod tests {
#[test]
fn the_run_payload_matches_what_the_backend_sends() {
// Verbatim `CookieBotRunView`, as `toRunViews` builds it. `max_minutes`,
// Verbatim run payload, exactly as the cloud API serves it. `max_minutes`,
// `chunks_total`, `chunk_index`, `dispatch_after` and `team_id` were all
// already on the wire and all silently discarded, so a multi-chunk night
// could not be reported as one.
@@ -1968,9 +2014,9 @@ mod tests {
#[test]
fn the_preset_list_carries_ids_not_behaviour() {
// If this type ever gained a site list, a dwell range or a step
// programme, the browsing model would have leaked into the open-source
// client. Ids and a rough duration are all that may cross.
// If this type ever gained the parameters that describe what a preset
// actually does, the server-owned browsing model would have leaked into the
// open-source client. Ids and a rough duration are all that may cross.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[{"id":"balanced","typical_minutes":35,"recommended":true}],
"default_preset":"balanced"}"#,
@@ -1988,9 +2034,9 @@ mod tests {
#[test]
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
// The pool is server-owned for the same reason a preset's browsing model
// is. If this type ever gained a `sites` field the curation would be
// published, and a published list is one a retailer can filter.
// The site pool is server-owned. If this type ever gained a `sites` field
// the curation would be published, and a published list is one a retailer
// can filter.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[],"default_preset":"balanced",
"templates":[{"id":"low-intent-purchaser","site_count":32,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+648 -188
View File
@@ -1,7 +1,31 @@
use serde::Serialize;
use tauri::command;
pub struct DefaultBrowser {}
/// What happened when the user asked Donut to become the default browser.
///
/// macOS and Linux let a program make the change itself. Windows does not. The
/// registry value that decides the handler carries a signature only the shell
/// can produce, so the most a program may do is register itself and open the
/// page where the user makes the choice. Without this distinction the caller
/// reports a change that has not happened yet, which is what the Windows path
/// used to do.
///
/// Each platform builds exactly one of these, so on any single target the other
/// one reads as never constructed. That is what the allow is for: the variant is
/// live, just not on the host being compiled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase", tag = "status")]
#[allow(dead_code)]
pub enum SetDefaultOutcome {
/// Donut is the default browser now. Nothing is left for the user to do.
Set,
/// Registration is complete and the system settings page is open. The user
/// makes the final choice there.
AwaitingSystemSettings,
}
impl DefaultBrowser {
fn new() -> Self {
Self {}
@@ -21,7 +45,7 @@ impl DefaultBrowser {
// Linux answers this by running `xdg-mime`, a shell script that forks
// further. That is blocking work with no upper bound, and this command
// runs on the same async runtime as every other command, the REST API and
// the sync scheduler so doing it inline occupies a worker for as long as
// the sync scheduler, so doing it inline occupies a worker for as long as
// the desktop takes to answer. The Settings page polls this on a timer.
#[cfg(target_os = "linux")]
return blocking(linux::is_default_browser).await;
@@ -30,16 +54,22 @@ impl DefaultBrowser {
Err("Unsupported platform".to_string())
}
pub async fn set_as_default_browser(&self) -> Result<(), String> {
pub async fn set_as_default_browser(&self) -> Result<SetDefaultOutcome, String> {
#[cfg(target_os = "macos")]
return macos::set_as_default_browser();
return macos::set_as_default_browser().map(|()| SetDefaultOutcome::Set);
// Windows writes several registry trees, broadcasts `WM_SETTINGCHANGE` to
// every top-level window on the desktop and then hands off to the shell.
// The broadcast alone costs about 130 ms on an idle desktop and seconds on
// a busy one, so this does not belong on a runtime worker either.
#[cfg(target_os = "windows")]
return windows::set_as_default_browser();
return blocking(windows::set_as_default_browser).await;
// Same reasoning, and this one additionally sleeps 500ms before verifying.
#[cfg(target_os = "linux")]
return blocking(linux::set_as_default_browser).await;
return blocking(linux::set_as_default_browser)
.await
.map(|()| SetDefaultOutcome::Set);
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
@@ -47,7 +77,7 @@ impl DefaultBrowser {
}
/// Run blocking work off the async runtime's worker threads.
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
async fn blocking<T, F>(work: F) -> Result<T, String>
where
F: FnOnce() -> Result<T, String> + Send + 'static,
@@ -124,18 +154,44 @@ mod macos {
#[cfg(target_os = "windows")]
#[allow(clippy::needless_borrows_for_generic_args)]
mod windows {
use super::SetDefaultOutcome;
use std::path::Path;
use winreg::enums::*;
use winreg::RegKey;
/// The key Windows knows us by. Never shown to a person.
const APP_NAME: &str = "DonutBrowser";
/// The name Windows shows in "Default apps" and in "Open with".
const DISPLAY_NAME: &str = "Donut Browser";
const DESCRIPTION: &str = "Donut Browser - Simple Yet Powerful Anti-Detect Browser";
const PROG_ID: &str = "DonutBrowser.HTML";
pub fn is_default_browser() -> Result<bool, String> {
let schemes = ["http", "https"];
/// A web browser registers under `StartMenuInternet`, and
/// `RegisteredApplications` points at the `Capabilities` subkey of that
/// entry. Edge, Chrome and Firefox all do exactly this, and the shell reads
/// the capability data from there.
///
/// The previous layout invented its own key at `Software\DonutBrowser` and
/// pointed `RegisteredApplications` at the parent instead of at
/// `Capabilities`. Every other entry on a normal machine ends in
/// `Capabilities`. The shell found no capability data, so Donut was never
/// offered as a browser and the button appeared to do nothing.
const CLIENT_KEY: &str = r"Software\Clients\StartMenuInternet\DonutBrowser";
/// The value written into `RegisteredApplications`.
const CAPABILITIES_KEY: &str = r"Software\Clients\StartMenuInternet\DonutBrowser\Capabilities";
/// The layout earlier builds wrote. Removed on every run, so a machine that
/// ran one of those does not keep stale capability data claiming http.
const LEGACY_APP_KEY: &str = r"Software\DonutBrowser";
for scheme in schemes {
// Check if our browser is set as the default handler for this scheme
const URL_SCHEMES: [&str; 2] = ["http", "https"];
/// The file types a browser is asked to open from Explorer. The ProgId
/// command passes the path through as `%1`, and `urls_from_args` in `lib.rs`
/// turns a path into a `file://` URL, so every extension listed here can
/// actually be serviced. Do not add one that cannot.
const FILE_EXTENSIONS: [&str; 4] = [".htm", ".html", ".shtml", ".xhtml"];
pub fn is_default_browser() -> Result<bool, String> {
for scheme in URL_SCHEMES {
if !is_default_for_scheme(scheme)? {
return Ok(false);
}
@@ -144,44 +200,42 @@ mod windows {
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
// Get the current executable path
let exe_path = std::env::current_exe()
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
pub fn set_as_default_browser() -> Result<SetDefaultOutcome, String> {
let exe_path =
std::env::current_exe().map_err(|e| format!("Failed to get current executable path: {e}"))?;
let exe_path_str = exe_path
let exe_path = exe_path
.to_str()
.ok_or("Failed to convert executable path to string")?;
// Verify the executable exists
if !Path::new(exe_path_str).exists() {
return Err(format!("Executable not found at: {}", exe_path_str));
if !Path::new(exe_path).exists() {
return Err(format!("Executable not found at: {exe_path}"));
}
// Register the application
register_application(exe_path_str)?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
remove_legacy_registration(&hkcu);
register_prog_id(&hkcu, exe_path)?;
register_client(&hkcu, exe_path)?;
register_file_extensions(&hkcu)?;
register_application(&hkcu)?;
// Set as default for HTTP and HTTPS
set_default_for_scheme("http")?;
set_default_for_scheme("https")?;
// Register file associations for HTML files
register_html_file_association(exe_path_str)?;
// Notify the system of changes
notify_system_of_changes();
Ok(())
open_default_apps_settings()?;
Ok(SetDefaultOutcome::AwaitingSystemSettings)
}
/// Wrap a path in the quotes the shell expects around a command or an icon.
fn quoted(value: &str) -> String {
format!(r#""{value}""#)
}
fn is_default_for_scheme(scheme: &str) -> Result<bool, String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Check Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice
let path = format!(
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
scheme
);
let path =
format!(r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice");
match hkcu.open_subkey(&path) {
Ok(key) => match key.get_value::<String, _>("ProgId") {
@@ -192,204 +246,512 @@ mod windows {
}
}
fn register_application(exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
/// Delete the layout earlier builds wrote.
///
/// Nothing else in this application has ever written under that key, so
/// removing it cannot lose anything a user cares about. Leaving it would
/// leave a second `Capabilities` block claiming http and https from a key the
/// shell no longer reads.
///
/// The old code also wrote the ProgId into the default value of
/// `Software\Classes\.html` and `.htm`. That value is the association itself,
/// and it was never ours to take. Give it back, but only where it still holds
/// the ProgId we wrote. Any other value is the user's own choice and is left
/// alone.
fn remove_legacy_registration(root: &RegKey) {
match root.delete_subkey_all(LEGACY_APP_KEY) {
Ok(()) => log::debug!("Removed the superseded default-browser registration key"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => log::debug!("Could not remove the superseded registration key: {e}"),
}
// Register in Software\RegisteredApplications
let (registered_apps, _) = hkcu
.create_subkey("Software\\RegisteredApplications")
.map_err(|e| format!("Failed to create RegisteredApplications key: {}", e))?;
for extension in [".htm", ".html"] {
let path = format!(r"Software\Classes\{extension}");
let Ok(key) = root.open_subkey_with_flags(&path, KEY_READ | KEY_SET_VALUE) else {
continue;
};
registered_apps
.set_value(APP_NAME, &format!("Software\\{}", APP_NAME))
.map_err(|e| format!("Failed to set registered application: {}", e))?;
let ours = key
.get_value::<String, _>("")
.map(|value| value == PROG_ID)
.unwrap_or(false);
// Create application key
let (app_key, _) = hkcu
.create_subkey(&format!("Software\\{}", APP_NAME))
.map_err(|e| format!("Failed to create application key: {}", e))?;
// Set application properties
app_key
.set_value("ApplicationName", &APP_NAME)
.map_err(|e| format!("Failed to set ApplicationName: {}", e))?;
app_key
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
)
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
app_key
.set_value("ApplicationIcon", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
// Create Capabilities key
let (capabilities, _) = app_key
.create_subkey("Capabilities")
.map_err(|e| format!("Failed to create Capabilities key: {}", e))?;
capabilities
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
)
.map_err(|e| format!("Failed to set Capabilities description: {}", e))?;
// Set URL associations
let (url_assoc, _) = capabilities
.create_subkey("URLAssociations")
.map_err(|e| format!("Failed to create URLAssociations key: {}", e))?;
url_assoc
.set_value("http", &PROG_ID)
.map_err(|e| format!("Failed to set http association: {}", e))?;
url_assoc
.set_value("https", &PROG_ID)
.map_err(|e| format!("Failed to set https association: {}", e))?;
// Set file associations
let (file_assoc, _) = capabilities
.create_subkey("FileAssociations")
.map_err(|e| format!("Failed to create FileAssociations key: {}", e))?;
file_assoc
.set_value(".html", &PROG_ID)
.map_err(|e| format!("Failed to set .html association: {}", e))?;
file_assoc
.set_value(".htm", &PROG_ID)
.map_err(|e| format!("Failed to set .htm association: {}", e))?;
// Register the ProgID
register_prog_id(exe_path)?;
Ok(())
if ours {
match key.delete_value("") {
Ok(()) => log::debug!("Released the {extension} association taken by an older build"),
Err(e) => log::debug!("Could not release the {extension} association: {e}"),
}
}
}
}
fn register_prog_id(exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Create ProgID key
let (prog_id_key, _) = hkcu
.create_subkey(&format!("Software\\Classes\\{}", PROG_ID))
.map_err(|e| format!("Failed to create ProgID key: {}", e))?;
/// Describe the document type Donut opens, and how to open one.
fn register_prog_id(root: &RegKey, exe_path: &str) -> Result<(), String> {
let (prog_id_key, _) = root
.create_subkey(format!(r"Software\Classes\{PROG_ID}"))
.map_err(|e| format!("Failed to create ProgID key: {e}"))?;
prog_id_key
.set_value("", &"Donut Browser Document")
.map_err(|e| format!("Failed to set ProgID default value: {}", e))?;
.map_err(|e| format!("Failed to set ProgID default value: {e}"))?;
prog_id_key
.set_value("FriendlyTypeName", &"Donut Browser Document")
.map_err(|e| format!("Failed to set FriendlyTypeName: {}", e))?;
.map_err(|e| format!("Failed to set FriendlyTypeName: {e}"))?;
// The shell reads this block to put a name and an icon beside the ProgId in
// the "Open with" list. Without it the entry shows as the raw ProgId.
let (application, _) = prog_id_key
.create_subkey("Application")
.map_err(|e| format!("Failed to create ProgID Application key: {e}"))?;
application
.set_value("ApplicationName", &DISPLAY_NAME)
.map_err(|e| format!("Failed to set ProgID ApplicationName: {e}"))?;
application
.set_value("ApplicationIcon", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set ProgID ApplicationIcon: {e}"))?;
// Create DefaultIcon key
let (icon_key, _) = prog_id_key
.create_subkey("DefaultIcon")
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
.map_err(|e| format!("Failed to create DefaultIcon key: {e}"))?;
icon_key
.set_value("", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set default icon: {}", e))?;
.set_value("", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set default icon: {e}"))?;
// Create shell\open\command key
let (command_key, _) = prog_id_key
.create_subkey("shell\\open\\command")
.map_err(|e| format!("Failed to create command key: {}", e))?;
.create_subkey(r"shell\open\command")
.map_err(|e| format!("Failed to create command key: {e}"))?;
command_key
.set_value("", &format!("\"{}\" \"%1\"", exe_path))
.map_err(|e| format!("Failed to set command: {}", e))?;
.set_value("", &format!(r#"{} "%1""#, quoted(exe_path)))
.map_err(|e| format!("Failed to set command: {e}"))?;
Ok(())
}
fn set_default_for_scheme(scheme: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
/// The `StartMenuInternet` entry: the shape the shell reads for a web
/// browser. A display name, an icon, the command that starts it, the
/// `InstallInfo` block the default-programs page expects, and the capability
/// lists that say which schemes and file types it handles.
fn register_client(root: &RegKey, exe_path: &str) -> Result<(), String> {
let (client, _) = root
.create_subkey(CLIENT_KEY)
.map_err(|e| format!("Failed to create browser client key: {e}"))?;
// Set in Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.html\UserChoice
// Note: On Windows 10+, this might require elevated permissions or user interaction
// through the Settings app due to security restrictions
client
.set_value("", &DISPLAY_NAME)
.map_err(|e| format!("Failed to set client display name: {e}"))?;
// Try to set the association in the user's choice
let user_choice_path = format!(
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
scheme
);
let (icon, _) = client
.create_subkey("DefaultIcon")
.map_err(|e| format!("Failed to create client DefaultIcon key: {e}"))?;
// Note: Setting UserChoice directly may not work on Windows 10+ due to hash verification
// The user may need to manually set the default browser through Windows Settings
match hkcu.create_subkey(&user_choice_path) {
Ok((user_choice, _)) => {
// Attempt to set the ProgId
if user_choice.set_value("ProgId", &PROG_ID).is_err() {
// If we can't set UserChoice, that's expected on newer Windows versions
// The registration is still valuable for the "Open with" menu
}
}
Err(_) => {
// Expected on newer Windows versions - user must set manually
}
icon
.set_value("", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set client icon: {e}"))?;
let (command, _) = client
.create_subkey(r"shell\open\command")
.map_err(|e| format!("Failed to create client command key: {e}"))?;
// No `%1` here. This entry is how the shell starts the browser with no
// document, for example from the Start menu.
command
.set_value("", &quoted(exe_path))
.map_err(|e| format!("Failed to set client command: {e}"))?;
// The shell reads the icons-visible state from here, so the block has to
// exist. It also understands `ReinstallCommand`, `HideIconsCommand` and
// `ShowIconsCommand`, and Edge and Chrome advertise all three. Donut does
// not, because it does not act on `--make-default-browser`, `--hide-icons`
// or `--show-icons`. Advertising a command the program ignores is the same
// empty claim as registering a file type nothing can open. Add them here on
// the day the flags do something.
let (install_info, _) = client
.create_subkey("InstallInfo")
.map_err(|e| format!("Failed to create InstallInfo key: {e}"))?;
install_info
.set_value("IconsVisible", &1u32)
.map_err(|e| format!("Failed to set IconsVisible: {e}"))?;
let (capabilities, _) = client
.create_subkey("Capabilities")
.map_err(|e| format!("Failed to create Capabilities key: {e}"))?;
// `ApplicationName` belongs inside `Capabilities`. The old code wrote it one
// level up, where the shell does not look, so the entry had no name.
capabilities
.set_value("ApplicationName", &DISPLAY_NAME)
.map_err(|e| format!("Failed to set ApplicationName: {e}"))?;
capabilities
.set_value("ApplicationDescription", &DESCRIPTION)
.map_err(|e| format!("Failed to set ApplicationDescription: {e}"))?;
capabilities
.set_value("ApplicationIcon", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set ApplicationIcon: {e}"))?;
let (url_assoc, _) = capabilities
.create_subkey("URLAssociations")
.map_err(|e| format!("Failed to create URLAssociations key: {e}"))?;
for scheme in URL_SCHEMES {
url_assoc
.set_value(scheme, &PROG_ID)
.map_err(|e| format!("Failed to set {scheme} association: {e}"))?;
}
let (file_assoc, _) = capabilities
.create_subkey("FileAssociations")
.map_err(|e| format!("Failed to create FileAssociations key: {e}"))?;
for extension in FILE_EXTENSIONS {
file_assoc
.set_value(extension, &PROG_ID)
.map_err(|e| format!("Failed to set {extension} association: {e}"))?;
}
Ok(())
}
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
/// Offer Donut in the "Open with" list for the HTML file types, without
/// taking the association away from whatever the user already chose.
///
/// The old code wrote the ProgId into the default value of
/// `Software\Classes\.html`, which is the association itself. That replaced
/// the user's choice without asking, was never undone on uninstall, and did
/// not even take effect, because the per-user `FileExts` choice outranks it.
/// `OpenWithProgids` is the additive form: it adds Donut to the list and
/// displaces nothing.
fn register_file_extensions(root: &RegKey) -> Result<(), String> {
for extension in FILE_EXTENSIONS {
let (open_with, _) = root
.create_subkey(format!(r"Software\Classes\{extension}\OpenWithProgids"))
.map_err(|e| format!("Failed to create OpenWithProgids key for {extension}: {e}"))?;
// Register .html and .htm file associations
for ext in &[".html", ".htm"] {
let ext_path = format!("Software\\Classes\\{}", ext);
match hkcu.create_subkey(&ext_path) {
Ok((ext_key, _)) => {
// Set the default value to our ProgID
let _ = ext_key.set_value("", &PROG_ID);
}
Err(_) => {
// Continue if we can't set the file association
}
}
// Only the value name matters here. The payload is a marker.
open_with
.set_value(PROG_ID, &"")
.map_err(|e| format!("Failed to register the {extension} handler: {e}"))?;
}
Ok(())
}
/// Point `RegisteredApplications` at the capability data. This is what puts
/// Donut in the list Windows offers under "Default apps".
fn register_application(root: &RegKey) -> Result<(), String> {
let (registered_apps, _) = root
.create_subkey(r"Software\RegisteredApplications")
.map_err(|e| format!("Failed to create RegisteredApplications key: {e}"))?;
registered_apps
.set_value(APP_NAME, &CAPABILITIES_KEY)
.map_err(|e| format!("Failed to set registered application: {e}"))
}
/// Open the page where the user chooses the default browser.
///
/// Windows does not let a program make itself the default. The value that
/// decides the handler, the `UserChoice` key under `UrlAssociations`, carries
/// a hash over the user's SID, the ProgId and a timestamp, and only the shell
/// can produce it. Windows 11 also ships UCPD.sys, which blocks writes to
/// those keys outright.
///
/// The old code wrote `ProgId` there with no hash and discarded every error,
/// then reported success. The registry never changed, the Settings page went
/// on saying "Inactive", and the user was told nothing. Registration is the
/// part a program is allowed to do. The choice belongs to the user, so open
/// the page where they can make it and let the caller say so.
fn open_default_apps_settings() -> Result<(), String> {
use windows::core::{HSTRING, PCWSTR};
use windows::Win32::System::Com::{
CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE,
};
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
// `registeredAppUser` makes the page open on our entry rather than at the
// top of the list. It is the name just written into
// `RegisteredApplications`, so it only resolves because registration ran
// first.
let target = HSTRING::from(format!(
"ms-settings:defaultapps?registeredAppUser={APP_NAME}"
));
let operation = HSTRING::from("open");
// ShellExecuteW hands the URI to a shell extension, and shell extensions
// are COM objects. This runs on a `spawn_blocking` thread, which has no
// apartment of its own, so give it one. An error means the thread already
// had an apartment in another mode, and in that case it is not ours to
// tear down.
let com_status =
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) };
let owns_com = com_status.is_ok();
let result = unsafe {
ShellExecuteW(
None,
PCWSTR(operation.as_ptr()),
PCWSTR(target.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
if owns_com {
unsafe { CoUninitialize() };
}
// ShellExecuteW reports success as a value above 32. Anything at or below
// that is an error code wearing a handle's type.
let code = result.0 as isize;
if code <= 32 {
return Err(format!(
"Donut Browser is registered, but Windows Settings did not open (code {code}). Open Settings, then Apps, then Default apps, find Donut Browser and set it for HTTP and HTTPS."
));
}
Ok(())
}
/// Tell the shell that the association it has cached is stale.
///
/// `SHChangeNotify` is the documented announcement for an association change,
/// and the `WM_SETTINGCHANGE` broadcast is what the shell's own settings UI
/// sends alongside it, so both go out.
///
/// This used to hand-declare `SendMessageTimeoutA` with `lpdwResult` typed as
/// `*mut u32` and pass it a `u32`. The real parameter is `PDWORD_PTR`, eight
/// bytes on x64, so every call wrote four bytes past a stack slot. The result
/// was a corrupted stack at the exact moment a user set Donut as their default
/// browser, and the process died with nothing in the log. Go through the
/// `windows` crate instead, which types the out-parameter correctly and cannot
/// drift from the real ABI.
fn notify_system_of_changes() {
// Use Windows API to notify the system of association changes
// This helps refresh the system's understanding of the changes
use windows::core::w;
use windows::Win32::Foundation::{LPARAM, WPARAM};
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST};
use windows::Win32::UI::WindowsAndMessaging::{
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
};
unsafe {
use std::ffi::c_void;
SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None);
const HWND_BROADCAST: *mut c_void = 0xffff as *mut c_void;
const WM_SETTINGCHANGE: u32 = 0x001A;
const SMTO_ABORTIFHUNG: u32 = 0x0002;
extern "system" {
fn SendMessageTimeoutA(
hWnd: *mut c_void,
Msg: u32,
wParam: usize,
lParam: isize,
fuFlags: u32,
uTimeout: u32,
lpdwResult: *mut u32,
) -> isize;
}
let mut result: u32 = 0;
SendMessageTimeoutA(
// The broadcast is best-effort: a hung top-level window elsewhere on the
// desktop must not hold up the click that triggered this, hence the
// timeout and SMTO_ABORTIFHUNG. `WM_SETTINGCHANGE`'s lParam string is
// marshalled cross-process by the window manager, and this one is
// 'static, so it stays valid for the whole call.
let mut result: usize = 0;
SendMessageTimeoutW(
HWND_BROADCAST,
WM_SETTINGCHANGE,
0,
c"Software\\Classes".as_ptr() as isize,
WPARAM(0),
LPARAM(w!("Software\\Classes").as_ptr() as isize),
SMTO_ABORTIFHUNG,
1000,
&mut result,
Some(&mut result),
);
}
}
#[cfg(test)]
mod registration_tests {
use super::*;
/// A scratch key that stands in for HKCU, so the test writes a real tree
/// through the real code without touching the tree Windows actually reads.
/// Deleted on the way out, including when an assertion fails.
struct ScratchRoot {
key: RegKey,
path: String,
}
const SCRATCH_PARENT: &str = r"Software\DonutBrowserTests";
impl ScratchRoot {
fn new(name: &str) -> Self {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let path = format!(r"{SCRATCH_PARENT}\{name}");
let _ = hkcu.delete_subkey_all(&path);
let (key, _) = hkcu.create_subkey(&path).expect("create the scratch root");
Self { key, path }
}
fn value(&self, subkey: &str, name: &str) -> Option<String> {
self
.key
.open_subkey(subkey)
.ok()?
.get_value::<String, _>(name)
.ok()
}
}
impl Drop for ScratchRoot {
fn drop(&mut self) {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let _ = hkcu.delete_subkey_all(&self.path);
// Take the shared parent too, so a test run leaves nothing at all in
// the user's registry. `delete_subkey` refuses a key that still has
// children, which is exactly the guard needed while tests run in
// parallel: whoever finishes last removes it.
let _ = hkcu.delete_subkey(SCRATCH_PARENT);
}
}
const EXE: &str = r"C:\Program Files\Donut Browser\donutbrowser.exe";
#[test]
fn registration_writes_the_shape_the_shell_reads() {
let root = ScratchRoot::new("registration");
register_prog_id(&root.key, EXE).expect("register the ProgId");
register_client(&root.key, EXE).expect("register the client");
register_file_extensions(&root.key).expect("register the file types");
register_application(&root.key).expect("register the application");
// The bug that made the button do nothing: this pointed at the
// application key instead of at its `Capabilities` subkey, so the shell
// read no capabilities and never offered Donut as a browser. Every other
// entry on a working machine ends in `Capabilities`.
let registered = root
.value(r"Software\RegisteredApplications", APP_NAME)
.expect("RegisteredApplications entry");
assert_eq!(registered, CAPABILITIES_KEY);
assert!(
registered.ends_with(r"\Capabilities"),
"RegisteredApplications must name the Capabilities subkey, got {registered}"
);
assert!(
root.key.open_subkey(&registered).is_ok(),
"RegisteredApplications names {registered}, which does not exist"
);
// The second bug: `ApplicationName` sat one level above `Capabilities`,
// where the shell does not look, so the entry had no name to show.
assert_eq!(
root.value(CAPABILITIES_KEY, "ApplicationName").as_deref(),
Some(DISPLAY_NAME)
);
assert_eq!(
root
.value(CAPABILITIES_KEY, "ApplicationDescription")
.as_deref(),
Some(DESCRIPTION)
);
// Every scheme and file type the capability lists claim.
for scheme in URL_SCHEMES {
assert_eq!(
root
.value(&format!(r"{CAPABILITIES_KEY}\URLAssociations"), scheme)
.as_deref(),
Some(PROG_ID),
"{scheme} is not claimed"
);
}
for extension in FILE_EXTENSIONS {
assert_eq!(
root
.value(&format!(r"{CAPABILITIES_KEY}\FileAssociations"), extension)
.as_deref(),
Some(PROG_ID),
"{extension} is not claimed"
);
}
// The rest of the StartMenuInternet entry.
assert_eq!(root.value(CLIENT_KEY, "").as_deref(), Some(DISPLAY_NAME));
assert_eq!(
root.value(&format!(r"{CLIENT_KEY}\shell\open\command"), ""),
Some(quoted(EXE))
);
assert!(root
.key
.open_subkey(format!(r"{CLIENT_KEY}\InstallInfo"))
.is_ok());
// The ProgId command has to carry `%1`. Without it the shell starts the
// browser and never says which page to open.
let prog_id_command = root
.value(
&format!(r"Software\Classes\{PROG_ID}\shell\open\command"),
"",
)
.expect("ProgId command");
assert_eq!(prog_id_command, format!(r#"{} "%1""#, quoted(EXE)));
// The file types are offered, not seized. Taking the default value of
// `Software\Classes\.html` is what the old code did, and that value
// belongs to whatever the user chose.
for extension in FILE_EXTENSIONS {
assert_eq!(
root
.value(
&format!(r"Software\Classes\{extension}\OpenWithProgids"),
PROG_ID
)
.as_deref(),
Some(""),
"{extension} should offer the handler"
);
assert!(
root
.value(&format!(r"Software\Classes\{extension}"), "")
.is_none(),
"{extension} default value must be left alone"
);
}
}
#[test]
fn the_association_an_older_build_took_is_given_back() {
let root = ScratchRoot::new("legacy");
// Recreate what the old code left behind: its own application key, and
// the ProgId written straight into the association for one file type.
let (legacy, _) = root
.key
.create_subkey(format!(r"{LEGACY_APP_KEY}\Capabilities\URLAssociations"))
.expect("legacy key");
legacy.set_value("http", &PROG_ID).expect("legacy claim");
let (html, _) = root
.key
.create_subkey(r"Software\Classes\.html")
.expect("html class");
html.set_value("", &PROG_ID).expect("legacy association");
// A file type the user pointed somewhere else. This one is not ours and
// must survive untouched.
let (htm, _) = root
.key
.create_subkey(r"Software\Classes\.htm")
.expect("htm class");
htm.set_value("", &"ChromeHTML").expect("user association");
remove_legacy_registration(&root.key);
assert!(
root.key.open_subkey(LEGACY_APP_KEY).is_err(),
"the superseded application key should be gone"
);
assert!(
root.value(r"Software\Classes\.html", "").is_none(),
"the association we took should have been released"
);
assert_eq!(
root.value(r"Software\Classes\.htm", "").as_deref(),
Some("ChromeHTML"),
"a choice that is not ours must not be touched"
);
}
}
@@ -545,7 +907,105 @@ pub async fn is_default_browser() -> Result<bool, String> {
}
#[command]
pub async fn set_as_default_browser() -> Result<(), String> {
pub async fn set_as_default_browser() -> Result<SetDefaultOutcome, String> {
let default_browser = DefaultBrowser::instance();
default_browser.set_as_default_browser().await
}
#[cfg(test)]
mod tests {
/// The type system now prevents the mistake behind the crash on Windows.
/// `SendMessageTimeoutW` comes from the `windows` crate, and its
/// out-parameter is typed `Option<*mut usize>`, so a four byte slot no longer
/// compiles. That guarantee holds only while the call goes through the crate.
/// A hand-written declaration would bring back the whole class of bug in a
/// form no compiler and no lint can see, so refuse one here.
///
/// This looks at the Windows module on every platform, because the module is
/// compiled out everywhere else and would otherwise go unchecked on the
/// runners that do most of the work.
#[test]
fn the_windows_module_declares_no_foreign_functions_by_hand() {
const SOURCE: &str = include_str!("default_browser.rs");
let start = SOURCE
.find("mod windows {")
.expect("the Windows module was renamed; update this guard");
let end = SOURCE
.find("mod linux {")
.expect("the Linux module was renamed; update this guard");
assert!(
start < end,
"the module order changed; update this guard so it still reads the Windows module"
);
assert!(
!SOURCE[start..end].contains(r#"extern ""#),
"The Windows module declares a foreign function by hand. Do not. A \
hand-written declaration of SendMessageTimeoutA, with its out-parameter \
typed *mut u32 instead of the real PDWORD_PTR, is what made Windows \
write four bytes past a stack slot and kill the process every time a \
user set Donut as their default browser. Take the binding from the \
`windows` crate, which cannot drift from the real ABI, and add the \
feature it needs to Cargo.toml."
);
}
/// Show why the out-parameter has to be pointer sized.
///
/// This does not try to reproduce the crash. Whether the four byte overrun is
/// fatal depends on the frame the optimiser happens to build, so a crash test
/// passes under one profile and fails under another. It measures the thing
/// that is always true instead: the call writes eight bytes.
#[cfg(target_os = "windows")]
#[test]
fn send_message_timeout_writes_a_pointer_sized_result() {
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::UI::WindowsAndMessaging::{SendMessageTimeoutW, SMTO_ABORTIFHUNG, WM_NULL};
/// A four byte slot with a marker behind it, laid out the way the old code
/// laid out its `u32`. Eight bytes in total and eight byte aligned, so a
/// pointer sized write lands entirely inside the struct. Nothing outside it
/// is touched and the test is not itself undefined behaviour.
#[repr(C, align(8))]
struct Probe {
result: u32,
canary: u32,
}
const SENTINEL: u32 = 0xDEAD_BEEF;
let mut probe = Probe {
result: SENTINEL,
canary: SENTINEL,
};
// The window handle is deliberately not a window. USER32 clears the
// out-parameter before it looks at the target, so this measures the write
// width without creating a window, without a message loop and without
// sending anything to another process. The test is hermetic.
unsafe {
SendMessageTimeoutW(
HWND(0xDEAD_0000_usize as *mut core::ffi::c_void),
WM_NULL,
WPARAM(0),
LPARAM(0),
SMTO_ABORTIFHUNG,
50,
Some(&mut probe as *mut Probe as *mut usize),
);
}
assert_eq!(
probe.result, 0,
"SendMessageTimeoutW did not write the out-parameter at all, so this test \
no longer measures anything. Check the call before trusting it."
);
assert_ne!(
probe.canary, SENTINEL,
"SendMessageTimeoutW wrote only four bytes. If Windows has really narrowed \
lpdwResult to a DWORD then notify_system_of_changes may use a u32. Until \
then the out-parameter stays pointer sized."
);
}
}
+184 -54
View File
@@ -375,14 +375,25 @@ impl DownloadedBrowsersRegistry {
}
/// Get all browsers and versions referenced by active profiles
/// Every (browser, version) something still needs.
///
/// A TRASHED profile counts. Its browser directory is exactly what a restore
/// puts back into use, and removing the binary underneath it would turn an
/// undo into a gigabyte download, quietly, days after the delete.
pub fn get_active_browser_versions(
&self,
profiles: &[crate::profile::BrowserProfile],
) -> Vec<(String, String)> {
profiles
let mut versions: Vec<(String, String)> = profiles
.iter()
.map(|profile| (profile.browser.clone(), profile.version.clone()))
.collect()
.collect();
versions.extend(
crate::profile::trash::list_entries(&crate::profile::trash::trash_dir())
.into_iter()
.map(|(profile, _)| (profile.browser, profile.version)),
);
versions
}
/// Verify that all registered browsers actually exist on disk and clean up stale entries
@@ -693,6 +704,73 @@ impl DownloadedBrowsersRegistry {
Ok(cleaned_up)
}
/// Update every stale profile of one browser to `latest_version`, then drop
/// the version binaries that leaves unused.
///
/// The update and cleanup passes deliberately sit outside the classification
/// loop. Running them inside it replayed every already-processed profile on
/// each iteration, so N profiles cost N(N+1)/2 metadata rewrites and just as
/// many `profile-updated` events. Taking both actions as callbacks also keeps
/// the pass exercisable without a `tauri::AppHandle`.
fn consolidate_profiles_for_browser(
browser_name: &str,
browser_profiles: &[&BrowserProfile],
latest_version: &str,
update_profile: &mut dyn FnMut(&BrowserProfile) -> Result<(), String>,
remove_version: &mut dyn FnMut(&str) -> Result<(), String>,
) -> Vec<String> {
let mut consolidated = Vec::new();
let mut profiles_to_update = Vec::new();
let mut older_versions_to_remove = std::collections::HashSet::<String>::new();
for profile in browser_profiles {
if profile.version != latest_version {
// Only update if profile is not currently running
if profile.process_id.is_none() {
profiles_to_update.push(*profile);
older_versions_to_remove.insert(profile.version.clone());
} else {
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name,
profile.version
);
}
}
}
// Update profiles to latest version
for profile in &profiles_to_update {
match update_profile(profile) {
Ok(()) => {
consolidated.push(format!(
"Updated profile '{}' from {} to {}",
profile.name, profile.version, latest_version
));
}
Err(e) => {
log::error!("Failed to update profile '{}': {}", profile.name, e);
}
}
}
// Remove older version binaries that are no longer needed
for old_version in &older_versions_to_remove {
log::info!("Consolidating: removing old version {browser_name} {old_version}");
match remove_version(old_version.as_str()) {
Ok(()) => {
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
log::info!("Successfully removed old version: {browser_name} {old_version}");
}
Err(e) => {
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
}
}
}
consolidated
}
/// Consolidate browser versions - keep only the latest version per browser
pub fn consolidate_browser_versions(
&self,
@@ -755,58 +833,24 @@ impl DownloadedBrowsersRegistry {
let latest_version = &available_versions[0];
log::info!("Latest available version for {browser_name}: {latest_version}");
// Check which profiles need to be updated to the latest version
let mut profiles_to_update = Vec::new();
let mut older_versions_to_remove = std::collections::HashSet::<String>::new();
for profile in browser_profiles {
if profile.version != *latest_version {
// Only update if profile is not currently running
if profile.process_id.is_none() {
profiles_to_update.push(profile);
older_versions_to_remove.insert(profile.version.clone());
} else {
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name,
profile.version
);
}
}
// Update profiles to latest version
for profile in &profiles_to_update {
match self.profile_manager.update_profile_version(
app_handle,
&profile.id.to_string(),
latest_version,
) {
Ok(_) => {
consolidated.push(format!(
"Updated profile '{}' from {} to {}",
profile.name, profile.version, latest_version
));
}
Err(e) => {
log::error!("Failed to update profile '{}': {}", profile.name, e);
}
}
}
// Remove older version binaries that are no longer needed
for old_version in &older_versions_to_remove {
log::info!("Consolidating: removing old version {browser_name} {old_version}");
match self.cleanup_failed_download(browser_name, old_version) {
Ok(_) => {
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
log::info!("Successfully removed old version: {browser_name} {old_version}");
}
Err(e) => {
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
}
}
}
}
let mut consolidated_for_browser = Self::consolidate_profiles_for_browser(
browser_name,
browser_profiles,
latest_version,
&mut |profile: &BrowserProfile| -> Result<(), String> {
self
.profile_manager
.update_profile_version(app_handle, &profile.id.to_string(), latest_version)
.map(|_| ())
.map_err(|e| e.to_string())
},
&mut |old_version: &str| -> Result<(), String> {
self
.cleanup_failed_download(browser_name, old_version)
.map_err(|e| e.to_string())
},
);
consolidated.append(&mut consolidated_for_browser);
}
// Save registry after consolidation
@@ -1061,6 +1105,47 @@ lazy_static::lazy_static! {
mod tests {
use super::*;
#[test]
fn a_trashed_profile_still_counts_as_a_reason_to_keep_its_browser() {
let root = tempfile::tempdir().unwrap();
let _guard = crate::app_dirs::set_test_data_dir(root.path().to_path_buf());
let registry = DownloadedBrowsersRegistry::new();
assert!(registry.get_active_browser_versions(&[]).is_empty());
// What `trash_profile` leaves behind: one directory per profile holding
// the profile it archived.
let profile = crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".to_string(),
version: "152.0.7977.64".to_string(),
..Default::default()
};
let entry = crate::profile::trash::trash_dir().join(profile.id.to_string());
std::fs::create_dir_all(&entry).unwrap();
std::fs::write(
entry.join("profile.json"),
serde_json::to_vec(&profile).unwrap(),
)
.unwrap();
std::fs::write(
entry.join("manifest.json"),
serde_json::json!({
"deleted_at": 1,
"expires_at": 2,
"size_bytes": 0,
"original_name": "Trashed",
})
.to_string(),
)
.unwrap();
assert_eq!(
registry.get_active_browser_versions(&[]),
vec![("wayfern".to_string(), "152.0.7977.64".to_string())],
"removing the binary under a trashed profile turns an undo into a download"
);
}
#[test]
fn test_registry_creation() {
// Create a mock profile manager for testing
@@ -1395,6 +1480,51 @@ mod tests {
"Browser should not be considered downloaded when files don't exist on disk"
);
}
#[test]
fn test_consolidate_profiles_for_browser_acts_once_per_profile() {
let profile = |name: &str, version: &str, process_id: Option<u32>| BrowserProfile {
id: uuid::Uuid::new_v4(),
name: name.to_string(),
browser: "testbrowser".to_string(),
version: version.to_string(),
process_id,
..Default::default()
};
let stale_a = profile("stale-a", "139.0", None);
let stale_b = profile("stale-b", "139.0", None);
let older = profile("older", "138.0", None);
let running = profile("running", "139.0", Some(4242));
let current = profile("current", "140.0", None);
let profiles = [&stale_a, &stale_b, &older, &running, &current];
let mut updated: Vec<String> = Vec::new();
let mut removed: Vec<String> = Vec::new();
let consolidated = DownloadedBrowsersRegistry::consolidate_profiles_for_browser(
"testbrowser",
&profiles,
"140.0",
&mut |p: &BrowserProfile| -> Result<(), String> {
updated.push(p.name.clone());
Ok(())
},
&mut |version: &str| -> Result<(), String> {
removed.push(version.to_string());
Ok(())
},
);
// Every stale, stopped profile is updated exactly once - the loop used to
// re-update each of them once per remaining profile.
assert_eq!(updated, vec!["stale-a", "stale-b", "older"]);
removed.sort();
assert_eq!(removed, vec!["138.0", "139.0"]);
assert_eq!(consolidated.len(), updated.len() + removed.len());
}
}
#[tauri::command]
+409 -10
View File
@@ -9,12 +9,17 @@ use crate::api_client::ApiClient;
use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
use crate::log_redaction::Plain;
// 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);
// Sent on both the asset request and its checksum sidecar so the CDN sees one
// consistent client for the pair.
const DOWNLOAD_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
// 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>>> =
@@ -233,6 +238,113 @@ impl Downloader {
.await?;
log::info!("Download URL resolved");
// Every browser asset is published with a `<asset>.sha256` sidecar. Fetch
// it before the transfer starts: an asset nobody can verify costs one small
// request to reject here, or a wasted gigabyte to reject later.
let expected_sha256 = self
.fetch_expected_archive_checksum(&download_url, browser_type.display_name(), version)
.await?;
let file_path = self
.stream_download(
browser_type.clone(),
version,
&download_url,
file_path,
cancel_token,
)
.await?;
// Hashing a multi-gigabyte archive takes seconds, so tell the UI what the
// pause is for instead of leaving the bar sitting at 100%.
let _ = events::emit(
"download-progress",
&DownloadProgress {
browser: browser_type.as_str().to_string(),
version: version.to_string(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 100.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "verifying".to_string(),
},
);
verify_archive_checksum(
&file_path,
&expected_sha256,
browser_type.display_name(),
version,
)
.await?;
Ok(file_path)
}
/// Fetch `<asset>.sha256` and return the digest it publishes for the asset.
/// Every failure mode maps to `BROWSER_CHECKSUM_UNAVAILABLE`; the specifics
/// go to the log.
async fn fetch_expected_archive_checksum(
&self,
download_url: &str,
browser: &str,
version: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
serde_json::json!({
"code": "BROWSER_CHECKSUM_UNAVAILABLE",
"params": { "browser": browser, "version": version }
})
.to_string()
.into()
};
let sidecar_url = checksum_sidecar_url(download_url);
let response = match self
.client
.get(&sidecar_url)
.header("User-Agent", DOWNLOAD_USER_AGENT)
.send()
.await
{
Ok(response) if response.status().is_success() => response,
Ok(response) => {
log::warn!(
"Checksum sidecar request failed for {browser} {version}: HTTP {}",
response.status()
);
return Err(unavailable());
}
Err(e) => {
log::warn!("Checksum sidecar request failed for {browser} {version}: {e}");
return Err(unavailable());
}
};
let sidecar_text = match response.text().await {
Ok(text) => text,
Err(e) => {
log::warn!("Failed to read the checksum sidecar for {browser} {version}: {e}");
return Err(unavailable());
}
};
let asset_name = asset_filename_from_url(download_url);
let Some(expected) = crate::checksum::parse_sidecar_digest(&sidecar_text, asset_name) else {
log::warn!("No usable digest for {asset_name} in {sidecar_url}");
return Err(unavailable());
};
Ok(expected)
}
async fn stream_download(
&self,
browser_type: BrowserType,
version: &str,
download_url: &str,
file_path: PathBuf,
cancel_token: Option<&CancellationToken>,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// In-session resume: a large (~1GB) download over a flaky connection can
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
// terminal failure (which forces the user to re-click and risks the CDN
@@ -257,11 +369,8 @@ impl Downloader {
for attempt in 0..=max_send_retries {
let mut request = self
.client
.get(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
);
.get(download_url)
.header("User-Agent", DOWNLOAD_USER_AGENT);
if existing_size > 0 {
request = request.header("Range", format!("bytes={existing_size}-"));
@@ -597,7 +706,11 @@ impl Downloader {
return Ok(version);
} else {
// Registry says it's downloaded but files don't exist - clean up registry
log::info!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry.");
log::info!(
"Registry indicates {} {} is downloaded, but files are missing. Cleaning up registry entry.",
Plain(&browser_str),
Plain(&version)
);
self.registry.remove_browser(&browser_str, &version);
self
.registry
@@ -682,7 +795,7 @@ impl Downloader {
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to download browser: {e}").into());
return Err(contextualize("Failed to download browser", e));
}
};
@@ -703,7 +816,11 @@ impl Downloader {
// Do not remove the archive here. We keep it until verification succeeds.
}
Err(e) => {
log::error!("Extraction failed for {browser_str} {version}: {e}");
log::error!(
"Extraction failed for {} {}: {e}",
Plain(&browser_str),
Plain(&version)
);
// Delete the corrupt/invalid archive so a fresh download happens next time
if download_path.exists() {
@@ -749,7 +866,11 @@ impl Downloader {
let _ = events::emit("download-progress", &progress);
// Verify the browser was downloaded correctly
log::info!("Verifying download for browser: {browser_str}, version: {version}");
log::info!(
"Verifying download for browser: {}, version: {}",
Plain(&browser_str),
Plain(&version)
);
// Use the browser's own verification method
if !browser.is_version_downloaded(&version, &binaries_dir) {
@@ -804,7 +925,11 @@ impl Downloader {
.registry
.mark_download_completed(&browser_str, &version, browser_dir.clone())
{
log::warn!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
log::warn!(
"Warning: Could not mark {} {} as completed in registry: {e}",
Plain(&browser_str),
Plain(&version)
);
}
self
.registry
@@ -837,8 +962,37 @@ impl Downloader {
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
{
let app_handle_for_update = app_handle.clone();
let browser_for_update = browser_str.clone();
let version_for_update = version.clone();
tauri::async_runtime::spawn(async move {
let auto_updater = crate::auto_updater::AutoUpdater::instance();
// A profile that is open right now cannot be switched to the new binary
// yet, so it only gets a pending update. That entry has to exist before
// cleanup runs: cleanup keeps a version only while it is in use or
// pending, and would otherwise delete what was just downloaded.
match auto_updater
.auto_update_profile_versions(
&app_handle_for_update,
&browser_for_update,
&version_for_update,
)
.await
{
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Applied {browser_for_update} {version_for_update} to profiles: {updated:?}"
);
}
}
Err(e) => {
log::error!(
"Failed to apply {browser_for_update} {version_for_update} to profiles: {e}"
);
}
}
match auto_updater.update_profiles_to_latest_installed(&app_handle_for_update) {
Ok(updated) => {
if !updated.is_empty() {
@@ -872,6 +1026,74 @@ impl Downloader {
}
}
/// Offset of a URL's query or fragment, or its length when it has neither.
fn url_path_end(url: &str) -> usize {
url.find(['?', '#']).unwrap_or(url.len())
}
/// The `<asset>.sha256` published next to every browser asset. Any query or
/// fragment stays at the end so a signed URL keeps working.
fn checksum_sidecar_url(download_url: &str) -> String {
let (base, suffix) = download_url.split_at(url_path_end(download_url));
format!("{base}.sha256{suffix}")
}
/// Last path segment of `url`. This is the name a `sha256sum` sidecar records,
/// and it is not the local filename: the local one is built from the running
/// platform, while this one is whatever the publisher called the asset.
fn asset_filename_from_url(url: &str) -> &str {
url[..url_path_end(url)].rsplit('/').next().unwrap_or("")
}
/// Compare the finished archive against the digest published beside it. A
/// mismatch means the bytes on disk are not the asset the manifest promised,
/// so the file is deleted instead of being handed to the extractor.
async fn verify_archive_checksum(
file_path: &Path,
expected: &str,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let hash_path = file_path.to_path_buf();
let actual = tokio::task::spawn_blocking(move || crate::checksum::sha256_file(&hash_path))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Checksum task failed: {e}").into()
})??;
if actual.eq_ignore_ascii_case(expected) {
log::info!("Checksum verified for {browser} {version}: {actual}");
return Ok(());
}
log::error!("Checksum mismatch for {browser} {version}: expected {expected}, got {actual}");
let _ = std::fs::remove_file(file_path);
Err(
serde_json::json!({
"code": "BROWSER_CHECKSUM_MISMATCH",
"params": { "browser": browser, "version": version }
})
.to_string()
.into(),
)
}
/// Prefix the calling context onto a bare message, but leave an already-coded
/// backend error alone: `wrap_backend_error` only passes a payload through
/// when it still starts with `{`, so prefixing one would strip the code and
/// the frontend would fall back to the untranslated INTERNAL_ERROR text.
fn contextualize(
context: &str,
e: impl std::fmt::Display,
) -> Box<dyn std::error::Error + Send + Sync> {
let msg = e.to_string();
if msg.starts_with('{') {
msg.into()
} else {
format!("{context}: {msg}").into()
}
}
/// Check if a specific browser-version pair is currently being downloaded
pub fn is_downloading(browser: &str, version: &str) -> bool {
let download_key = format!("{browser}-{version}");
@@ -1040,6 +1262,183 @@ mod tests {
assert_eq!(downloaded_content.len(), test_content.len());
}
// Stand-in archive body for the checksum tests. Digests are computed from
// it rather than hardcoded, so the fixture and the assertion cannot drift.
const ARCHIVE_BODY: &[u8] = b"wayfern archive bytes";
fn digest_of(bytes: &[u8]) -> String {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("archive.zip");
std::fs::write(&path, bytes).unwrap();
crate::checksum::sha256_file(&path).unwrap()
}
#[test]
fn test_checksum_sidecar_url_appends_to_the_asset_path() {
assert_eq!(
checksum_sidecar_url("https://download.wayfern.com/wayfern-151_windows_x64.zip"),
"https://download.wayfern.com/wayfern-151_windows_x64.zip.sha256"
);
// A signed URL keeps its query, so the sidecar stays reachable.
assert_eq!(
checksum_sidecar_url("https://cdn.example.com/a.zip?token=abc&exp=1"),
"https://cdn.example.com/a.zip.sha256?token=abc&exp=1"
);
assert_eq!(
checksum_sidecar_url("https://cdn.example.com/a.zip#frag"),
"https://cdn.example.com/a.zip.sha256#frag"
);
}
#[test]
fn test_asset_filename_from_url_takes_the_publisher_name() {
// Deliberately different from the local filename, which is built from the
// running platform and would never match a sidecar entry.
assert_eq!(
asset_filename_from_url("https://download.wayfern.com/wayfern-151.0.7922.71_windows_x64.zip"),
"wayfern-151.0.7922.71_windows_x64.zip"
);
assert_eq!(
asset_filename_from_url("https://cdn.example.com/dir/a.tar.xz?token=abc"),
"a.tar.xz"
);
assert_eq!(asset_filename_from_url("https://cdn.example.com/"), "");
}
#[tokio::test]
async fn test_fetch_expected_archive_checksum_reads_the_sidecar() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let digest = digest_of(ARCHIVE_BODY);
Mock::given(method("GET"))
.and(path("/wayfern-151_windows_x64.zip.sha256"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!("{digest} wayfern-151_windows_x64.zip\n")),
)
.mount(&server)
.await;
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
let expected = downloader
.fetch_expected_archive_checksum(&url, "wayfern", "151")
.await
.expect("sidecar should resolve");
assert_eq!(expected, digest);
}
#[tokio::test]
async fn test_fetch_expected_archive_checksum_fails_when_the_sidecar_is_missing() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
Mock::given(method("GET"))
.and(path("/wayfern-151_windows_x64.zip.sha256"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
let error = downloader
.fetch_expected_archive_checksum(&url, "wayfern", "151")
.await
.expect_err("an unverifiable asset must not be downloaded")
.to_string();
assert!(
error.contains("BROWSER_CHECKSUM_UNAVAILABLE") && error.contains("151"),
"expected a coded, translatable error, got: {error}"
);
}
#[tokio::test]
async fn test_fetch_expected_archive_checksum_rejects_a_sidecar_without_a_digest() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
// A CDN that answers 200 with an error page must not be read as a digest.
Mock::given(method("GET"))
.and(path("/wayfern-151_windows_x64.zip.sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string("<!doctype html><title>404</title>"))
.mount(&server)
.await;
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
let error = downloader
.fetch_expected_archive_checksum(&url, "wayfern", "151")
.await
.expect_err("an unparsable sidecar must not pass")
.to_string();
assert!(
error.contains("BROWSER_CHECKSUM_UNAVAILABLE"),
"expected a coded error, got: {error}"
);
}
#[tokio::test]
async fn test_verify_archive_checksum_accepts_a_matching_digest() {
let temp_dir = TempDir::new().unwrap();
let archive = temp_dir.path().join("wayfern.zip");
std::fs::write(&archive, ARCHIVE_BODY).unwrap();
let digest = crate::checksum::sha256_file(&archive).unwrap();
// Case is normalized on both sides, so an uppercase sidecar still matches.
verify_archive_checksum(
&archive,
&digest.to_ascii_uppercase(),
"wayfern",
"151.0.7922.71",
)
.await
.expect("a matching digest should verify");
assert!(archive.exists(), "a verified archive must be kept");
}
#[tokio::test]
async fn test_verify_archive_checksum_rejects_and_deletes_a_mismatch() {
let temp_dir = TempDir::new().unwrap();
let archive = temp_dir.path().join("wayfern.zip");
// The file on disk is the wrong asset entirely, which is what a mislinked
// manifest slot delivers, while the sidecar describes the right one.
std::fs::write(&archive, b"a macOS disk image, not a windows zip").unwrap();
let expected = digest_of(ARCHIVE_BODY);
let error = verify_archive_checksum(&archive, &expected, "wayfern", "151.0.7922.71")
.await
.expect_err("a mismatched digest must fail")
.to_string();
assert!(
error.contains("BROWSER_CHECKSUM_MISMATCH") && error.contains("151.0.7922.71"),
"expected a coded, translatable error, got: {error}"
);
assert!(
!archive.exists(),
"an archive that failed verification must be deleted, not extracted"
);
}
#[test]
fn test_contextualize_preserves_a_coded_backend_error() {
// A coded payload must survive untouched: wrap_backend_error only passes
// it through while it still starts with '{'.
let coded = r#"{"code":"BROWSER_CHECKSUM_MISMATCH","params":{"browser":"wayfern"}}"#;
assert_eq!(
contextualize("Failed to download browser", coded).to_string(),
coded
);
// A bare message still gets its context.
assert_eq!(
contextualize("Failed to download browser", "connection reset").to_string(),
"Failed to download browser: connection reset"
);
}
#[test]
fn test_clear_download_state_for_browser_removes_stuck_keys() {
// Simulate a download future that was abandoned without running its own cleanup,

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