Compare commits

...

48 Commits

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 09:19:51 +00:00
zhom 53165e3cf0 chore: cleanup issue validation 2026-05-06 13:18:23 +04:00
github-actions[bot] 29e73bd2d8 chore: update flake.nix for v0.22.7 [skip ci] (#341)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-05 22:13:54 +00:00
github-actions[bot] 6441843d85 docs: update CHANGELOG.md and README.md for v0.22.7 [skip ci] (#340)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-05 22:13:36 +00:00
zhom 5356d59d72 chore: version bump 2026-05-05 22:34:56 +04:00
zhom 34450ad06b refactor: cleanup 2026-05-05 22:34:56 +04:00
zhom 904dda2bad chore: copy 2026-05-05 22:34:56 +04:00
github-actions[bot] 39b13ead5b chore: update flake.nix for v0.22.6 [skip ci] (#337)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-03 23:27:39 +00:00
github-actions[bot] 62c84b52fc docs: update CHANGELOG.md and README.md for v0.22.6 [skip ci] (#336)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-03 23:27:18 +00:00
134 changed files with 42653 additions and 7400 deletions
+8
View File
@@ -1,4 +1,12 @@
# macOS code signing + notarization for `pnpm tauri build`.
# Loaded into the build environment via scripts/run-with-env.mjs (and direnv via .envrc).
# APPLE_SIGNING_IDENTITY: the exact name of your Developer ID Application
# certificate as it appears in `security find-identity -v -p codesigning`.
# Example: "Developer ID Application: Your Name (TEAMID)"
# APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID: credentials for notarytool.
# APPLE_PASSWORD must be an app-specific password from appleid.apple.com,
# not your real Apple ID password.
APPLE_TEAM_ID=
APPLE_ID=
APPLE_PASSWORD=
+4
View File
@@ -1 +1,5 @@
use flake
# Load .env on top of the flake's environment so APPLE_SIGNING_IDENTITY,
# APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. are available to `tauri build`
# and any other tools spawned from this directory.
dotenv_if_exists .env
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+357 -69
View File
@@ -16,6 +16,11 @@ permissions:
pull-requests: write
id-token: write
env:
# Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: anthropic/claude-opus-4.7
COMPOSER_MODEL: anthropic/claude-opus-4.7
jobs:
analyze-issue:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
@@ -40,42 +45,211 @@ jobs:
echo "is_first_time=false" >> $GITHUB_OUTPUT
fi
- name: Build repo context and find related files
- name: Parse issue template fields
env:
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
node <<'EOF'
const fs = require('node:fs');
const body = process.env.ISSUE_BODY || '';
// GitHub issue templates render fields as `### Heading\nValue` blocks.
// Split on `###` at line start to recover them.
const fields = {};
const sections = body.split(/^###\s+/m);
for (const section of sections.slice(1)) {
const nl = section.indexOf('\n');
if (nl < 0) continue;
const heading = section.slice(0, nl).trim();
const value = section.slice(nl + 1).trim();
fields[heading] = value === '_No response_' ? '' : value;
}
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
// Convenience extractions for the prompt — empty string if missing.
const get = (k) => fields[k] || '';
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?'));
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
EOF
echo "Parsed fields:"
cat /tmp/issue-fields.json
- name: Build repo context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
# List all source files for the AI to pick from
# List all source files for the AI to choose from
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
- name: Select relevant files with AI
- name: Write shared knowledge files (scope + pricing)
run: |
cat > /tmp/scope-and-pricing.md <<'EOF'
# PROJECT SCOPE
- **Donut Browser** — this repo. A Tauri desktop launcher (Rust + Next.js) that
downloads, manages, and launches anti-detect browser profiles. In-scope for bug
reports about profile management, downloads, sync, proxy, VPN, the launcher UI,
its API, MCP server, and the bundled `donut-sync` self-hosted server.
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
bugs are in-scope here unless they are obviously upstream Chromium issues.
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
contribute to Camoufox and CANNOT fix bugs in it.
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
https://github.com/daijro/camoufox/issues.
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
in-scope here.
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
supported. Feature requests asking for them are out of scope.
# PAID vs FREE FEATURES
Source: donutbrowser.com pricing tiers (verbatim from translations).
## Free (no account required)
- Unlimited local profiles
- Chromium (Wayfern) and Firefox (Camoufox) browser engines
- Proxy support (HTTP/SOCKS5)
- VPN support (WireGuard)
- Profile Management API & MCP (list / create / launch / kill / config)
- Cookie & Extension Management
- Set as default browser
- **Profile sync IS FREE if the user self-hosts the `donut-sync` server**
## Pro ($16/mo) — adds:
- Browser Manipulation API & MCP (`type_text`, `click_element`,
`evaluate_javascript`, `screenshot`, `navigate`, etc.)
- Cross-OS fingerprinting (e.g. macOS user appearing as Windows)
- Profile Synchronizer for Wayfern
- 20 cloud profile backup (cloud sync via donutbrowser.com)
- Commercial use license
## Team ($80/mo) — adds:
- 100 cloud profile sync
- Team collaboration, profile sharing, unlimited seats
# ANTI-PATTERNS
- **Regression**: user explicitly mentions a previous version that worked
differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
which exact version was the last working one and what changed.
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
behavior. Redirect, do not collect logs.
- **Fork-support request**: asks the maintainer to support an alternative
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
"clear", "reasonable", "well-thought-out", etc.
- **AI-generated / template-violating report**: report doesn't follow the
template, may cite "official documentation" via context7, deepwiki, or any
non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative
sources are this GitHub repo and donutbrowser.com.
- **Speculation about internals**: never write a "Possible cause" / "Likely
cause" / "Root cause" section. Never cite internal file paths or line
numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
# Easiest path for the user: Donut → Settings → Advanced → Copy logs
# (puts the latest rotated log on the clipboard). If they prefer to
# attach files directly, the active log is `DonutBrowser.log`; older
# rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-…`).
- macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else)
- **`CDP not ready after N attempts on port X: HTTP 5xx ...`** —
an HTTP 5xx (503 / 502) response from a freshly-launched
browser's `/json/version` endpoint always means *something on
the loopback path is intercepting the connection*: a firewall,
an antivirus web-shield (Kaspersky, Bitdefender, ESET, Avast /
AVG, Yandex Protect on Windows; Little Snitch, LuLu on macOS),
a VPN client that hijacks 127.0.0.1, or a corporate MDM /
proxy (Zscaler, Cisco AnyConnect, Netskope). Chrome's
DevTools endpoint never returns 5xx itself — only synthetic
responses from interception layers do. **Do NOT speculate
about Gatekeeper, first-launch verification, code signing, or
quarantine** — none of those cause a 5xx response, and
Gatekeeper never delays a launch long enough to surface as
"120 attempts". Lead with: which AV / web-shield / firewall /
VPN / MDM is installed, and ask the user to try with the AV's
web-shield component temporarily disabled (not the whole AV).
EOF
- name: Build triage system prompt
run: |
# The static system prompt has apostrophes ("doesn't", "official docs"
# etc.) that collide with shell single-quoting if embedded directly in
# the jq filter. Build the full prompt to a file instead, then load it
# via --rawfile in the next step.
{
cat <<'TRIAGE_HEAD'
You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.
TRIAGE_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'TRIAGE_TAIL'
# OUTPUT
Return ONLY valid JSON. No preamble, no code fences. Schema:
{
"language": "en" or ISO 639-1 code,
"classification": one of ["bug-in-scope", "bug-upstream-camoufox", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
"operating_system": "macos" | "windows" | "linux" | "unknown",
"is_paid_feature": true | false,
"user_followed_template": true | false,
"regression_signal": quoted user snippet or null,
"user_cited_external_docs": URL string or null,
"files_to_read": array of at most 20 file paths from the list,
"notes": one short sentence describing what you observed
}
Classification guidance:
- "bug-upstream-camoufox": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.
- "bug-template-violation": missing or filled-in nonsense for required template fields.
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
- "regression": user names a prior version that worked.
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
TRIAGE_TAIL
} > /tmp/triage-system.txt
wc -c /tmp/triage-system.txt
- name: Stage 1 — Triage and file selection
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
# The triage call returns ONLY JSON. It classifies the issue and picks a
# short list of source files for the composer to read.
PAYLOAD=$(jq -n \
--arg model "$TRIAGE_MODEL" \
--rawfile system_prompt /tmp/triage-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile files /tmp/all-source-files.txt \
'{
model: "anthropic/claude-opus-4.6",
model: $model,
messages: [
{
role: "system",
content: "You are a file selector for Donut Browser (Tauri + Next.js + Rust anti-detect browser). Given an issue and a list of source files, output ONLY the 10 most likely relevant file paths, one per line. No explanations, no numbering, just paths."
},
{
role: "user",
content: ("Issue: " + $title + "\n\n" + $body + "\n\nFiles:\n" + $files)
}
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
]
}')
@@ -84,65 +258,171 @@ jobs:
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/selected-files.txt
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
# Read the selected files in full (skip binary files)
echo "" > /tmp/file-contents.txt
while IFS= read -r filepath; do
# Strip ```json fences if the model couldn't help itself.
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
# Validate; if the model returned junk, fall back to a minimal stub so the
# composer still gets called and produces SOMETHING.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification"
cat /tmp/triage-raw.txt
jq -n '{
language: "en",
classification: "bug-in-scope",
operating_system: "unknown",
is_paid_feature: false,
user_followed_template: true,
regression_signal: null,
user_cited_external_docs: null,
files_to_read: [],
notes: "triage call failed; defaulting"
}' > /tmp/triage.json
fi
echo "Triage result:"
cat /tmp/triage.json
- name: Read files chosen by triage
run: |
: > /tmp/file-context.txt
# files_to_read may be empty (e.g. upstream Camoufox) — that's fine.
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
filepath=$(echo "$filepath" | xargs)
[ -z "$filepath" ] && continue
# Reject paths that escape the repo or look fishy
case "$filepath" in
/*|*..*|*$'\n'*) continue ;;
esac
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath ===" >> /tmp/file-contents.txt
cat "$filepath" >> /tmp/file-contents.txt
echo "" >> /tmp/file-contents.txt
echo "=== $filepath ===" >> /tmp/file-context.txt
cat "$filepath" >> /tmp/file-context.txt
echo "" >> /tmp/file-context.txt
fi
done < /tmp/selected-files.txt
done
# Cap total context at 100 KB to keep token cost bounded.
head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt
mv /tmp/file-context.capped.txt /tmp/file-context.txt
wc -c /tmp/file-context.txt
# Cap total context at 100KB
head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt
- name: Build composer system prompt
run: |
# Same reason as the triage prompt: lots of apostrophes, no shell-quoting
# gymnastics. Build it to a file, load via --rawfile.
{
cat <<'COMPOSER_HEAD'
You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.
- name: Analyze issue with AI
COMPOSER_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'COMPOSER_TAIL'
# RULES — STRICT
## Output shape
- One sentence acknowledging the report.
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.
- Maximum 15 lines.
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
- No closing pleasantries ("please let me know", "happy to help", etc.).
## Forbidden — never do these
- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
- NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
- NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
- NEVER list more than one OS log path. Use ONLY the path matching the user's reported OS. If OS is unknown, ask for it instead of listing all three.
- NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
- NEVER call a report "a known and expected behavior" or "a false positive" if the user mentions a regression. The triage tells you when this applies.
## Classification handling
The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.
## Paid-feature awareness
If `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.
## Language
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
Recommend Settings → Advanced → Copy logs first — it bundles the
latest rotated log onto the clipboard without the user hunting for
a directory. If they want to attach files directly, point at the
path that matches `triage.operating_system`. The active log is
always `DonutBrowser.log`; rotated copies sit next to it.
- macos: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
- unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions)
If the issue body contains any of these, lead with the matching
response — do NOT speculate about other causes:
- `CDP not ready after N attempts on port X: HTTP 5xx ...` —
this is loopback interception by a firewall / antivirus
web-shield / VPN / MDM. Lead with that question (specifically:
Kaspersky, Bitdefender, ESET, Avast/AVG, Yandex Protect on
Windows; Little Snitch, LuLu, corporate MDM on macOS; any
VPN). Suggest temporarily disabling the AV's web-shield
component (NOT the whole AV) and retrying. Do NOT mention
Gatekeeper, first-launch verification, code signing, or
quarantine — none of those cause an HTTP 5xx response, and
Gatekeeper never delays a launch long enough to produce a
"120 attempts" failure.
COMPOSER_TAIL
} > /tmp/composer-system.txt
wc -c /tmp/composer-system.txt
- name: Stage 2 — Compose response
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
run: |
GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING='This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"'
# Use printf with %s so the apostrophe inside the string never has to
# cross a shell single-quote boundary.
printf '%s' 'This is the first issue from this user — start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
else
: > /tmp/greeting.txt
fi
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile system_prompt /tmp/composer-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile author /tmp/issue-author.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile triage /tmp/triage.json \
--rawfile greeting /tmp/greeting.txt \
--rawfile repo_context /tmp/repo-context.txt \
--rawfile context /tmp/file-context.txt \
--rawfile files /tmp/file-context.txt \
'{
model: "anthropic/claude-opus-4.6",
model: $model,
messages: [
{
role: "system",
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <container>` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n - Only ask for information that is actually missing. If the issue is already detailed, just acknowledge it.\n3. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Do NOT include a \"Possible cause\" section. Do not speculate about what code might be causing the issue.\n- Be brief and focused on collecting actionable information from the reporter.\n- If the issue already has everything needed (steps to reproduce, logs, version, OS), just acknowledge it.\n- Never exceed 15 lines.")
},
{
role: "user",
content: (
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
"Analyze this issue:\n\nTitle: " + $title +
"\nAuthor: " + $author +
"\n\nBody:\n" + $body +
"\n\nRelevant source files:\n" + $context
)
}
{ role: "system", content: $system_prompt },
{ role: "user",
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
+ "Title: " + $title
+ "\nAuthor: " + $author
+ "\n\n## Triage result\n" + $triage
+ "\n\n## Parsed template fields\n" + $fields
+ "\n\n## Raw issue body\n" + $body
+ "\n\n## Source files (selected by triage)\n" + $files) }
]
}')
@@ -154,28 +434,41 @@ jobs:
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty"
echo "::error::Composer returned empty response"
echo "Raw response:"
echo "$RESPONSE"
exit 1
fi
- name: Post comment and label
- name: Strip forbidden sections (defense in depth)
run: |
# Even with explicit prompt rules, LLMs sometimes still emit "Possible cause"
# and friends. Strip any such heading + its block. Also drop any stray
# `Label:` lines from earlier prompt iterations.
python3 - <<'EOF'
import re
path = '/tmp/ai-comment.txt'
text = open(path).read()
# Drop forbidden section headers and everything until a blank line or another header.
forbidden = re.compile(
r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)',
re.IGNORECASE | re.MULTILINE | re.DOTALL,
)
text = forbidden.sub('', text)
# Drop stale Label: lines (we don't label anymore).
text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE)
# Collapse 3+ blank lines.
text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n'
open(path, 'w').write(text)
EOF
- name: Post comment (no labeling)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
LABEL=$(grep -oP '^Label:\s*\K.*' /tmp/ai-comment.txt | tail -1 | tr '[:upper:]' '[:lower:]' | xargs)
sed -i '/^Label:/d' /tmp/ai-comment.txt
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
if [ "$LABEL" = "bug" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "bug" 2>/dev/null || true
elif [ "$LABEL" = "enhancement" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "enhancement" 2>/dev/null || true
fi
analyze-pr:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
@@ -204,26 +497,20 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Get changed files list
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
> /tmp/pr-files.txt
# Get the actual diff
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
--header "Accept: application/vnd.github.diff" \
> /tmp/pr-diff-full.txt 2>/dev/null || true
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
# Get CONTRIBUTING.md and README.md for context
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt
# Read full contents of all changed files (skip binary)
echo "" > /tmp/related-file-contents.txt
: > /tmp/related-file-contents.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
@@ -258,6 +545,7 @@ jobs:
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile title /tmp/pr-title.txt \
--rawfile body /tmp/pr-body.txt \
--rawfile author /tmp/pr-author.txt \
@@ -270,7 +558,7 @@ jobs:
--rawfile contributing /tmp/contributing.txt \
--rawfile file_context /tmp/pr-file-context.txt \
'{
model: "anthropic/claude-opus-4.6",
model: $model,
messages: [
{
role: "system",
@@ -327,7 +615,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@557734bd130a68188454bc691e153f9f3731830e #v1.14.31
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+200
View File
@@ -0,0 +1,200 @@
name: Notify Telegram
# tauri-action creates the release with the default GITHUB_TOKEN, and GitHub
# Actions deliberately suppresses `release: published` events for releases
# made by GITHUB_TOKEN (to prevent recursive workflow chains). So we can't
# listen for `release: published` — it will never fire on stable releases.
#
# Instead, chain off the Release workflow via `workflow_run`, the same way
# `publish-repos.yml` does. `workflow_dispatch` is kept so a missed
# announcement can be replayed by hand.
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to announce (e.g. v0.23.0). Leave empty for latest stable."
required: false
type: string
workflow_run:
workflows: ["Release"]
types:
- completed
permissions:
contents: read
jobs:
notify:
if: >
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 0
- name: Resolve release tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
# `head_branch` of a workflow_run trigger is attacker-influenceable
# (anyone with push to a tag can choose its name), so we pass it via
# env and validate before use rather than splicing it into the
# shell script literally. See CodeQL actions/code-injection.
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
REPO: ${{ github.repository }}
run: |
if [[ -n "${INPUT_TAG:-}" ]]; then
TAG="${INPUT_TAG}"
elif [[ "${EVENT_NAME}" == "workflow_run" ]]; then
# The Release workflow runs on `push: tags: v*` so head_branch
# of the triggering run is the tag name. Reject anything that
# isn't a plain tag-shaped string to keep this resistant to
# shell metacharacters injected via a crafted ref name.
if [[ ! "${WORKFLOW_RUN_HEAD_BRANCH}" =~ ^[A-Za-z0-9._/-]+$ ]]; then
echo "::error::Refusing tag with unexpected characters: ${WORKFLOW_RUN_HEAD_BRANCH}"
exit 1
fi
TAG="${WORKFLOW_RUN_HEAD_BRANCH}"
else
TAG=$(gh release view --repo "${REPO}" --json tagName -q .tagName)
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved tag: ${TAG}"
- name: Skip pre-releases / missing releases
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
# Tag like `nightly-…` or `nightly` is never an announceable
# stable release. Short-circuit before hitting the API.
if [[ "${TAG}" == nightly* ]]; then
echo "Tag '${TAG}' is a rolling/nightly build, skipping Telegram post."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Only stable semver tags vX.Y.Z are eligible. Reject anything
# with a pre-release suffix (`-rc1`, `-beta`, etc.).
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag '${TAG}' is not a stable semver tag, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Confirm the release exists and isn't marked prerelease in the
# GitHub UI — guards against someone manually flipping the flag.
RELEASE_JSON=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isPrerelease,tagName 2>/dev/null || echo "")
if [[ -z "${RELEASE_JSON}" ]]; then
echo "Release ${TAG} not found via gh — skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
IS_PRE=$(jq -r .isPrerelease <<< "${RELEASE_JSON}")
if [[ "${IS_PRE}" == "true" ]]; then
echo "Release ${TAG} is marked prerelease, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Post release announcement to Telegram
if: steps.gate.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# Find the previous stable tag (skip the current one) so the
# changelog range is well-defined.
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
# Build a plain bullet list from feat / fix / refactor commits.
# Other commit types (chore, docs, ci, test, deps) are intentionally
# filtered out to keep the channel focused on user-visible changes.
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
;;
esac
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
if [ -z "$CHANGES" ]; then
CHANGES="• See release notes."$'\n'
fi
# HTML-escape the changelog before injecting into Telegram HTML
# mode — commit messages can legitimately contain `<`, `>`, `&`.
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
VERSION="${TAG}"
VERSION_NUM="${TAG#v}"
RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
DL="https://github.com/${REPO}/releases/download/${VERSION}"
# Build the API payload in one jq pass — keeps every literal
# newline, every angle bracket, and every quote correctly escaped
# for both shell and JSON.
PAYLOAD=$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg version "$VERSION" \
--arg changes "$ESCAPED_CHANGES" \
--arg dl "$DL" \
--arg vnum "$VERSION_NUM" \
--arg release_url "$RELEASE_URL" \
'{
chat_id: $chat_id,
parse_mode: "HTML",
disable_web_page_preview: true,
text: (
"<b>Donut Browser " + $version + " released</b>\n\n" +
$changes + "\n" +
"<b>Download</b>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_aarch64.dmg\">macOS (Apple Silicon)</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64.dmg\">macOS (Intel)</a>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64-setup.exe\">Windows x64</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_amd64.AppImage\">Linux x64</a>\n\n" +
"<a href=\"" + $release_url + "\">Full release notes</a>"
)
}')
# Use --fail-with-body so we surface Telegram's error JSON on 4xx/5xx
# instead of just a curl exit code.
RESPONSE=$(curl -sSL --fail-with-body \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage") \
|| { echo "::error::Telegram API call failed"; echo "$RESPONSE"; exit 1; }
if [ "$(jq -r .ok <<< "$RESPONSE")" != "true" ]; then
echo "::error::Telegram API rejected the message:"
jq . <<< "$RESPONSE"
exit 1
fi
echo "Posted to Telegram (message_id $(jq -r .result.message_id <<< "$RESPONSE"))"
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo
uses: crate-ci/typos@bbaefadf97b0ec5fdc942684b647f1a6ab250274 #v1.46.0
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+62
View File
@@ -69,6 +69,58 @@ donutbrowser/
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all seven locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Seven sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
## Backend error codes (mandatory)
User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BAR", "params": { … } }` strings — never raw English (`format!("Failed to …")`). The frontend resolves the code via `translateBackendError(t, err)` from `src/lib/backend-errors.ts`. Adding a new code requires four parallel edits:
1. Emit the JSON from Rust:
```rust
return Err(serde_json::json!({ "code": "FOO_BAR" }).to_string());
// or with params:
return Err(serde_json::json!({ "code": "FOO_BAR", "params": { "n": "5" } }).to_string());
```
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
4. Add `backendErrors.fooBar` to all seven locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
## Sub-page Dialog mode
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
```tsx
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
Account
</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4">…</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
```
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
## Singletons
@@ -93,6 +145,16 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## App data directory naming
`src-tauri/src/app_dirs.rs::app_name()` returns `"DonutBrowserDev"` when `cfg!(debug_assertions)` is true, `"DonutBrowser"` otherwise. So release builds (anything built via `tauri build` / `cargo build --release`) write to:
- macOS — `~/Library/Application Support/DonutBrowser/`
- Linux — `~/.local/share/DonutBrowser/`
- Windows — `%LOCALAPPDATA%\DonutBrowser\`
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
+112
View File
@@ -1,6 +1,118 @@
# Changelog
## v0.24.1 (2026-05-12)
### Refactoring
- creation button disaster recovery
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.0 [skip ci] (#357)
## v0.24.0 (2026-05-12)
### Features
- support latest camoufox
- full ui refresh
### Bug Fixes
- pass correct parameter for dns list selection
### Refactoring
- better error handling and prevention of creating ephemeral password protected profiles
- ui cleanup
- sync cleanup
- proxy spawn
### Maintenance
- chore: version bump
- chore: update dependencies
- chore: fix telegram notifications
- chore: fix issue validation
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
## v0.23.0 (2026-05-10)
### Features
- password protected profiles
- telegram notifications
### Refactoring
- reduce the number of s3 calls
### Documentation
- remove fossa badge
### Maintenance
- chore: version bump
- chore: logging
- chore: copy
- chore: optimize issue validation
- chore: linting
- ci(deps): bump the github-actions group with 3 updates (#348)
- chore: cleanup issue validation
- chore: update flake.nix for v0.22.7 [skip ci] (#341)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#349)
- deps(rust)(deps): bump tauri from 2.11.0 to 2.11.1 in /src-tauri (#346)
- deps(rust)(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
## v0.22.7 (2026-05-05)
### Refactoring
- cleanup
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.6 [skip ci] (#337)
## v0.22.6 (2026-05-03)
### Features
- vpn manipulation via the api
### Refactoring
- don't block ui on clade check
### Documentation
- update CHANGELOG.md and README.md for v0.22.5 [skip ci] (#327)
### Maintenance
- chore: version bump
- chore: rand bump
- chore: pnpm bump
- ci(deps): bump the github-actions group with 3 updates (#330)
- chore: update flake.nix for v0.22.5 [skip ci] (#328)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#331)
## v0.22.5 (2026-04-29)
### Bug Fixes
+5 -8
View File
@@ -16,9 +16,6 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a>
@@ -51,7 +48,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64.dmg) |
Or install via Homebrew:
@@ -61,15 +58,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut-0.22.5-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut-0.22.5-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+12 -12
View File
@@ -18,33 +18,33 @@
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1024.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0",
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.18",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.2",
"@types/node": "^25.7.0",
"@types/supertest": "^7.2.0",
"jest": "^30.3.0",
"jest": "^30.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.2"
"typescript": "^6.0.3"
},
"jest": {
"moduleFileExtensions": [
+1 -1
View File
@@ -117,7 +117,7 @@ export class SyncController {
@Get("subscribe")
@Sse()
subscribe(@Req() req: Request): Observable<MessageEvent> {
return this.syncService.subscribe(this.getUserContext(req), 2000).pipe(
return this.syncService.subscribe(this.getUserContext(req), 5000).pipe(
map((event) => ({
data: event,
})),
+239 -47
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
CreateBucketCommand,
DeleteObjectCommand,
@@ -41,6 +42,18 @@ import type {
SubscribeEventDto,
} from "./dto/sync.dto.js";
/**
* Marker object written under each scope (user / team / self-hosted root).
* Subscribers HEAD this object on each poll and only LIST when its ETag has
* changed, which keeps the steady-state polling cost down to one Class-B
* HeadObject per scope per poll instead of N Class-A ListObjectsV2 calls.
*
* Filename starts with a dot so it sorts first and is unmistakably internal
* to donut-sync; client `list()` calls strip it from results so it never
* leaks into application data.
*/
const MANIFEST_KEY = ".donut-sync-manifest";
@Injectable()
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
@@ -149,6 +162,71 @@ export class SyncService implements OnModuleInit {
return `${ctx.prefix}${key}`;
}
/**
* Return every scope prefix the given user can write to. For self-hosted
* that's the bucket root (`""`); for cloud that's the user prefix plus an
* optional team prefix.
*/
private scopesFor(ctx: UserContext): string[] {
if (ctx.mode === "self-hosted") return [""];
const out = [ctx.prefix];
if (ctx.teamPrefix) out.push(ctx.teamPrefix);
return out;
}
/**
* Bump the manifest object for the scope that owns `scopedKey`. Writers call
* this fire-and-forget after any successful mutation so subscribers'
* cheap HEAD polls observe an ETag change and pull a fresh listing.
*
* Slightly over-eager by design: we bump on presign-issue (rather than on
* the actual S3 PUT), so a never-completed upload causes one wasted refresh
* on other devices. That's strictly cheaper than verifying every upload.
*/
private async bumpManifest(
ctx: UserContext,
scopedKey: string,
): Promise<void> {
const scope = this.scopeForKey(ctx, scopedKey);
if (scope === null) return;
const key = `${scope}${MANIFEST_KEY}`;
// Body just needs to be unique so the ETag changes; clients never read it.
const body = JSON.stringify({
updatedAt: new Date().toISOString(),
nonce: randomUUID(),
});
try {
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: "application/json",
}),
);
} catch (err) {
// Manifest bump failures must NEVER fail the user's request.
// Subscribers fall back to detecting changes on their next listing.
this.logger.warn(
`Manifest bump failed for ${key}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Resolve which scope owns a fully-scoped key. Returns null if the key
* doesn't belong to a known scope (which shouldn't happen in practice
* because validateKeyAccess gates the write paths).
*/
private scopeForKey(ctx: UserContext, scopedKey: string): string | null {
if (ctx.mode === "self-hosted") return "";
if (ctx.teamPrefix && scopedKey.startsWith(ctx.teamPrefix)) {
return ctx.teamPrefix;
}
if (scopedKey.startsWith(ctx.prefix)) return ctx.prefix;
return null;
}
/**
* Validate that a key is accessible by the user.
* For cloud mode, key must start with user's prefix or team prefix.
@@ -220,6 +298,11 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
// Notify subscribers via the per-scope manifest. Fire-and-forget; a
// failure here just means other devices pick up the change on their
// next full listing instead of immediately.
void this.bumpManifest(ctx, key);
return {
url,
expiresAt: expiresAt.toISOString(),
@@ -294,6 +377,10 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
if (deleted || tombstoneCreated) {
void this.bumpManifest(ctx, key);
}
return { deleted, tombstoneCreated };
}
@@ -311,19 +398,22 @@ export class SyncService implements OnModuleInit {
const userPrefix = ctx?.prefix || "";
const teamPrefix = ctx?.teamPrefix || "";
const objects = (response.Contents || []).map((obj) => {
let key = obj.Key || "";
if (teamPrefix && key.startsWith(teamPrefix)) {
key = key.substring(teamPrefix.length);
} else if (userPrefix && key.startsWith(userPrefix)) {
key = key.substring(userPrefix.length);
}
return {
key,
lastModified: obj.LastModified?.toISOString() || "",
size: obj.Size || 0,
};
});
const objects = (response.Contents || [])
// Don't leak donut-sync's internal manifest object to clients.
.filter((obj) => !(obj.Key || "").endsWith(MANIFEST_KEY))
.map((obj) => {
let key = obj.Key || "";
if (teamPrefix && key.startsWith(teamPrefix)) {
key = key.substring(teamPrefix.length);
} else if (userPrefix && key.startsWith(userPrefix)) {
key = key.substring(userPrefix.length);
}
return {
key,
lastModified: obj.LastModified?.toISOString() || "",
size: obj.Size || 0,
};
});
return {
objects,
@@ -373,6 +463,20 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
// One bump per scope touched by this batch (usually one).
if (items.length > 0) {
const scopesSeen = new Set<string>();
for (const item of dto.items) {
const key = this.scopeKey(ctx, item.key);
const scope = this.scopeForKey(ctx, key);
if (scope !== null && !scopesSeen.has(scope)) {
scopesSeen.add(scope);
// Use any key from the scope; bumpManifest only inspects scope.
void this.bumpManifest(ctx, key);
}
}
}
return { items };
}
@@ -475,66 +579,154 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
if (deletedCount > 0 || tombstoneCreated) {
void this.bumpManifest(ctx, prefix);
}
return { deletedCount, tombstoneCreated };
}
/**
* Long-lived per-client poll loop.
*
* Steady-state cost is one HEAD per scope per poll (Class B on R2). A LIST
* (Class A) is only issued when:
* 1. it's the client's first poll (need to seed the state map), or
* 2. a write touched the scope and bumped its manifest ETag.
*
* This is *eventual* cross-device sync, gated by the poll interval.
* Real-time push is intentionally not provided here — that lives in the
* paid backend.
*/
subscribe(
ctx: UserContext,
pollIntervalMs = 2000,
pollIntervalMs = 5000,
): Observable<SubscribeEventDto> {
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
const scopes = this.scopesFor(ctx);
let prefixes: string[];
if (ctx.mode === "self-hosted") {
prefixes = basePrefixes;
} else {
prefixes = basePrefixes.map((p) => `${ctx.prefix}${p}`);
if (ctx.teamPrefix) {
prefixes.push(...basePrefixes.map((p) => `${ctx.teamPrefix}${p}`));
}
}
// Per-connection state (not shared across subscribers)
// Per-connection state (not shared across subscribers).
const lastManifestEtag = new Map<string, string | undefined>();
let lastKnownState = new Map<string, string>();
let initialized = false;
const pollChanges$ = interval(pollIntervalMs).pipe(
startWith(0),
switchMap(async () => {
const events: SubscribeEventDto[] = [];
const currentState = new Map<string, string>();
for (const prefix of prefixes) {
// Phase 1 — cheap HEAD on each scope's manifest. This is the
// steady-state cost (Class B). If no manifest changed since the
// last poll, we don't touch S3 again this tick.
let anyScopeChanged = false;
for (const scope of scopes) {
const manifestKey = `${scope}${MANIFEST_KEY}`;
let currentEtag: string | undefined;
try {
const result = await this.list({ prefix, maxKeys: 1000 });
for (const obj of result.objects) {
const stateKey = `${obj.key}:${obj.lastModified}`;
currentState.set(obj.key, stateKey);
const previousStateKey = lastKnownState.get(obj.key);
if (previousStateKey !== stateKey) {
events.push({
type: "change",
key: obj.key,
lastModified: obj.lastModified,
size: obj.size,
});
}
const head = await this.s3Client.send(
new HeadObjectCommand({
Bucket: this.bucket,
Key: manifestKey,
}),
);
currentEtag = head.ETag;
} catch (err: unknown) {
const status =
err && typeof err === "object" && "$metadata" in err
? (err as { $metadata?: { httpStatusCode?: number } }).$metadata
?.httpStatusCode
: undefined;
const name =
err && typeof err === "object" && "name" in err
? (err as { name?: string }).name
: undefined;
if (name === "NotFound" || name === "NoSuchKey" || status === 404) {
// No manifest yet — treat as "no changes" (undefined ETag).
currentEtag = undefined;
} else {
this.logger.error(
`Manifest HEAD failed for ${manifestKey}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
}
} catch (error) {
console.error(`Failed to list prefix ${prefix}:`, error);
}
const previousEtag = lastManifestEtag.get(scope);
if (previousEtag !== currentEtag) {
anyScopeChanged = true;
}
lastManifestEtag.set(scope, currentEtag);
}
// After the first poll, only run the LIST when something actually
// changed in at least one scope.
if (initialized && !anyScopeChanged) {
return [];
}
// Phase 2 — one LIST per scope (not per base prefix). Filter to the
// four base prefixes client-side. This is the cost we pay only when
// a manifest told us there's something new to look at.
const currentState = new Map<string, string>();
for (const scope of scopes) {
let continuationToken: string | undefined;
do {
try {
const result = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: scope,
MaxKeys: 1000,
ContinuationToken: continuationToken,
}),
);
for (const obj of result.Contents || []) {
const fullKey = obj.Key;
if (!fullKey) continue;
const relativeKey = fullKey.startsWith(scope)
? fullKey.substring(scope.length)
: fullKey;
// Skip the manifest object itself + anything outside the
// four data prefixes.
if (relativeKey === MANIFEST_KEY) continue;
if (!basePrefixes.some((bp) => relativeKey.startsWith(bp))) {
continue;
}
const lastModified = obj.LastModified?.toISOString() || "";
const stateKey = `${relativeKey}:${lastModified}`;
currentState.set(relativeKey, stateKey);
const previousStateKey = lastKnownState.get(relativeKey);
if (previousStateKey !== stateKey) {
events.push({
type: "change",
key: relativeKey,
lastModified,
size: obj.Size || 0,
});
}
}
continuationToken = result.NextContinuationToken;
} catch (err) {
this.logger.error(
`List failed for scope '${scope}': ${err instanceof Error ? err.message : String(err)}`,
);
continuationToken = undefined;
}
} while (continuationToken);
}
// Detect deletes by comparing key sets.
for (const [key] of lastKnownState) {
if (!currentState.has(key)) {
events.push({
type: "delete",
key,
});
events.push({ type: "delete", key });
}
}
lastKnownState = currentState;
initialized = true;
return events;
}),
switchMap((events) => of(...events)),
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.22.5";
releaseVersion = "0.24.1";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.AppImage";
hash = "sha256-709vcQ3SsFxsZEmDkuamlbHVsbFhGBAb3x59YvTehl4=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage";
hash = "sha256-nJ4WmbXQcnXWDaneucOlwzZmlOOBx+G/qDeCHH6/Vno=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.AppImage";
hash = "sha256-T7ZrRvo7gM5mnzmXfLQXVMekf28jVOgFlfAAi89huMY=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage";
hash = "sha256-aLzHAdn+o9YsnKtK5BpjjrzAAbp/itsN1QdELTpHyTQ=";
}
else
null;
+25 -22
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.22.6",
"version": "0.24.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -16,7 +16,7 @@
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "tauri",
"tauri": "node scripts/run-with-env.mjs tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
@@ -45,55 +45,58 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "~2.11.0",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "~2.5.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.9",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "~2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^26.0.3",
"lucide-react": "^1.7.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.3",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-icons": "^5.6.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "~2.11.0",
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1",
"@types/color": "^4.2.1",
"@types/node": "^25.5.2",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"tailwindcss": "^4.2.2",
"lint-staged": "^17.0.4",
"tailwindcss": "^4.3.0",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2"
"typescript": "~6.0.3"
},
"pnpm": {
"overrides": {
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12",
"fast-xml-parser@<5.7.0": ">=5.7.2"
"fast-xml-parser@<5.7.0": ">=5.7.2",
"fast-uri@<3.1.2": ">=3.1.2",
"fast-xml-builder@<1.2.0": ">=1.2.0"
}
},
"packageManager": "pnpm@10.33.2",
+2306 -3016
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Wrapper that loads `.env` into process.env (without overwriting anything
// already in the environment) and execs the given command. Used by the
// `tauri` npm script so `pnpm tauri build` picks up APPLE_SIGNING_IDENTITY,
// APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. without requiring direnv.
//
// Plain shell `source .env` works on macOS/Linux but not Windows; this
// wrapper is platform-agnostic.
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const envPath = resolve(projectRoot, ".env");
if (existsSync(envPath)) {
const content = readFileSync(envPath, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
// Don't overwrite values already exported by the parent shell — direnv
// / CI secrets / one-off `FOO=bar pnpm tauri ...` invocations win.
if (process.env[key] === undefined) {
process.env[key] = val;
}
}
}
const [, , cmd, ...args] = process.argv;
if (!cmd) {
console.error("usage: run-with-env.mjs <command> [args...]");
process.exit(2);
}
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
child.on("error", (err) => {
console.error(`Failed to spawn ${cmd}:`, err.message);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code ?? 1);
}
});
+12 -1
View File
@@ -171,10 +171,21 @@ async function startMinio(minioBin) {
async function buildDonutSync() {
log("Building donut-sync...");
// `nest build` runs incremental tsc, which silently skips emit when
// tsconfig.build.tsbuildinfo says nothing changed — even if dist/ was
// wiped. Drop the cache so we always produce a fresh dist.
const syncDir = path.join(ROOT_DIR, "donut-sync");
await rm(path.join(syncDir, "tsconfig.build.tsbuildinfo"), {
force: true,
});
await rm(path.join(syncDir, "dist"), { recursive: true, force: true });
execSync("pnpm build", {
cwd: path.join(ROOT_DIR, "donut-sync"),
cwd: syncDir,
stdio: process.env.VERBOSE ? "inherit" : "ignore",
});
if (!existsSync(path.join(syncDir, "dist", "main.js"))) {
throw new Error("donut-sync build did not produce dist/main.js");
}
log("donut-sync built");
}
+457 -267
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.22.6"
version = "0.24.2"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -44,6 +44,7 @@ tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
tauri-plugin-clipboard-manager = "2"
log = "0.4"
env_logger = "0.11"
@@ -51,7 +52,7 @@ directories = "6"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7"
sysinfo = "0.38"
sysinfo = "0.39"
lazy_static = "1.5"
base64 = "0.22"
libc = "0.2"
@@ -99,6 +100,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
rusqlite = { version = "0.39", features = ["bundled"] }
serde_yaml = "0.9"
toml = "0.9"
thiserror = "2.0"
regex-lite = "0.1"
tempfile = "3"
@@ -110,7 +112,7 @@ boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.23"
tray-icon = "0.24"
tao = "0.35"
image = "0.25"
dirs = "6"
+3 -1
View File
@@ -41,6 +41,8 @@
"macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission",
"log:default"
"log:default",
"clipboard-manager:default",
"clipboard-manager:allow-write-text"
]
}
+53 -3
View File
@@ -401,6 +401,10 @@ impl ApiServer {
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
// Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers.
.layer(middleware::from_fn(request_logging_middleware))
.layer(CorsLayer::permissive())
.with_state(state);
@@ -454,6 +458,8 @@ async fn auth_middleware(
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
let path = request.uri().path().to_string();
// Get the Authorization header
let auth_header = headers
.get("Authorization")
@@ -462,19 +468,31 @@ async fn auth_middleware(
let token = match auth_header {
Some(token) => token,
None => return Err(StatusCode::UNAUTHORIZED),
None => {
log::warn!("[api] Rejected {path}: missing Authorization header");
return Err(StatusCode::UNAUTHORIZED);
}
};
// Get the stored token
let settings_manager = crate::settings_manager::SettingsManager::instance();
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
Ok(None) => {
log::warn!(
"[api] Rejected {path}: API server has no stored token (was the API toggled off?)"
);
return Err(StatusCode::UNAUTHORIZED);
}
Err(e) => {
log::error!("[api] Failed to read stored API token: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
// Compare tokens
if token != stored_token {
log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED);
}
@@ -482,6 +500,38 @@ async fn auth_middleware(
Ok(next.run(request).await)
}
/// Logs every request: method, path, query, response status, duration.
/// Skips Authorization header and request bodies entirely.
async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let query = request.uri().query().map(|q| q.to_string());
let started = std::time::Instant::now();
let response = next.run(request).await;
let status = response.status();
let elapsed_ms = started.elapsed().as_millis();
let level = if status.is_server_error() {
log::Level::Error
} else if status.is_client_error() {
log::Level::Warn
} else {
log::Level::Info
};
match query {
Some(q) => log::log!(
level,
"[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)"
),
None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"),
}
response
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
+29 -12
View File
@@ -928,18 +928,35 @@ impl AppAutoUpdater {
// Move new app to current location
fs::rename(installer_path, &current_app_path)?;
// Remove quarantine attributes from the new app
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
let _ = Command::new("xattr")
.args(["-cr", current_app_path.to_str().unwrap()])
.output();
// Remove the macOS quarantine attribute from the freshly-installed app
// so Gatekeeper doesn't block its first launch — but only if it's
// actually present. macOS Sequoia's App Management TCC fires on the
// modify-class syscall regardless of whether anything is actually
// modified, so we gate the call behind a read-only `getxattr` check.
let needs_quarantine_removal = {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok();
let attr_c = CString::new("com.apple.quarantine").ok();
match (path_c, attr_c) {
(Some(p), Some(a)) => {
// SAFETY: getxattr with a null buffer is a read-only size query.
let result =
unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
result >= 0
}
_ => false,
}
};
if needs_quarantine_removal {
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
}
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
+11
View File
@@ -108,6 +108,17 @@ pub fn dns_blocklist_dir() -> PathBuf {
cache_dir().join("dns_blocklists")
}
/// Resolve the directory that tauri-plugin-log writes to. Mirrors the
/// `LogDir` target used in the plugin builder so the path matches what's
/// actually on disk for this OS.
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
use tauri::Manager;
handle
.path()
.app_log_dir()
.unwrap_or_else(|_| std::env::temp_dir())
}
#[cfg(test)]
thread_local! {
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
+2
View File
@@ -701,6 +701,8 @@ mod tests {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
}
}
+17 -10
View File
@@ -82,9 +82,14 @@ fn build_proxy_url(
#[tokio::main(flavor = "multi_thread")]
async fn main() {
// Initialize logger to write to stderr (which will be redirected to file)
// Initialize logger to write to stderr (which will be redirected to file).
//
// Default filter is Info — Debug pulls in reqwest/hyper internals which
// make the per-worker log unreadable on a busy browser session and obscure
// the actual lines we care about (binds, accept errors, upstream failures).
// RUST_LOG=debug or RUST_LOG=donut_proxy=trace still works for deep dives.
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.init();
@@ -343,8 +348,11 @@ async fn main() {
// Set high priority so this process is killed last under resource pressure
set_high_priority();
log::error!("Proxy worker starting, looking for config id: {}", id);
log::error!("Process PID: {}", std::process::id());
log::info!(
"Proxy worker starting (pid {}, config id {})",
std::process::id(),
id
);
// Retry config loading to handle file system race condition on Windows
// where the config file may not be immediately visible after being written
@@ -352,7 +360,7 @@ async fn main() {
let mut attempts = 0;
loop {
if let Some(config) = get_proxy_config(id) {
log::error!(
log::info!(
"Found config: id={}, port={:?}, upstream={}",
config.id,
config.local_port,
@@ -369,20 +377,19 @@ async fn main() {
);
process::exit(1);
}
log::error!("Config {} not found yet, retrying ({}/10)...", id, attempts);
log::debug!("Config {} not found yet, retrying ({}/10)...", id, attempts);
std::thread::sleep(std::time::Duration::from_millis(50));
}
};
// Run the proxy server - this should never return (infinite loop)
log::error!("Starting proxy server for config id: {}", id);
log::info!("Starting proxy server for config id: {}", id);
if let Err(e) = run_proxy_server(config).await {
log::error!("Failed to run proxy server: {}", e);
log::error!("Error details: {:?}", e);
log::error!("Proxy server failed: {} ({:?})", e, e);
process::exit(1);
}
// This should never be reached - run_proxy_server has an infinite loop
log::error!("ERROR: Proxy server returned unexpectedly (this should never happen)");
log::error!("Proxy server returned unexpectedly (this should never happen)");
process::exit(1);
} else {
log::error!("Invalid action for proxy-worker. Use 'start'");
+2
View File
@@ -1218,6 +1218,8 @@ mod tests {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+189 -29
View File
@@ -7,10 +7,78 @@ use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System;
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
/// low-traffic window for the average user; everyone shares the same UTC
/// instant so the value here doesn't track any one user's local schedule.
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
/// File name of the per-profile marker recording the last fingerprint
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
/// and is excluded from cloud sync (see `sync::manifest`) so each device
/// runs its own refresh schedule.
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
/// Most recent rollover instant on or before `now` — used as a staleness
/// threshold for Wayfern fingerprints. Anything generated before this
/// timestamp is considered stale and gets regenerated on next launch.
fn most_recent_rollover_epoch() -> u64 {
let now = Utc::now();
let today_threshold = Utc
.with_ymd_and_hms(
now.year(),
now.month(),
now.day(),
FINGERPRINT_ROLLOVER_HOUR_UTC,
0,
0,
)
.single()
.unwrap_or(now);
let threshold = if now >= today_threshold {
today_threshold
} else {
today_threshold - chrono::Duration::days(1)
};
threshold.timestamp().max(0) as u64
}
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
}
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
/// Returns `None` if the file doesn't exist or its content can't be parsed —
/// both signal "needs a refresh" to the caller.
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
let path = last_fp_refresh_path(profile_id, profiles_dir);
let content = std::fs::read_to_string(&path).ok()?;
content.trim().parse::<u64>().ok()
}
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
/// this profile. Failure is logged but never propagated — a missing marker
/// only costs an extra regen on the next launch, never blocks one.
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
let path = last_fp_refresh_path(profile_id, profiles_dir);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
return;
}
}
}
if let Err(e) = std::fs::write(&path, ts.to_string()) {
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
}
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -83,32 +151,73 @@ impl BrowserRunner {
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
async fn resolve_launch_hook_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
let Some(url) = profile.launch_hook.as_deref() else {
return Ok(None);
fn fire_launch_hook(profile: &BrowserProfile) {
let Some(raw_url) = profile.launch_hook.as_deref() else {
return;
};
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return;
}
let parsed = match url::Url::parse(trimmed) {
Ok(u) => u,
Err(e) => {
log::warn!(
"Skipping launch hook for profile {} (ID: {}): invalid URL: {e}",
profile.name,
profile.id
);
return;
}
};
log::info!(
"Calling launch hook for profile {} (ID: {})",
profile.name,
profile.id
);
if !matches!(parsed.scheme(), "http" | "https") {
log::warn!(
"Skipping launch hook for profile {} (ID: {}): URL must be http or https",
profile.name,
profile.id
);
return;
}
PROXY_MANAGER
.fetch_proxy_from_url(url, Duration::from_millis(500))
.await
let url = parsed.to_string();
let profile_name = profile.name.clone();
let profile_id = profile.id.to_string();
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})");
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(e) => {
log::warn!("Launch hook client build failed for {url}: {e}");
return;
}
};
match client.get(&url).send().await {
Ok(resp) => {
log::info!(
"Launch hook {url} for profile {profile_name} returned status {}",
resp.status()
);
}
Err(e) => {
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}");
}
}
});
}
async fn resolve_launch_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? {
return Ok(Some(proxy_settings));
}
Self::fire_launch_hook(profile);
self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
@@ -291,8 +400,12 @@ impl BrowserRunner {
);
}
// Create ephemeral dir for ephemeral profiles
let override_profile_path = if profile.ephemeral {
// Create ephemeral dir for ephemeral or password-protected profiles
let override_profile_path = if profile.password_protected {
let dir = crate::profile::password::prepare_for_launch(profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir)
} else if profile.ephemeral {
let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir)
@@ -499,12 +612,32 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Check if we need to generate a new fingerprint on every launch
// Decide whether to (re)generate the Wayfern fingerprint for this
// launch. Two triggers:
//
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
// randomization the user opted into.
// 2. The fingerprint hasn't been refreshed since the most recent
// rollover instant. We check the per-profile marker file first
// (`.last-fp-refresh`); if it's absent we fall back to
// `profile.created_at` so brand-new profiles don't immediately
// regenerate the fingerprint they were just created with.
// Profiles with neither (truly legacy) are treated as ancient
// and refresh on next launch — once.
let mut updated_profile = profile.clone();
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
let stale_threshold = most_recent_rollover_epoch();
let profile_id_str = profile.id.to_string();
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
let effective_last_refresh =
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
if randomize_every_launch || is_stale_profile {
log::info!(
"Generating random fingerprint for Wayfern profile: {}",
profile.name
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
profile.name,
randomize_every_launch,
is_stale_profile
);
// Create a config copy without the existing fingerprint to force generation of a new one
@@ -526,10 +659,24 @@ impl BrowserRunner {
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
// Save the updated fingerprint to the profile so it persists
// Write the marker so the next launch within the same rollover
// window skips this branch. The marker is excluded from cloud
// sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each
// device's refresh schedule is independent.
let now_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(stale_threshold);
write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch);
// Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint);
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
// Preserve the user's randomize-on-launch preference rather than
// forcing it on. The rollover path must not silently flip this
// flag for users who only opted into the scheduled refresh.
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone();
}
@@ -542,8 +689,11 @@ impl BrowserRunner {
);
}
// Create ephemeral dir for ephemeral profiles
if profile.ephemeral {
// Create ephemeral dir for ephemeral or password-protected profiles
if profile.password_protected {
crate::profile::password::prepare_for_launch(profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
} else if profile.ephemeral {
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
}
@@ -1431,7 +1581,12 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
if profile.password_protected {
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -1771,7 +1926,12 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
if profile.password_protected {
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
+57 -11
View File
@@ -12,6 +12,7 @@ use crate::camoufox::env_vars;
use crate::camoufox::fingerprint::types::*;
use crate::camoufox::fonts;
use crate::camoufox::geolocation;
use crate::camoufox::presets;
use crate::camoufox::webgl;
/// Browserforge mapping from YAML.
@@ -307,10 +308,59 @@ impl CamoufoxConfigBuilder {
}
/// Build the complete Camoufox launch configuration.
///
/// Prefers a real-fingerprint preset (matched against the Camoufox build's
/// Firefox version via `presets::preset_line_for`) when no explicit
/// fingerprint was passed. Falls back to the Bayesian network-based
/// synthesizer when presets are unavailable, so callers without a known
/// Firefox version (or with no preset for the requested OS) still get a
/// valid config — matching pre-v150 behaviour byte-for-byte.
pub fn build(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
// Generate or use provided fingerprint
let fingerprint = if let Some(fp) = self.fingerprint {
fp
let mut rng = rand::rng();
let ff_version = self.ff_version;
// 1) The caller supplied a fingerprint outright — honour it and skip
// presets entirely. This is the path tests and advanced consumers
// use to inject deterministic fixtures.
// 2) Otherwise, try a bundled preset for the requested OS / FF line.
// 3) Fall back to the Bayesian generator. This is also the path that
// runs for users whose Camoufox binary has no readable `version.json`
// (`ff_version == None`), or whose OS has no presets bundled.
let (mut config, target_os) = if let Some(fp) = self.fingerprint {
let target_os = env_vars::determine_ua_os(&fp.navigator.user_agent);
// `from_browserforge` already runs `handle_screen_xy` internally.
let config = from_browserforge(&fp, ff_version);
(config, target_os)
} else if let Some(preset) =
presets::get_random_preset(self.operating_system.as_deref(), ff_version)
{
let mut config = presets::from_preset(&preset, ff_version);
let target_os = config
.get("navigator.userAgent")
.and_then(|v| v.as_str())
.map(env_vars::determine_ua_os)
.or_else(|| {
// Last-resort heuristic from the platform string — keeps target_os
// sensible even if a preset somehow omits the user agent.
config
.get("navigator.platform")
.and_then(|v| v.as_str())
.map(|p| match p {
"Win32" => "windows",
"MacIntel" => "macos",
_ => "linux",
})
})
.unwrap_or("macos");
// Presets don't carry multi-monitor offsets, so default screenX/Y to
// (0, 0) — matches what real single-display users send.
config
.entry("window.screenX".to_string())
.or_insert(serde_json::json!(0));
config
.entry("window.screenY".to_string())
.or_insert(serde_json::json!(0));
(config, target_os)
} else {
let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?;
let options = FingerprintOptions {
@@ -320,17 +370,13 @@ impl CamoufoxConfigBuilder {
screen: self.screen_constraints,
..Default::default()
};
generator.get_fingerprint(&options)?.fingerprint
let fingerprint = generator.get_fingerprint(&options)?.fingerprint;
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
let config = from_browserforge(&fingerprint, ff_version);
(config, target_os)
};
// Determine target OS from user agent
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
// Convert fingerprint to config
let mut config = from_browserforge(&fingerprint, self.ff_version);
// Add random window history length
let mut rng = rand::rng();
config.insert(
"window.history.length".to_string(),
serde_json::json!(rng.random_range(1..=5)),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -7,3 +7,21 @@ pub const FONTS_JSON: &str = include_str!("fonts.json");
pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml");
pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db");
pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml");
/// Real fingerprint presets bundled with the original Camoufox v135 line
/// (Firefox <= 148). Frozen upstream — kept around so users who haven't
/// upgraded their Camoufox binary keep getting matched fingerprints.
/// Mirrors `pythonlib/camoufox/fingerprint-presets.json` upstream.
pub const FINGERPRINT_PRESETS_V135_JSON: &str = include_str!("fingerprint-presets-v135.json");
/// Real fingerprint presets for every Camoufox release after the v135 line
/// (currently Firefox 149+ via the v150 build). This file is expected to
/// be refreshed regularly as upstream Camoufox tracks newer Firefox
/// releases — we keep the upstream filename here so each refresh is a
/// straight `cp` from `pythonlib/camoufox/fingerprint-presets-v150.json`.
pub const FINGERPRINT_PRESETS_NEWER_JSON: &str = include_str!("fingerprint-presets-v150.json");
/// Firefox major version at which the newer preset bundle takes over from
/// the frozen v135 bundle. Matches `PRESETS_V150_MIN_FF` in
/// `pythonlib/camoufox/fingerprints.py`.
pub const PRESETS_NEWER_MIN_FF: u32 = 149;
+1
View File
@@ -43,6 +43,7 @@ pub mod fingerprint;
pub mod fonts;
pub mod geolocation;
pub mod launcher;
pub mod presets;
pub mod webgl;
// Re-export main types for convenience
+405
View File
@@ -0,0 +1,405 @@
//! Real-fingerprint preset support for Camoufox.
//!
//! Mirrors the preset-selection logic from
//! `pythonlib/camoufox/fingerprints.py` (`_select_presets_file`,
//! `load_presets`, `get_random_preset`, `from_preset`).
//!
//! Camoufox ships two bundled preset files:
//! - `fingerprint-presets-v135.json` — real fingerprints harvested from
//! browsers running Firefox ≤148. The frozen "v135 line" — kept around
//! so users who haven't upgraded their Camoufox binary keep getting
//! consistent fingerprints.
//! - `fingerprint-presets-v150.json` — the *newer* bundle, refreshed by
//! upstream as Camoufox tracks newer Firefox versions. This is the
//! bundle every newer Camoufox release uses; we make no assumption that
//! Firefox 150 is the ceiling.
//!
//! At launch we know the bundled Firefox version (see
//! `config::get_firefox_version`) and pick `v135` or `newer` accordingly.
//! The split point lives in `data::PRESETS_NEWER_MIN_FF` (currently 149)
//! and is the only number we hard-code — anything ≥ that gets the newer
//! bundle, regardless of how far Firefox itself has moved on.
//!
//! Falling back to Bayesian-network synthesis (the previous default) is
//! still possible when no preset matches the requested OS.
use rand::prelude::IndexedRandom;
use regex_lite::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::camoufox::data;
#[derive(Debug, Clone, Deserialize)]
pub struct Navigator {
#[serde(rename = "userAgent")]
pub user_agent: Option<String>,
pub platform: Option<String>,
#[serde(rename = "hardwareConcurrency")]
pub hardware_concurrency: Option<u32>,
#[serde(rename = "maxTouchPoints")]
pub max_touch_points: Option<u32>,
pub oscpu: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Screen {
pub width: Option<u32>,
pub height: Option<u32>,
#[serde(rename = "colorDepth")]
pub color_depth: Option<u32>,
#[serde(rename = "availWidth")]
pub avail_width: Option<u32>,
#[serde(rename = "availHeight")]
pub avail_height: Option<u32>,
#[serde(rename = "devicePixelRatio")]
pub device_pixel_ratio: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebGl {
#[serde(rename = "unmaskedVendor")]
pub unmasked_vendor: Option<String>,
#[serde(rename = "unmaskedRenderer")]
pub unmasked_renderer: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Preset {
#[serde(default)]
pub navigator: Option<Navigator>,
#[serde(default)]
pub screen: Option<Screen>,
#[serde(default)]
pub webgl: Option<WebGl>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub fonts: Option<Vec<String>>,
#[serde(rename = "speechVoices", default)]
pub speech_voices: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PresetBundle {
/// Bundle schema version — upstream writes this as a JSON integer (e.g.
/// `1`), so we accept any JSON shape here and ignore it. Only the
/// `presets` map matters at runtime.
#[allow(dead_code)]
#[serde(default)]
pub version: Option<serde_json::Value>,
#[serde(default)]
pub presets: HashMap<String, Vec<Preset>>,
}
/// Which Camoufox release line the active binary belongs to. Determines
/// which preset bundle to load. The set is intentionally just two-valued:
/// the legacy v135 line and "everything newer" — upstream refreshes the
/// newer bundle as Firefox versions advance, but our routing logic stays
/// the same.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PresetLine {
V135,
Newer,
}
/// Pick the preset line that matches a Firefox major version, mirroring
/// `_select_presets_file` in the Python lib. Unknown / very old versions
/// fall back to the v135 bundle so the older Camoufox builds keep working.
pub fn preset_line_for(ff_version: Option<u32>) -> PresetLine {
match ff_version {
Some(v) if v >= data::PRESETS_NEWER_MIN_FF => PresetLine::Newer,
_ => PresetLine::V135,
}
}
/// Cache the parsed bundles forever — they're static, embedded data and
/// parsing the newer file twice would waste a few megs of CPU work on
/// every launch.
static V135_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
static NEWER_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
fn parse_bundle(json: &str) -> Option<PresetBundle> {
match serde_json::from_str::<PresetBundle>(json) {
Ok(b) => Some(b),
Err(e) => {
log::warn!("camoufox preset bundle failed to parse: {e}");
None
}
}
}
pub fn load_presets(line: PresetLine) -> Option<&'static PresetBundle> {
let slot = match line {
PresetLine::V135 => &V135_BUNDLE,
PresetLine::Newer => &NEWER_BUNDLE,
};
slot
.get_or_init(|| match line {
PresetLine::V135 => parse_bundle(data::FINGERPRINT_PRESETS_V135_JSON),
PresetLine::Newer => parse_bundle(data::FINGERPRINT_PRESETS_NEWER_JSON),
})
.as_ref()
}
/// Normalize the OS string the rest of the codebase uses ("macos", "windows",
/// "linux") to the preset key. Returns `None` for OSes that don't have any
/// presets bundled.
fn normalize_os(os: &str) -> Option<&'static str> {
match os {
"windows" | "win" => Some("windows"),
"macos" | "mac" | "darwin" => Some("macos"),
"linux" | "lin" => Some("linux"),
_ => None,
}
}
/// Pick a random preset for the requested OS. `None` if there are no
/// presets bundled for that OS (which can happen in tests with reduced
/// fixtures, or if a new OS is added before its preset bundle ships).
pub fn get_random_preset(os: Option<&str>, ff_version: Option<u32>) -> Option<Preset> {
let bundle = load_presets(preset_line_for(ff_version))?;
let candidates: Vec<&Preset> = match os.and_then(normalize_os) {
Some(os_key) => bundle.presets.get(os_key).map(|v| v.iter().collect())?,
None => bundle.presets.values().flatten().collect(),
};
if candidates.is_empty() {
return None;
}
candidates.choose(&mut rand::rng()).map(|p| (*p).clone())
}
/// Match python's `from_preset` — translate a real-fingerprint preset into
/// the CAMOU_CONFIG-style HashMap the rest of the launcher expects.
///
/// The caller is responsible for filling in fonts, voices, and the random
/// seeds; those are intentionally left out here so each call site can layer
/// its own RNG and font policy.
pub fn from_preset(preset: &Preset, ff_version: Option<u32>) -> HashMap<String, serde_json::Value> {
let mut config: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(nav) = &preset.navigator {
if let Some(ua) = &nav.user_agent {
let ua = if let Some(v) = ff_version {
rewrite_ua_firefox_version(ua, v)
} else {
ua.clone()
};
config.insert("navigator.userAgent".to_string(), serde_json::json!(ua));
}
if let Some(p) = &nav.platform {
config.insert("navigator.platform".to_string(), serde_json::json!(p));
}
if let Some(hc) = nav.hardware_concurrency {
config.insert(
"navigator.hardwareConcurrency".to_string(),
serde_json::json!(hc),
);
}
if let Some(mtp) = nav.max_touch_points {
config.insert(
"navigator.maxTouchPoints".to_string(),
serde_json::json!(mtp),
);
}
// navigator.oscpu — explicit, or derived from the platform.
let oscpu = nav.oscpu.clone().or_else(|| {
nav.platform.as_deref().and_then(|plat| match plat {
"MacIntel" => Some("Intel Mac OS X 10.15".to_string()),
"Win32" => Some("Windows NT 10.0; Win64; x64".to_string()),
p if p.to_ascii_lowercase().contains("linux") => Some("Linux x86_64".to_string()),
_ => None,
})
});
if let Some(o) = oscpu {
config.insert("navigator.oscpu".to_string(), serde_json::json!(o));
}
}
if let Some(s) = &preset.screen {
if let Some(w) = s.width {
config.insert("screen.width".to_string(), serde_json::json!(w));
}
if let Some(h) = s.height {
config.insert("screen.height".to_string(), serde_json::json!(h));
}
if let Some(cd) = s.color_depth {
config.insert("screen.colorDepth".to_string(), serde_json::json!(cd));
config.insert("screen.pixelDepth".to_string(), serde_json::json!(cd));
}
if let Some(aw) = s.avail_width {
config.insert("screen.availWidth".to_string(), serde_json::json!(aw));
}
if let Some(ah) = s.avail_height {
config.insert("screen.availHeight".to_string(), serde_json::json!(ah));
}
}
if let Some(w) = &preset.webgl {
if let Some(v) = &w.unmasked_vendor {
config.insert("webGl:vendor".to_string(), serde_json::json!(v));
}
if let Some(r) = &w.unmasked_renderer {
config.insert("webGl:renderer".to_string(), serde_json::json!(r));
}
}
if let Some(tz) = &preset.timezone {
config.insert("timezone".to_string(), serde_json::json!(tz));
}
config
}
fn rewrite_ua_firefox_version(ua: &str, version: u32) -> String {
let firefox_re = Regex::new(r"Firefox/\d+\.0").expect("static regex");
let rv_re = Regex::new(r"rv:\d+\.0").expect("static regex");
let first = firefox_re.replace_all(ua, format!("Firefox/{version}.0"));
rv_re
.replace_all(&first, format!("rv:{version}.0"))
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picks_v135_for_old_firefox() {
assert_eq!(preset_line_for(Some(135)), PresetLine::V135);
assert_eq!(preset_line_for(Some(148)), PresetLine::V135);
assert_eq!(preset_line_for(None), PresetLine::V135);
}
#[test]
fn picks_newer_for_anything_past_the_legacy_line() {
// The threshold is data::PRESETS_NEWER_MIN_FF (currently 149).
// Future Firefox versions all share the same bundle — there's
// intentionally no per-version routing past v135.
assert_eq!(preset_line_for(Some(149)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(150)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(160)), PresetLine::Newer);
assert_eq!(preset_line_for(Some(200)), PresetLine::Newer);
}
#[test]
fn both_bundles_parse_and_cover_all_platforms() {
for (line, json) in [
(PresetLine::V135, data::FINGERPRINT_PRESETS_V135_JSON),
(PresetLine::Newer, data::FINGERPRINT_PRESETS_NEWER_JSON),
] {
let bundle: PresetBundle =
serde_json::from_str(json).unwrap_or_else(|e| panic!("bundle {line:?} parse error: {e}"));
for os in ["macos", "windows", "linux"] {
let presets = bundle.presets.get(os).unwrap_or_else(|| {
panic!("bundle {line:?} is missing presets for {os}");
});
assert!(
!presets.is_empty(),
"bundle {line:?} has zero presets for {os}"
);
}
}
}
#[test]
fn random_preset_returns_for_each_os() {
for os in ["macos", "windows", "linux"] {
let preset = get_random_preset(Some(os), Some(150)).expect("preset");
assert!(preset.navigator.is_some(), "navigator present for {os}");
}
}
#[test]
fn from_preset_rewrites_firefox_version() {
let preset = Preset {
navigator: Some(Navigator {
user_agent: Some(
"Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".to_string(),
),
platform: Some("Linux x86_64".to_string()),
hardware_concurrency: Some(8),
max_touch_points: Some(0),
oscpu: None,
}),
screen: None,
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
let config = from_preset(&preset, Some(150));
let ua = config
.get("navigator.userAgent")
.and_then(|v| v.as_str())
.unwrap();
assert!(ua.contains("Firefox/150.0"), "got: {ua}");
assert!(ua.contains("rv:150.0"), "got: {ua}");
// oscpu derived from "Linux x86_64" platform
assert_eq!(
config
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Linux x86_64"
);
}
#[test]
fn from_preset_derives_oscpu_for_mac_and_win() {
let mut preset = Preset {
navigator: Some(Navigator {
user_agent: None,
platform: Some("MacIntel".to_string()),
hardware_concurrency: None,
max_touch_points: None,
oscpu: None,
}),
screen: None,
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
assert_eq!(
from_preset(&preset, None)
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Intel Mac OS X 10.15"
);
preset.navigator.as_mut().unwrap().platform = Some("Win32".to_string());
assert_eq!(
from_preset(&preset, None)
.get("navigator.oscpu")
.and_then(|v| v.as_str())
.unwrap(),
"Windows NT 10.0; Win64; x64"
);
}
#[test]
fn screen_color_depth_fills_both_keys() {
let preset = Preset {
navigator: None,
screen: Some(Screen {
width: Some(1920),
height: Some(1080),
color_depth: Some(24),
avail_width: Some(1920),
avail_height: Some(1050),
device_pixel_ratio: Some(1.0),
}),
webgl: None,
timezone: None,
fonts: None,
speech_voices: None,
};
let config = from_preset(&preset, None);
assert_eq!(config.get("screen.colorDepth").unwrap(), 24);
assert_eq!(config.get("screen.pixelDepth").unwrap(), 24);
assert_eq!(config.get("screen.availWidth").unwrap(), 1920);
}
}
+25 -8
View File
@@ -127,8 +127,16 @@ lazy_static! {
impl CloudAuthManager {
fn new() -> Self {
let state = Self::load_auth_state_from_disk();
// Bound every cloud API call so no single slow / hung request can stall
// the startup chain (sync-token → proxy-config → wayfern-token), which
// otherwise gates Wayfern launch behind whichever endpoint is slowest.
let client = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| Client::new());
Self {
client: Client::new(),
client,
state: Mutex::new(state),
refresh_lock: tokio::sync::Mutex::new(()),
wayfern_token: Mutex::new(None),
@@ -990,7 +998,15 @@ impl CloudAuthManager {
let token = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
let client = reqwest::Client::new();
// Bound the request: without a timeout, an unreachable
// api.donutbrowser.com hangs the background fetch indefinitely,
// which in turn forces wayfern_manager's launch-time wait to
// exhaust its full polling budget every time.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.connect_timeout(std::time::Duration::from_secs(4))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
async move {
let response = client
.post(&url)
@@ -1199,13 +1215,14 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?;
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
// Always clear the stored sync URL and token on cloud logout. While the
// user was signed in, the cloud auth flow populated these with the hosted
// sync server's URL + a server-issued token — leaving them in place would
// pre-fill the Self-Hosted tab with our production URL and a token the
// user never typed. The cloud-URL-only check we used to do here missed
// trailing-slash / scheme variants and any future cloud endpoint moves.
let manager = crate::settings_manager::SettingsManager::instance();
if let Ok(sync_settings) = manager.get_sync_settings() {
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
let _ = manager.save_sync_server_url(None);
}
}
let _ = manager.save_sync_server_url(None);
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
+148 -1
View File
@@ -1,6 +1,6 @@
use crate::profile::manager::ProfileManager;
use crate::profile::BrowserProfile;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
@@ -134,6 +134,24 @@ pub struct CookieReadResult {
pub total_count: usize,
}
/// Lightweight cookie metadata for the profile-info dialog. Computed without
/// decrypting any cookie values, so it stays cheap even for multi-MB Chromium
/// cookie stores and never blocks the runtime for noticeable time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieStats {
pub profile_id: String,
pub browser_type: String,
pub total_count: usize,
/// Every domain the profile has cookies for, sorted by cookie count desc.
pub domains: Vec<DomainCount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainCount {
pub domain: String,
pub count: usize,
}
/// Request to copy specific cookies
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieCopyRequest {
@@ -694,6 +712,135 @@ impl CookieManager {
})
}
/// Open the cookie SQLite database read-only without acquiring any lock.
///
/// `immutable=1` tells SQLite the file will not change during the read,
/// which causes it to skip all locking. That lets us read metadata even
/// while the browser holds an exclusive lock on the cookies database —
/// the trade-off is that we may see a slightly stale snapshot, which is
/// acceptable for the badge/preview use cases this powers.
fn open_cookie_db_readonly(db_path: &Path) -> Result<Connection, String> {
let path_str = db_path.to_string_lossy();
if path_str.contains('?') || path_str.contains('#') {
return Err(
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": "profile path contains a reserved URI character" }
})
.to_string(),
);
}
let uri = format!("file:{path_str}?mode=ro&immutable=1");
Connection::open_with_flags(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
let code = if e.to_string().to_lowercase().contains("locked") {
"COOKIE_DB_LOCKED"
} else {
"COOKIE_DB_UNAVAILABLE"
};
serde_json::json!({
"code": code,
"params": { "detail": e.to_string() }
})
.to_string()
})
}
/// Public API: read lightweight stats (total count + top 5 domains) for a
/// profile's cookie store. Reads from a snapshot view of the SQLite file
/// without holding a lock, so this works while the browser is running.
pub fn read_stats(profile_id: &str) -> Result<CookieStats, String> {
let profile_manager = ProfileManager::instance();
let profiles_dir = profile_manager.get_profiles_dir();
let profiles = profile_manager.list_profiles().map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
let db_path = Self::get_cookie_db_path(profile, &profiles_dir).map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e }
})
.to_string()
})?;
let conn = Self::open_cookie_db_readonly(&db_path)?;
let (count_sql, domain_sql) = match profile.browser.as_str() {
"camoufox" => (
"SELECT COUNT(*) FROM moz_cookies",
"SELECT host, COUNT(*) FROM moz_cookies GROUP BY host ORDER BY COUNT(*) DESC, host ASC",
),
"wayfern" => (
"SELECT COUNT(*) FROM cookies",
"SELECT host_key, COUNT(*) FROM cookies GROUP BY host_key ORDER BY COUNT(*) DESC, host_key ASC",
),
_ => {
return Err(
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": format!("unsupported browser: {}", profile.browser) }
})
.to_string(),
)
}
};
let total_count: usize = conn
.query_row(count_sql, [], |row| row.get::<_, i64>(0))
.map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})? as usize;
let mut stmt = conn.prepare(domain_sql).map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
let domains: Vec<DomainCount> = stmt
.query_map([], |row| {
Ok(DomainCount {
domain: row.get::<_, String>(0)?,
count: row.get::<_, i64>(1)? as usize,
})
})
.and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
.map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
Ok(CookieStats {
profile_id: profile_id.to_string(),
browser_type: profile.browser.clone(),
total_count,
domains,
})
}
/// Public API: Copy cookies between profiles
pub async fn copy_cookies(
app_handle: &AppHandle,
+30 -9
View File
@@ -290,24 +290,45 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
// For each browser where every registered version would be removed (no
// profile uses any), keep the newest one by semver. Without this, the
// version preserved depends on HashMap iteration order, so a freshly
// downloaded version can be deleted in favor of an older orphan — leaving
// the UI stuck on "needs to be downloaded".
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
let mut removal_versions_by_browser: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
for (browser, version) in &to_remove {
removal_versions_by_browser
.entry(browser.clone())
.or_default()
.push(version.clone());
}
to_remove.retain(|(browser, version)| {
let mut keep_per_browser: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (browser, versions) in &removal_versions_by_browser {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
if versions.len() >= total {
if let Some(latest) = versions
.iter()
.max_by(|a, b| crate::api_client::compare_versions(a, b))
{
keep_per_browser.insert(browser.clone(), latest.clone());
}
}
}
drop(data);
to_remove.retain(|(browser, version)| {
if keep_per_browser
.get(browser)
.is_some_and(|keep| keep == version)
{
log::info!("Keeping latest available version: {browser} {version}");
return false;
}
true
+3 -1
View File
@@ -240,7 +240,7 @@ fn cleanup_legacy_dirs() {
}
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
if profile.ephemeral {
if profile.ephemeral || profile.password_protected {
if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
return dir;
}
@@ -279,6 +279,8 @@ mod tests {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
}
}
+70 -23
View File
@@ -12,6 +12,39 @@ use tokio::process::Command;
#[cfg(target_os = "macos")]
use std::fs::create_dir_all;
/// Returns true if `path` carries a `com.apple.quarantine` extended attribute.
///
/// Uses `getxattr` with a null buffer to query the attribute size only —
/// this is a read-only syscall and does NOT trigger macOS Sequoia's App
/// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS
/// fires the prompt on the modify-class syscall (`removexattr`) even when
/// the operation is a no-op, so skipping the call entirely when the
/// attribute is absent is the only way to stay quiet.
#[cfg(target_os = "macos")]
fn has_quarantine_attr(path: &Path) -> bool {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else {
return false;
};
let Ok(attr_c) = CString::new("com.apple.quarantine") else {
return false;
};
// SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0
// makes it a pure read-only size query.
let result = unsafe {
libc::getxattr(
path_c.as_ptr(),
attr_c.as_ptr(),
std::ptr::null_mut(),
0,
0,
0,
)
};
result >= 0
}
pub struct Extractor;
impl Extractor {
@@ -207,18 +240,23 @@ impl Extractor {
match extraction_result {
Ok(path) => {
// Remove quarantine attributes on macOS to prevent
// "app was prevented from modifying data" prompts
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
// but only if there's actually something to remove. Calling the
// modify-class `removexattr` syscall on a file without quarantine still
// fires macOS Sequoia's App Management TCC notification, so we skip
// the call entirely when the attribute is absent.
#[cfg(target_os = "macos")]
{
let _ = tokio::process::Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
dest_dir.to_str().unwrap_or("."),
])
.output()
.await;
if has_quarantine_attr(dest_dir) {
let _ = tokio::process::Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
dest_dir.to_str().unwrap_or("."),
])
.output()
.await;
}
}
log::info!(
@@ -419,9 +457,15 @@ impl Extractor {
log::info!("Copying .app to: {}", app_path.display());
// `-X` strips extended attributes (notably com.apple.quarantine) during
// the copy itself. Without it, `cp -R` preserves quarantine from the
// mounted DMG, which then has to be removed with `xattr -dr` — and that
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
// Management TCC notification ("Donut.app was prevented from modifying
// apps on your Mac"). Stripping at copy time is silent.
let output = Command::new("cp")
.args([
"-R",
"-RX",
app_entry.to_str().unwrap(),
app_path.to_str().unwrap(),
])
@@ -444,18 +488,21 @@ impl Extractor {
log::info!("Successfully copied .app bundle");
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output()
.await;
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output()
.await;
log::info!("Removed quarantine attributes");
// Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
// — but only if it's actually present. A no-op `removexattr` syscall on a
// signed .app bundle still trips macOS Sequoia's App Management privacy
// prompt ("Donut.app was prevented from modifying apps on your Mac"),
// even when no modification actually happens, so we gate the call behind
// a read-only `getxattr` check.
if has_quarantine_attr(&app_path) {
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output()
.await;
log::info!("Removed quarantine attributes");
} else {
log::info!("No quarantine attribute on .app, skipping xattr removal");
}
// Unmount the DMG
let output = Command::new("hdiutil")
+3 -13
View File
@@ -268,7 +268,9 @@ impl GroupManager {
}
}
// Create result including all groups (even those with 0 count)
// Create result including all groups (even those with 0 count).
// The "Default" pseudo-group is intentionally not returned: profiles
// without a group_id are surfaced through the "All" filter instead.
let mut result = Vec::new();
for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0);
@@ -281,18 +283,6 @@ impl GroupManager {
});
}
// Add default group count (profiles without group_id), always include even if 0
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
sync_enabled: false,
last_sync: None,
};
// Insert at the beginning for consistent ordering with UI expectations
result.insert(0, default_group);
Ok(result)
}
}
+175 -119
View File
@@ -52,6 +52,7 @@ pub mod daemon_client;
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
mod tag_manager;
mod team_lock;
@@ -72,6 +73,11 @@ use profile::manager::{
update_wayfern_config,
};
use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile, verify_profile_password,
};
use browser_version_manager::{
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_supported_browsers,
@@ -88,16 +94,17 @@ use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
save_table_sorting_settings, should_show_launch_on_login_prompt,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
};
use sync::{
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password,
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
verify_e2e_password,
};
use tag_manager::get_all_tags;
@@ -305,8 +312,21 @@ async fn import_proxies_from_parsed(
}
#[tauri::command]
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
cookie_manager::CookieManager::read_cookies(&profile_id)
async fn read_profile_cookies(
profile_id: String,
) -> Result<cookie_manager::CookieReadResult, String> {
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_cookies(&profile_id))
.await
.map_err(|e| format!("Failed to read profile cookies: {e}"))?
}
#[tauri::command]
async fn get_profile_cookie_stats(
profile_id: String,
) -> Result<cookie_manager::CookieStats, String> {
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_stats(&profile_id))
.await
.map_err(|e| format!("Failed to read profile cookie stats: {e}"))?
}
#[tauri::command]
@@ -485,20 +505,20 @@ fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
}
}
#[tauri::command]
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
Ok(dir.join("manifest.json").exists())
fn is_mcp_in_claude_desktop_internal() -> bool {
let Some(dir) = claude_desktop_extension_dir() else {
return false;
};
dir.join("manifest.json").exists()
}
#[tauri::command]
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
@@ -587,8 +607,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
Ok(())
}
#[tauri::command]
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
if ext_dir.exists() {
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
@@ -650,91 +669,48 @@ fn update_claude_extensions_registry(
Ok(())
}
fn find_claude_cli() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = vec![
std::path::PathBuf::from("/usr/local/bin/claude"),
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
];
if let Some(home) = dirs::home_dir() {
candidates.insert(0, home.join(".local/bin/claude"));
candidates.push(home.join(".claude/local/claude"));
}
#[cfg(windows)]
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.insert(
0,
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
);
}
for p in &candidates {
if p.exists() {
return Some(p.clone());
}
}
None
}
#[tauri::command]
async fn is_mcp_in_claude_code() -> Result<bool, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
// `claude mcp list` health-checks every registered MCP server, so a
// missing or stalled server can hang the call for many seconds. Cap it
// — for this dialog, a slow `claude` is treated the same as "not registered".
let fut = tokio::process::Command::new(&cli)
.args(["mcp", "list"])
.output();
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.map_err(|_| "claude mcp list timed out".to_string())?
.map_err(|e| format!("Failed to run claude: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.contains("donut-browser"))
}
#[tauri::command]
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
let _ = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output();
let output = std::process::Command::new(&cli)
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
}
Ok(())
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
}
#[tauri::command]
fn remove_mcp_from_claude_code() -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let output = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
Ok(mcp_integrations::list_agents_with_status(&[(
"claude-desktop",
claude_desktop_connected,
)]))
}
#[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
Ok(())
if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
}
#[tauri::command]
@@ -748,6 +724,15 @@ async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::Traffic
Ok(crate::traffic_stats::get_all_traffic_snapshots_realtime())
}
#[tauri::command]
async fn get_profile_traffic_snapshot(
profile_id: String,
) -> Result<Option<crate::traffic_stats::TrafficSnapshot>, String> {
Ok(crate::traffic_stats::get_traffic_snapshot_for_profile(
&profile_id,
))
}
#[tauri::command]
async fn clear_all_traffic_stats() -> Result<(), String> {
crate::traffic_stats::clear_all_traffic_stats()
@@ -1133,6 +1118,8 @@ async fn generate_sample_fingerprint(
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
if browser == "camoufox" {
@@ -1180,7 +1167,11 @@ pub fn run() {
.target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}))
.max_file_size(100_000) // 100KB
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
@@ -1216,6 +1207,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -1238,7 +1230,7 @@ pub fn run() {
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(840.0, 500.0)
.inner_size(880.0, 500.0)
.resizable(false)
.fullscreen(false)
.center()
@@ -1344,18 +1336,31 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await;
});
// Auto-start MCP server if it was previously enabled
// Auto-start MCP server if it was previously enabled. Always log the
// decision so customer logs reveal whether MCP is actually running —
// "automation features don't work" is otherwise indistinguishable from
// "MCP server isn't enabled" without this line.
{
let mcp_handle = app.handle().clone();
let settings_mgr = settings_manager::SettingsManager::instance();
if let Ok(settings) = settings_mgr.load_settings() {
if settings.mcp_enabled {
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
});
match settings_mgr.load_settings() {
Ok(settings) => {
if settings.mcp_enabled {
log::info!("MCP server is enabled in settings, attempting auto-start");
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
});
} else {
log::info!(
"MCP server is DISABLED in settings (mcp_enabled=false). Browser automation tools will not be available until it's enabled in Settings → Integrations."
);
}
}
Err(e) => {
log::warn!("Could not read settings to determine MCP state: {e}");
}
}
}
@@ -1716,7 +1721,23 @@ pub fn run() {
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
for profile in profiles {
// Only walk profiles that either have a stored PID or that we last
// saw as running — for users with hundreds of idle profiles this
// turns an O(N) sysinfo scan into an O(running) scan. The Rust
// launch path always emits profile-running-changed when a profile
// STARTS, so newly-running profiles still get tracked here.
let profiles_to_check: Vec<_> = profiles
.into_iter()
.filter(|p| {
p.process_id.is_some()
|| last_running_states
.get(&p.id.to_string())
.copied()
.unwrap_or(false)
})
.collect();
for profile in profiles_to_check {
// Check browser status and track changes
match runner
.check_browser_status(app_handle_status.clone(), &profile)
@@ -1759,6 +1780,19 @@ pub fn run() {
);
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
// Must run BEFORE `mark_profile_stopped` because that
// releases any queued sync run, and a sync that picks up
// the on-disk dir before re-encryption finishes uploads
// the previous snapshot (issue: encrypted profiles not
// syncing fresh data).
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit_and_wait(&profile)
.await;
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
@@ -1888,21 +1922,31 @@ pub fn run() {
// Start cloud auth background refresh loop
let app_handle_cloud = app.handle().clone();
tauri::async_runtime::spawn(async move {
// On startup, refresh sync token and proxy if cloud auth is active.
// On startup, refresh sync token, proxy config, and wayfern token in
// PARALLEL. Previously they were awaited sequentially, so the wayfern
// token request didn't even start until the earlier two API calls had
// finished. Wayfern launch can race with this task — a few seconds of
// serialized API calls translates directly into a slow first launch
// because launch_wayfern blocks waiting for the token to land.
// api_call_with_retry handles 401/refresh internally — no direct
// refresh_access_token call needed.
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
// Request wayfern token on startup for paid users
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
let sync_token_fut = async {
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
}
};
let proxy_fut = async {
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
};
let wayfern_fut = async {
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
}
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
}
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
});
@@ -1938,6 +1982,8 @@ pub fn run() {
rename_profile,
get_app_settings,
save_app_settings,
read_log_files,
open_log_directory,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
@@ -2005,6 +2051,7 @@ pub fn run() {
stop_api_server,
get_api_server_status,
get_all_traffic_snapshots,
get_profile_traffic_snapshot,
clear_all_traffic_stats,
get_traffic_stats_for_period,
get_sync_settings,
@@ -2023,8 +2070,11 @@ pub fn run() {
enable_sync_for_all_entities,
set_e2e_password,
check_has_e2e_password,
verify_e2e_password,
delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies,
get_profile_cookie_stats,
copy_profile_cookies,
import_cookies_from_file,
export_profile_cookies,
@@ -2038,12 +2088,9 @@ pub fn run() {
stop_mcp_server,
get_mcp_server_status,
get_mcp_config,
is_mcp_in_claude_desktop,
add_mcp_to_claude_desktop,
remove_mcp_from_claude_desktop,
is_mcp_in_claude_code,
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
list_mcp_agents,
add_mcp_to_agent,
remove_mcp_from_agent,
// VPN commands
import_vpn_config,
list_vpn_configs,
@@ -2082,6 +2129,14 @@ pub fn run() {
// DNS blocklist commands
dns_blocklist::get_dns_blocklist_cache_status,
dns_blocklist::refresh_dns_blocklists,
// Profile password commands
set_profile_password,
change_profile_password,
remove_profile_password,
verify_profile_password,
unlock_profile,
lock_profile,
is_profile_locked,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -2128,6 +2183,7 @@ mod tests {
"generate_sample_fingerprint",
"cloud_get_wayfern_token",
"cloud_refresh_wayfern_token",
"lock_profile",
];
// Extract command names from the generate_handler! macro in this file
+574
View File
@@ -0,0 +1,574 @@
// MCP client integrations — installs/removes the donut-browser MCP server in
// 14 popular AI assistant clients. Ports the add-mcp registry to Rust.
//
// Claude Desktop is managed via Claude's local extensions bundle
// (manifest.json + node bridge), since the desktop app supports only stdio
// servers via its plain JSON config but exposes HTTP through the extension
// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other
// agents (including Claude Code) use the generic config-file installer here.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const SERVER_NAME: &str = "donut-browser";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum AgentCategory {
DesktopApp,
Cli,
Editor,
EditorExt,
}
#[derive(Debug, Clone, Copy)]
enum ConfigFormat {
Json,
Toml,
Yaml,
}
#[derive(Debug, Clone)]
struct AgentSpec {
id: &'static str,
display_name: &'static str,
category: AgentCategory,
/// Top-level key (supports dot notation) where the server is written.
config_key: &'static str,
format: ConfigFormat,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct McpAgentInfo {
pub id: String,
pub display_name: String,
pub category: AgentCategory,
pub connected: bool,
/// True when the underlying client appears to be installed on the system
/// (its config directory exists), regardless of whether we have installed
/// the donut-browser server into it.
pub detected: bool,
}
fn home() -> Option<PathBuf> {
dirs::home_dir()
}
#[cfg(target_os = "macos")]
fn vscode_user_dir() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Code")
.join("User")
})
}
#[cfg(target_os = "windows")]
fn vscode_user_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Code").join("User"))
}
#[cfg(target_os = "linux")]
fn vscode_user_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Code").join("User"))
}
#[cfg(target_os = "macos")]
fn zed_config_dir() -> Option<PathBuf> {
home().map(|h| h.join("Library").join("Application Support").join("Zed"))
}
#[cfg(target_os = "windows")]
fn zed_config_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Zed"))
}
#[cfg(target_os = "linux")]
fn zed_config_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("zed"))
}
#[cfg(target_os = "windows")]
fn goose_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Block")
.join("goose")
.join("config")
.join("config.yaml")
})
}
#[cfg(not(target_os = "windows"))]
fn goose_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("goose").join("config.yaml"))
}
/// Resolve the global config path for an agent. Returns `None` on unsupported
/// platforms (none currently — every supported agent has a defined path on
/// macOS/Linux/Windows).
fn config_path_for(agent_id: &str) -> Option<PathBuf> {
let h = home()?;
match agent_id {
"antigravity" => Some(
h.join(".gemini")
.join("antigravity")
.join("mcp_config.json"),
),
"cline" => vscode_user_dir().map(|d| {
d.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json")
}),
"cline-cli" => {
let base = std::env::var("CLINE_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".cline"));
Some(
base
.join("data")
.join("settings")
.join("cline_mcp_settings.json"),
)
}
"claude-code" => Some(h.join(".claude.json")),
"claude-desktop" => claude_desktop_config_path(),
"codex" => {
let base = std::env::var("CODEX_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".codex"));
Some(base.join("config.toml"))
}
"cursor" => Some(h.join(".cursor").join("mcp.json")),
"gemini-cli" => Some(h.join(".gemini").join("settings.json")),
"goose" => goose_config_path(),
"github-copilot-cli" => Some(
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".copilot"))
.join("mcp-config.json"),
),
"mcporter" => {
// add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back
// to mcporter.jsonc if it already exists, else default to mcporter.json.
let dir = h.join(".mcporter");
let json_path = dir.join("mcporter.json");
let jsonc_path = dir.join("mcporter.jsonc");
if json_path.exists() {
Some(json_path)
} else if jsonc_path.exists() {
Some(jsonc_path)
} else {
Some(json_path)
}
}
"opencode" => Some(h.join(".config").join("opencode").join("opencode.json")),
"vscode" => vscode_user_dir().map(|d| d.join("mcp.json")),
"zed" => zed_config_dir().map(|d| d.join("settings.json")),
_ => None,
}
}
#[cfg(target_os = "macos")]
fn claude_desktop_config_path() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "windows")]
fn claude_desktop_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "linux")]
fn claude_desktop_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Claude").join("claude_desktop_config.json"))
}
const AGENT_SPECS: &[AgentSpec] = &[
AgentSpec {
id: "claude-desktop",
display_name: "Claude Desktop",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "claude-code",
display_name: "Claude Code",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cursor",
display_name: "Cursor",
category: AgentCategory::Editor,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "vscode",
display_name: "VS Code",
category: AgentCategory::Editor,
config_key: "servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "zed",
display_name: "Zed",
category: AgentCategory::Editor,
config_key: "context_servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline-cli",
display_name: "Cline CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline",
display_name: "Cline VSCode",
category: AgentCategory::EditorExt,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "codex",
display_name: "Codex",
category: AgentCategory::Cli,
config_key: "mcp_servers",
format: ConfigFormat::Toml,
},
AgentSpec {
id: "gemini-cli",
display_name: "Gemini CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "github-copilot-cli",
display_name: "GitHub Copilot CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "goose",
display_name: "Goose",
category: AgentCategory::Cli,
config_key: "extensions",
format: ConfigFormat::Yaml,
},
AgentSpec {
id: "antigravity",
display_name: "Antigravity",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "opencode",
display_name: "OpenCode",
category: AgentCategory::Cli,
config_key: "mcp",
format: ConfigFormat::Json,
},
AgentSpec {
id: "mcporter",
display_name: "MCPorter",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
];
fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> {
AGENT_SPECS.iter().find(|s| s.id == agent_id)
}
fn detect_agent_directory(agent_id: &str) -> bool {
// Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate
// parent of the config file. Used only for UI annotation; install/uninstall
// always operates on the resolved config path.
let Some(h) = home() else {
return false;
};
match agent_id {
"antigravity" => h.join(".gemini").exists(),
"cline" => config_path_for("cline")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"cline-cli" => config_path_for("cline-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"claude-code" => h.join(".claude").exists(),
"claude-desktop" => claude_desktop_config_path()
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"codex" => h.join(".codex").exists(),
"cursor" => h.join(".cursor").exists(),
"gemini-cli" => h.join(".gemini").exists(),
"github-copilot-cli" => config_path_for("github-copilot-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"goose" => goose_config_path().is_some_and(|p| p.exists()),
"mcporter" => h.join(".mcporter").exists(),
"opencode" => h.join(".config").join("opencode").exists(),
"vscode" => vscode_user_dir().is_some_and(|d| d.exists()),
"zed" => zed_config_dir().is_some_and(|d| d.exists()),
_ => false,
}
}
/// Transform the donut-browser HTTP server config into the per-agent shape.
/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge
/// (handled by the extension installer in lib.rs).
fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value {
use serde_json::json;
match agent_id {
"zed" => json!({ "source": "custom", "type": "http", "url": url }),
"opencode" => json!({ "type": "remote", "url": url, "enabled": true }),
"antigravity" => json!({ "serverUrl": url }),
"cursor" => json!({ "url": url }),
"cline" | "cline-cli" => json!({
"url": url,
"type": "streamableHttp",
"disabled": false,
}),
"codex" => json!({ "type": "http", "url": url }),
"github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }),
"goose" => json!({
"name": SERVER_NAME,
"description": "",
"type": "streamable_http",
"uri": url,
"headers": {},
"enabled": true,
"timeout": 300,
}),
"vscode" => json!({ "type": "http", "url": url }),
// claude-code, claude-desktop, gemini-cli, mcporter — passthrough
_ => json!({ "type": "http", "url": url }),
}
}
/// Detect whether a server config object looks like our donut-browser HTTP
/// endpoint by URL prefix. Matches across the various per-agent key shapes
/// (`url`, `uri`, `serverUrl`).
fn config_matches_donut(value: &serde_json::Value) -> bool {
for key in ["url", "uri", "serverUrl"] {
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if s.contains("/mcp/")
&& (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost"))
{
return true;
}
}
}
false
}
fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value {
let Ok(content) = fs::read_to_string(path) else {
return serde_json::Value::Null;
};
match format {
ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null),
ConfigFormat::Toml => toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|t| serde_json::to_value(t).ok())
.unwrap_or(serde_json::Value::Null),
ConfigFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(&content)
.ok()
.and_then(|y| serde_json::to_value(y).ok())
.unwrap_or(serde_json::Value::Null),
}
}
fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = match format {
ConfigFormat::Json => {
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))?
}
ConfigFormat::Toml => {
let toml_val: toml::Value = serde_json::from_value(value.clone())
.map_err(|e| format!("Failed to convert to TOML: {e}"))?;
toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))?
}
ConfigFormat::Yaml => {
let yaml_val: serde_yaml::Value = serde_yaml::from_str(
&serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?,
)
.map_err(|e| format!("Failed to convert to YAML: {e}"))?;
serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))?
}
};
fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
/// Navigate `config_key` (dot notation), creating object literals at each
/// missing level. Returns a mutable reference to the bottom container so the
/// caller can set/remove server entries.
fn ensure_nested_object<'a>(
root: &'a mut serde_json::Value,
config_key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !root.is_object() {
*root = serde_json::Value::Object(serde_json::Map::new());
}
let mut current = root.as_object_mut().expect("just set to object");
let parts: Vec<&str> = config_key.split('.').collect();
for part in &parts {
let entry = current
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
current = entry.as_object_mut().expect("just ensured object");
}
current
}
fn nested_object<'a>(
root: &'a serde_json::Value,
config_key: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let mut current = root.as_object()?;
for part in config_key.split('.') {
current = current.get(part)?.as_object()?;
}
Some(current)
}
fn is_generic_agent_connected(agent_id: &str) -> bool {
let Some(spec) = spec_for(agent_id) else {
return false;
};
let Some(path) = config_path_for(agent_id) else {
return false;
};
if !path.exists() {
return false;
}
let root = read_value(&path, spec.format);
let Some(servers) = nested_object(&root, spec.config_key) else {
return false;
};
if let Some(entry) = servers.get(SERVER_NAME) {
return config_matches_donut(entry);
}
servers.values().any(config_matches_donut)
}
/// Install or remove the donut-browser entry from a generic agent. Returns
/// `true` if a write happened. Callers handle higher-level dispatch (Claude
/// Desktop extension setup, Claude Code CLI invocation).
pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let path = config_path_for(agent_id)
.ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?;
let mut root = if path.exists() {
read_value(&path, spec.format)
} else {
serde_json::Value::Object(serde_json::Map::new())
};
if !root.is_object() {
root = serde_json::Value::Object(serde_json::Map::new());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.insert(
SERVER_NAME.to_string(),
transform_remote_config(agent_id, url),
);
write_value(&path, &root, spec.format)
}
pub fn uninstall_generic(agent_id: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let Some(path) = config_path_for(agent_id) else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let mut root = read_value(&path, spec.format);
if !root.is_object() {
return Ok(());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.remove(SERVER_NAME);
write_value(&path, &root, spec.format)
}
pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec<McpAgentInfo> {
AGENT_SPECS
.iter()
.map(|spec| {
let connected = connected_overrides
.iter()
.find(|(id, _)| *id == spec.id)
.map(|(_, c)| *c)
.unwrap_or_else(|| is_generic_agent_connected(spec.id));
McpAgentInfo {
id: spec.id.to_string(),
display_name: spec.display_name.to_string(),
category: spec.category,
connected,
detected: detect_agent_directory(spec.id),
}
})
.collect()
}
pub fn agent_exists(agent_id: &str) -> bool {
spec_for(agent_id).is_some()
}
+88 -42
View File
@@ -112,6 +112,17 @@ impl McpServer {
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
// Log the failed gate so customer logs explain why an MCP tool returned
// an error. Include enough state (logged-in vs not, plan, status) for
// support to diagnose without leaking secrets.
let summary = match CLOUD_AUTH.get_user().await {
Some(state) => format!(
"logged_in=true plan={} status={} period={:?}",
state.user.plan, state.user.subscription_status, state.user.plan_period,
),
None => "logged_in=false".to_string(),
};
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
return Err(McpError {
code: -32000,
message: format!("{feature} requires an active paid subscription"),
@@ -1458,103 +1469,138 @@ impl McpServer {
.cloned()
.unwrap_or(serde_json::json!({}));
// Surface the call in logs so customer reports show which tools the MCP
// client is actually invoking (and therefore which gate any subsequent
// error came from). Log only the tool name and the profile_id arg —
// arbitrary URLs / JS / selectors can be sensitive.
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}");
let started = std::time::Instant::now();
let result = self.dispatch_tool_call(tool_name, &arguments).await;
let elapsed_ms = started.elapsed().as_millis();
match &result {
Ok(_) => {
log::info!(
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> ok ({elapsed_ms} ms)"
);
}
Err(e) => {
log::warn!(
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> error code={} msg={:?} ({elapsed_ms} ms)",
e.code,
e.message
);
}
}
result
}
async fn dispatch_tool_call(
&self,
tool_name: &str,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
match tool_name {
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).await,
"get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_run_profile(&arguments).await
self.handle_run_profile(arguments).await
}
"kill_profile" => self.handle_kill_profile(&arguments).await,
"create_profile" => self.handle_create_profile(&arguments).await,
"update_profile" => self.handle_update_profile(&arguments).await,
"delete_profile" => self.handle_delete_profile(&arguments).await,
"kill_profile" => self.handle_kill_profile(arguments).await,
"create_profile" => self.handle_create_profile(arguments).await,
"update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_profile(arguments).await,
"list_tags" => self.handle_list_tags().await,
"list_proxies" => self.handle_list_proxies().await,
"get_profile_status" => self.handle_get_profile_status(&arguments).await,
"get_profile_status" => self.handle_get_profile_status(arguments).await,
// Group management
"list_groups" => self.handle_list_groups().await,
"get_group" => self.handle_get_group(&arguments).await,
"create_group" => self.handle_create_group(&arguments).await,
"update_group" => self.handle_update_group(&arguments).await,
"delete_group" => self.handle_delete_group(&arguments).await,
"assign_profiles_to_group" => self.handle_assign_profiles_to_group(&arguments).await,
"get_group" => self.handle_get_group(arguments).await,
"create_group" => self.handle_create_group(arguments).await,
"update_group" => self.handle_update_group(arguments).await,
"delete_group" => self.handle_delete_group(arguments).await,
"assign_profiles_to_group" => self.handle_assign_profiles_to_group(arguments).await,
// Full proxy management
"get_proxy" => self.handle_get_proxy(&arguments).await,
"create_proxy" => self.handle_create_proxy(&arguments).await,
"update_proxy" => self.handle_update_proxy(&arguments).await,
"delete_proxy" => self.handle_delete_proxy(&arguments).await,
"get_proxy" => self.handle_get_proxy(arguments).await,
"create_proxy" => self.handle_create_proxy(arguments).await,
"update_proxy" => self.handle_update_proxy(arguments).await,
"delete_proxy" => self.handle_delete_proxy(arguments).await,
// Proxy import/export
"export_proxies" => self.handle_export_proxies(&arguments).await,
"import_proxies" => self.handle_import_proxies(&arguments).await,
"export_proxies" => self.handle_export_proxies(arguments).await,
"import_proxies" => self.handle_import_proxies(arguments).await,
// VPN management
"import_vpn" => self.handle_import_vpn(&arguments).await,
"import_vpn" => self.handle_import_vpn(arguments).await,
"list_vpn_configs" => self.handle_list_vpn_configs().await,
"delete_vpn" => self.handle_delete_vpn(&arguments).await,
"connect_vpn" => self.handle_connect_vpn(&arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(&arguments).await,
"delete_vpn" => self.handle_delete_vpn(arguments).await,
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await,
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
"update_profile_proxy_bypass_rules" => {
self
.handle_update_profile_proxy_bypass_rules(&arguments)
.handle_update_profile_proxy_bypass_rules(arguments)
.await
}
// DNS blocklist management
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(&arguments).await,
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(arguments).await,
"get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
"create_extension_group" => self.handle_create_extension_group(&arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(&arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(&arguments).await,
"create_extension_group" => self.handle_create_extension_group(arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
"assign_extension_group_to_profile" => {
self
.handle_assign_extension_group_to_profile(&arguments)
.handle_assign_extension_group_to_profile(arguments)
.await
}
// Team lock tools
"get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await,
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
// Synchronizer tools
"start_sync_session" => {
Self::require_paid_subscription("Synchronizer").await?;
self.handle_start_sync_session(&arguments).await
self.handle_start_sync_session(arguments).await
}
"stop_sync_session" => self.handle_stop_sync_session(&arguments).await,
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
"get_sync_sessions" => self.handle_get_sync_sessions().await,
"remove_sync_follower" => self.handle_remove_sync_follower(&arguments).await,
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
// Browser interaction tools (require paid subscription)
"navigate" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_navigate(&arguments).await
self.handle_navigate(arguments).await
}
"screenshot" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_screenshot(&arguments).await
self.handle_screenshot(arguments).await
}
"evaluate_javascript" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_evaluate_javascript(&arguments).await
self.handle_evaluate_javascript(arguments).await
}
"click_element" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_click_element(&arguments).await
self.handle_click_element(arguments).await
}
"type_text" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_type_text(&arguments).await
self.handle_type_text(arguments).await
}
"get_page_content" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_content(&arguments).await
self.handle_get_page_content(arguments).await
}
"get_page_info" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_info(&arguments).await
self.handle_get_page_info(arguments).await
}
_ => Err(McpError {
code: -32602,
+702
View File
@@ -0,0 +1,702 @@
//! Per-file encryption for password-protected profiles.
//!
//! Each on-disk file in `profiles/{uuid}/profile/` has:
//! - **Filename**: `urlsafe_no_pad(HMAC-SHA256(profile_key, plaintext_relpath))[..32]`.
//! Deterministic so cross-machine sync sees stable filenames; same plaintext
//! path with same key always produces the same on-disk name.
//! - **Content**: `nonce(12B) || AES-256-GCM(profile_key, path_len(2B-LE) || plaintext_path || file_bytes)`.
//! The plaintext relpath is encoded inside the ciphertext so a launch can
//! reconstruct the directory tree without a separate manifest.
//!
//! Wrong password fails the AES-GCM auth tag on the first decrypt, which
//! doubles as password verification.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ring::hmac;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use crate::sync::encryption::{decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt};
/// Length of the on-disk HMAC filename in chars.
const HMAC_FILENAME_LEN: usize = 32;
/// Marker file written into encrypted profile dirs so launch code can verify
/// the password before attempting to decrypt actual user data files.
const VERIFY_FILE_NAME: &str = ".donut-pw-verify";
const VERIFY_FILE_PATH: &str = "__donut_pw_verify__";
lazy_static::lazy_static! {
/// In-memory cache of derived per-profile encryption keys, keyed by profile UUID.
/// Only populated while a profile is unlocked / running. Never persisted.
static ref KEY_CACHE: Mutex<HashMap<uuid::Uuid, [u8; 32]>> = Mutex::new(HashMap::new());
}
#[derive(Debug, thiserror::Error)]
pub enum PasswordError {
#[error("io error: {0}")]
Io(String),
#[error("encryption error: {0}")]
Encryption(String),
#[error("invalid password")]
WrongPassword,
#[error("invalid file format")]
InvalidFormat,
}
pub type PasswordResult<T> = Result<T, PasswordError>;
impl From<std::io::Error> for PasswordError {
fn from(e: std::io::Error) -> Self {
PasswordError::Io(e.to_string())
}
}
/// Compute the HMAC-SHA256 derived on-disk filename for a plaintext relative path.
pub fn hmac_filename(key: &[u8; 32], plaintext_relpath: &str) -> String {
let signing_key = hmac::Key::new(hmac::HMAC_SHA256, key);
let tag = hmac::sign(&signing_key, plaintext_relpath.as_bytes());
let encoded = URL_SAFE_NO_PAD.encode(tag.as_ref());
encoded.chars().take(HMAC_FILENAME_LEN).collect()
}
/// Encrypt a single file's contents with its plaintext relative path embedded.
pub fn encrypt_profile_file(
key: &[u8; 32],
plaintext_relpath: &str,
file_bytes: &[u8],
) -> PasswordResult<Vec<u8>> {
let path_bytes = plaintext_relpath.as_bytes();
if path_bytes.len() > u16::MAX as usize {
return Err(PasswordError::Encryption("relpath too long".into()));
}
let mut plaintext = Vec::with_capacity(2 + path_bytes.len() + file_bytes.len());
plaintext.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
plaintext.extend_from_slice(path_bytes);
plaintext.extend_from_slice(file_bytes);
encrypt_bytes(key, &plaintext).map_err(PasswordError::Encryption)
}
/// Decrypt one file's bytes back into `(plaintext_relpath, file_bytes)`.
pub fn decrypt_profile_file(
key: &[u8; 32],
encrypted_bytes: &[u8],
) -> PasswordResult<(String, Vec<u8>)> {
let plaintext = decrypt_bytes(key, encrypted_bytes).map_err(|_| PasswordError::WrongPassword)?;
if plaintext.len() < 2 {
return Err(PasswordError::InvalidFormat);
}
let path_len = u16::from_le_bytes([plaintext[0], plaintext[1]]) as usize;
if plaintext.len() < 2 + path_len {
return Err(PasswordError::InvalidFormat);
}
let path = std::str::from_utf8(&plaintext[2..2 + path_len])
.map_err(|_| PasswordError::InvalidFormat)?
.to_string();
let content = plaintext[2 + path_len..].to_vec();
Ok((path, content))
}
fn build_excludes(patterns: &[&str]) -> GlobSet {
let mut builder = GlobSetBuilder::new();
for p in patterns {
if let Ok(g) = Glob::new(p) {
builder.add(g);
}
}
builder.build().unwrap_or_else(|_| GlobSet::empty())
}
fn walk_files(
base: &Path,
current: &Path,
excludes: &GlobSet,
out: &mut Vec<(String, PathBuf)>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
let relative = path
.strip_prefix(base)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
if excludes.is_match(&relative) {
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if metadata.is_dir() {
walk_files(base, &path, excludes, out)?;
} else if metadata.is_file() {
out.push((relative, path));
}
}
Ok(())
}
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("donut-tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)
}
fn write_verifier(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
let encrypted = encrypt_profile_file(key, VERIFY_FILE_PATH, b"donut-verify")?;
let path = encrypted_dir.join(VERIFY_FILE_NAME);
atomic_write(&path, &encrypted)?;
Ok(())
}
/// Verify a derived key against an encrypted profile dir. Returns Ok(()) on
/// success, `Err(WrongPassword)` if the password is wrong, or another error
/// for I/O / format problems.
pub fn verify_key_against_dir(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
let path = encrypted_dir.join(VERIFY_FILE_NAME);
if !path.exists() {
return Err(PasswordError::InvalidFormat);
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
if relpath != VERIFY_FILE_PATH || content != b"donut-verify" {
return Err(PasswordError::InvalidFormat);
}
Ok(())
}
/// Encrypt every file under `plaintext_dir` into `encrypted_dir`, replacing
/// it. Files matching `exclude_patterns` are dropped.
pub fn encrypt_profile_dir(
key: &[u8; 32],
plaintext_dir: &Path,
encrypted_dir: &Path,
exclude_patterns: &[&str],
) -> PasswordResult<()> {
if encrypted_dir.exists() {
std::fs::remove_dir_all(encrypted_dir)?;
}
std::fs::create_dir_all(encrypted_dir)?;
let excludes = build_excludes(exclude_patterns);
let mut files = Vec::new();
if plaintext_dir.exists() {
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut files)?;
}
for (relpath, abs) in files {
let bytes = std::fs::read(&abs)?;
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
atomic_write(&on_disk, &encrypted)?;
}
write_verifier(key, encrypted_dir)?;
Ok(())
}
/// Decrypt every file in `encrypted_dir` back into `plaintext_dir` (which is
/// created if missing). Returns the per-file mtimes captured after writing,
/// keyed by plaintext relpath. Caller can use them as the "before-launch"
/// snapshot to skip unchanged files on re-encrypt.
pub fn decrypt_profile_dir(
key: &[u8; 32],
encrypted_dir: &Path,
plaintext_dir: &Path,
) -> PasswordResult<HashMap<String, SystemTime>> {
std::fs::create_dir_all(plaintext_dir)?;
let mut mtimes = HashMap::new();
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
for entry in entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
let dest = plaintext_dir.join(&relpath);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, &content)?;
if let Ok(m) = dest.metadata().and_then(|m| m.modified()) {
mtimes.insert(relpath, m);
}
}
Ok(mtimes)
}
/// Re-encrypt the contents of `plaintext_dir` back into `encrypted_dir`,
/// preserving on-disk filenames for files whose plaintext content didn't
/// change. Returns the number of files re-encrypted.
///
/// `before_launch_mtimes` is the snapshot captured by `decrypt_profile_dir`.
/// Files whose mtime hasn't moved are left untouched on disk.
pub fn reencrypt_changed_files(
key: &[u8; 32],
plaintext_dir: &Path,
encrypted_dir: &Path,
exclude_patterns: &[&str],
before_launch_mtimes: &HashMap<String, SystemTime>,
) -> PasswordResult<usize> {
std::fs::create_dir_all(encrypted_dir)?;
let excludes = build_excludes(exclude_patterns);
let mut current_files = Vec::new();
if plaintext_dir.exists() {
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut current_files)?;
}
let mut current_paths: HashSet<String> = HashSet::new();
let mut rewrote = 0usize;
for (relpath, abs) in current_files {
current_paths.insert(relpath.clone());
let cur_mtime = abs.metadata().and_then(|m| m.modified()).ok();
let unchanged = match (cur_mtime, before_launch_mtimes.get(&relpath)) {
(Some(now), Some(before)) => now == *before,
_ => false,
};
if unchanged {
continue;
}
let bytes = std::fs::read(&abs)?;
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
atomic_write(&on_disk, &encrypted)?;
rewrote += 1;
}
// Delete on-disk files for plaintext paths that no longer exist
let valid_names: HashSet<String> = current_paths
.iter()
.map(|p| hmac_filename(key, p))
.collect();
for entry in std::fs::read_dir(encrypted_dir)?.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
if !valid_names.contains(&name) {
let _ = std::fs::remove_file(&path);
}
}
write_verifier(key, encrypted_dir)?;
Ok(rewrote)
}
/// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in
/// place. Used when changing a profile password without launching it.
pub fn rekey_profile_dir(
old_key: &[u8; 32],
new_key: &[u8; 32],
encrypted_dir: &Path,
) -> PasswordResult<()> {
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
let mut decrypted: Vec<(String, Vec<u8>)> = Vec::new();
for entry in &entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(old_key, &bytes)?;
decrypted.push((relpath, content));
}
// Decryption succeeded for every file; safe to rewrite the directory.
for entry in entries {
let path = entry.path();
if path.is_file() {
let _ = std::fs::remove_file(&path);
}
}
for (relpath, content) in decrypted {
let encrypted = encrypt_profile_file(new_key, &relpath, &content)?;
let on_disk = encrypted_dir.join(hmac_filename(new_key, &relpath));
atomic_write(&on_disk, &encrypted)?;
}
write_verifier(new_key, encrypted_dir)?;
Ok(())
}
// ---------- key cache ----------
pub fn cache_key(profile_id: uuid::Uuid, key: [u8; 32]) {
if let Ok(mut guard) = KEY_CACHE.lock() {
guard.insert(profile_id, key);
}
}
pub fn get_cached_key(profile_id: &uuid::Uuid) -> Option<[u8; 32]> {
KEY_CACHE.lock().ok()?.get(profile_id).copied()
}
pub fn drop_cached_key(profile_id: &uuid::Uuid) {
if let Ok(mut guard) = KEY_CACHE.lock() {
guard.remove(profile_id);
}
}
pub fn has_cached_key(profile_id: &uuid::Uuid) -> bool {
KEY_CACHE
.lock()
.map(|g| g.contains_key(profile_id))
.unwrap_or(false)
}
/// Convenience: derive + verify against the encrypted dir + cache the key on success.
pub fn unlock(
profile_id: uuid::Uuid,
password: &str,
salt: &str,
encrypted_dir: &Path,
) -> PasswordResult<()> {
let key = derive_profile_key(password, salt).map_err(PasswordError::Encryption)?;
verify_key_against_dir(&key, encrypted_dir)?;
cache_key(profile_id, key);
Ok(())
}
pub fn fresh_salt() -> String {
generate_salt()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_key() -> [u8; 32] {
derive_profile_key("hunter2", &generate_salt()).unwrap()
}
#[test]
fn test_hmac_filename_deterministic() {
let key = [7u8; 32];
let a = hmac_filename(&key, "Default/Cookies");
let b = hmac_filename(&key, "Default/Cookies");
assert_eq!(a, b);
assert_eq!(a.len(), HMAC_FILENAME_LEN);
}
#[test]
fn test_hmac_filename_different_keys() {
let a = hmac_filename(&[1u8; 32], "Default/Cookies");
let b = hmac_filename(&[2u8; 32], "Default/Cookies");
assert_ne!(a, b);
}
#[test]
fn test_hmac_filename_different_paths() {
let key = [1u8; 32];
let a = hmac_filename(&key, "Default/Cookies");
let b = hmac_filename(&key, "Default/Login Data");
assert_ne!(a, b);
}
#[test]
fn test_file_roundtrip() {
let key = make_key();
let original = b"hello world".to_vec();
let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap();
let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap();
assert_eq!(path, "Default/Cookies");
assert_eq!(content, original);
}
#[test]
fn test_file_wrong_key_fails() {
let key1 = make_key();
let key2 = make_key();
let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap();
assert!(matches!(
decrypt_profile_file(&key2, &encrypted),
Err(PasswordError::WrongPassword)
));
}
#[test]
fn test_file_truncated_ciphertext() {
let key = make_key();
let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap();
// Drop the auth tag
let truncated = &encrypted[..encrypted.len() - 1];
assert!(decrypt_profile_file(&key, truncated).is_err());
}
#[test]
fn test_dir_roundtrip() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(plain.join("Default")).unwrap();
std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap();
std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
std::fs::write(plain.join("Local State"), b"state").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
// No plaintext filenames on disk
let names: Vec<String> = std::fs::read_dir(&enc)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
for n in &names {
assert!(!n.contains("Cookies"), "plaintext leaked: {n}");
assert!(!n.contains("Bookmarks"));
assert!(!n.contains("Local State"));
}
// Verify file present
assert!(enc.join(VERIFY_FILE_NAME).exists());
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
assert_eq!(mtimes.len(), 3);
assert_eq!(
std::fs::read(restored.join("Default/Cookies")).unwrap(),
b"sqlite-data"
);
assert_eq!(
std::fs::read(restored.join("Default/Bookmarks")).unwrap(),
b"{\"x\":1}"
);
assert_eq!(
std::fs::read(restored.join("Local State")).unwrap(),
b"state"
);
}
#[test]
fn test_dir_excludes() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(plain.join("Default/Cache")).unwrap();
std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap();
std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap();
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
// Only Cookies (1 file) should be present, not Cache contents
assert_eq!(mtimes.len(), 1);
assert!(mtimes.contains_key("Default/Cookies"));
assert!(restored.join("Default/Cookies").exists());
assert!(!restored.join("Default/Cache/data").exists());
}
#[test]
fn test_verify_against_wrong_key() {
let key1 = make_key();
let key2 = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("file"), b"data").unwrap();
encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap();
assert!(verify_key_against_dir(&key1, &enc).is_ok());
assert!(matches!(
verify_key_against_dir(&key2, &enc),
Err(PasswordError::WrongPassword)
));
}
#[test]
fn test_reencrypt_skips_unchanged() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("a"), b"AAA").unwrap();
std::fs::write(plain.join("b"), b"BBB").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
// Capture pre-rewrite ciphertext bytes
let name_a = hmac_filename(&key, "a");
let name_b = hmac_filename(&key, "b");
let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap();
let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap();
// Modify only "a" in the restored tree
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap();
let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
assert_eq!(rewrote, 1);
let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap();
let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap();
assert_ne!(
cipher_a_before, cipher_a_after,
"changed file should have new ciphertext"
);
assert_eq!(
cipher_b_before, cipher_b_after,
"unchanged file should have stable ciphertext"
);
}
#[test]
fn test_reencrypt_handles_added_and_removed() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("keep"), b"k").unwrap();
std::fs::write(plain.join("delete"), b"d").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
std::fs::remove_file(restored.join("delete")).unwrap();
std::fs::write(restored.join("new"), b"n").unwrap();
reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
let names: HashSet<String> = std::fs::read_dir(&enc)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&hmac_filename(&key, "keep")));
assert!(names.contains(&hmac_filename(&key, "new")));
assert!(!names.contains(&hmac_filename(&key, "delete")));
assert!(names.contains(VERIFY_FILE_NAME));
}
#[test]
fn test_rekey_changes_filenames_and_content() {
let old = make_key();
let new = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("x"), b"data").unwrap();
encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap();
let old_name = hmac_filename(&old, "x");
let new_name = hmac_filename(&new, "x");
assert_ne!(old_name, new_name);
rekey_profile_dir(&old, &new, &enc).unwrap();
assert!(!enc.join(&old_name).exists());
assert!(enc.join(&new_name).exists());
verify_key_against_dir(&new, &enc).unwrap();
assert!(matches!(
verify_key_against_dir(&old, &enc),
Err(PasswordError::WrongPassword)
));
let restored = work.path().join("restored");
decrypt_profile_dir(&new, &enc, &restored).unwrap();
assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data");
}
#[test]
fn test_atomic_write_leaves_original_intact_if_tmp_lingers() {
let work = TempDir::new().unwrap();
let target = work.path().join("file");
std::fs::write(&target, b"original").unwrap();
// Simulate a stale tmp from a crashed write
std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap();
// A successful write should overwrite the original even when stale tmp exists
atomic_write(&target, b"new").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"new");
}
#[test]
fn test_key_cache_lifecycle() {
let id = uuid::Uuid::new_v4();
assert!(!has_cached_key(&id));
cache_key(id, [9u8; 32]);
assert!(has_cached_key(&id));
assert_eq!(get_cached_key(&id), Some([9u8; 32]));
drop_cached_key(&id);
assert!(!has_cached_key(&id));
}
#[test]
fn test_unlock_helper() {
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("x"), b"data").unwrap();
let salt = generate_salt();
let key = derive_profile_key("correct horse", &salt).unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let id = uuid::Uuid::new_v4();
drop_cached_key(&id);
assert!(unlock(id, "wrong", &salt, &enc).is_err());
assert!(!has_cached_key(&id));
assert!(unlock(id, "correct horse", &salt, &enc).is_ok());
assert!(has_cached_key(&id));
drop_cached_key(&id);
}
}
+204 -14
View File
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url;
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
});
{
let mut f = fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)
}
pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
@@ -184,6 +198,8 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -285,6 +301,8 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -340,6 +358,13 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
};
// Save profile info
@@ -385,7 +410,7 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?;
let json = serde_json::to_string_pretty(profile)?;
fs::write(profile_file, json)?;
atomic_write(&profile_file, json.as_bytes())?;
// Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -410,8 +435,26 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
let content = match fs::read_to_string(&metadata_file) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Skipping profile at {}: failed to read metadata.json: {e}",
path.display()
);
continue;
}
};
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!(
"Skipping profile at {}: invalid metadata.json: {e}",
path.display()
);
continue;
}
};
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
@@ -420,7 +463,7 @@ impl ProfileManager {
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
let _ = atomic_write(&metadata_file, json.as_bytes());
}
}
}
@@ -462,6 +505,8 @@ impl ProfileManager {
// Save profile with new name
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -642,7 +687,7 @@ impl ProfileManager {
pub fn assign_profiles_to_group(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_ids: Vec<String>,
group_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
@@ -667,14 +712,14 @@ impl ProfileManager {
profile.group_id = group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id {
let group_id_clone = new_group_id.clone();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
let _ =
crate::sync::enable_group_sync_if_needed(&group_id_clone, &app_handle_clone).await;
let _ = crate::sync::enable_group_sync_if_needed(&group_id_clone).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_group_sync(group_id_clone).await;
}
@@ -723,6 +768,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -757,6 +804,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -783,6 +832,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -812,6 +863,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -836,6 +889,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -987,6 +1042,13 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: source.dns_blocklist,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
};
self.save_profile(&new_profile)?;
@@ -1044,6 +1106,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1104,6 +1168,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1120,7 +1186,7 @@ impl ProfileManager {
pub async fn update_profile_proxy(
&self,
app_handle: tauri::AppHandle,
_app_handle: tauri::AppHandle,
profile_id: &str,
proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
@@ -1158,10 +1224,12 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id, &app_handle).await;
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_proxy_sync(new_proxy_id.clone()).await;
}
@@ -1238,7 +1306,7 @@ impl ProfileManager {
})?;
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id;
profile.vpn_id = vpn_id.clone();
profile.proxy_id = None;
self
@@ -1247,6 +1315,18 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
let _ = crate::sync::enable_vpn_sync_if_needed(new_vpn_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_vpn_sync(new_vpn_id.clone()).await;
}
}
}
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1271,9 +1351,25 @@ impl ProfileManager {
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id;
profile.extension_group_id = extension_group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller.
if profile.is_sync_enabled() {
if let Some(new_group_id) = extension_group_id {
tauri::async_runtime::spawn(async move {
let _ = crate::sync::enable_extension_group_sync_if_needed(&new_group_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_extension_group_sync(new_group_id).await;
}
});
}
}
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Failed to emit profile update event: {e}");
}
@@ -1413,13 +1509,18 @@ impl ProfileManager {
};
let mut merged = latest_profile.clone();
let mut detected_stop = false;
if let Some(pid) = found_pid {
if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}");
}
if let Some(prev) = old_pid {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, pid);
}
}
} else if merged.process_id.is_some() {
// Clear the PID if no process found
@@ -1427,6 +1528,15 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}");
}
detected_stop = true;
}
if detected_stop {
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(&app_handle, &merged)
{
merged = updated;
}
}
// Emit profile update event to frontend
@@ -1441,7 +1551,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager
async fn check_camoufox_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager;
@@ -1470,10 +1580,14 @@ impl ProfileManager {
};
if latest.process_id != camoufox_process.processId {
let old_pid = latest.process_id;
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, camoufox_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1515,6 +1629,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1551,6 +1671,12 @@ impl ProfileManager {
);
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
// Emit profile update event to frontend
if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1565,7 +1691,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager
async fn check_wayfern_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager;
@@ -1594,10 +1720,14 @@ impl ProfileManager {
};
if latest.process_id != wayfern_process.processId {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, wayfern_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1639,6 +1769,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -2056,6 +2192,38 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("http or https"));
}
#[test]
fn test_validate_launch_hook_accepts_https_url() {
let result = super::validate_launch_hook(Some("https://example.com/track")).unwrap();
assert_eq!(result.as_deref(), Some("https://example.com/track"));
}
#[test]
fn test_validate_launch_hook_rejects_garbage_with_code() {
let err = super::validate_launch_hook(Some("not a url")).unwrap_err();
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
}
#[test]
fn test_validate_launch_hook_rejects_non_http_scheme_with_code() {
let err = super::validate_launch_hook(Some("ftp://example.com/hook")).unwrap_err();
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
}
#[test]
fn test_validate_launch_hook_empty_clears_hook() {
let result = super::validate_launch_hook(Some("")).unwrap();
assert!(result.is_none());
let result_ws = super::validate_launch_hook(Some(" ")).unwrap();
assert!(result_ws.is_none());
let result_none = super::validate_launch_hook(None).unwrap();
assert!(result_none.is_none());
}
}
#[allow(clippy::too_many_arguments)]
@@ -2154,12 +2322,34 @@ pub fn update_profile_note(
.map_err(|e| format!("Failed to update profile note: {e}"))
}
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
pub(crate) fn validate_launch_hook(launch_hook: Option<&str>) -> Result<Option<String>, String> {
let Some(raw) = launch_hook else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
let ok = url::Url::parse(trimmed)
.ok()
.map(|u| matches!(u.scheme(), "http" | "https"))
.unwrap_or(false);
if !ok {
return Err(serde_json::json!({ "code": "INVALID_LAUNCH_HOOK_URL" }).to_string());
}
Ok(Some(trimmed.to_string()))
}
#[tauri::command]
pub fn update_profile_launch_hook(
app_handle: tauri::AppHandle,
profile_id: String,
launch_hook: Option<String>,
) -> Result<BrowserProfile, String> {
validate_launch_hook(launch_hook.as_deref())?;
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
+2
View File
@@ -1,4 +1,6 @@
pub mod encryption;
pub mod manager;
pub mod password;
pub mod types;
pub use manager::ProfileManager;
File diff suppressed because it is too large Load Diff
+9
View File
@@ -69,6 +69,15 @@ pub struct BrowserProfile {
pub created_by_email: Option<String>,
#[serde(default)]
pub dns_blocklist: Option<String>,
/// True when the on-disk profile dir is encrypted with a per-profile password.
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)]
pub password_protected: bool,
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
/// profiles that pre-date this field — those are treated as ancient by
/// any staleness check.
#[serde(default)]
pub created_at: Option<u64>,
}
pub fn default_release_type() -> String {
+11
View File
@@ -584,6 +584,8 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -664,6 +666,8 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
};
match self
@@ -715,6 +719,13 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
),
};
self.profile_manager.save_profile(&profile)?;
+87 -415
View File
@@ -174,6 +174,10 @@ pub struct ProxyManager {
// Track active proxy IDs by profile name for targeted cleanup
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
// Consecutive cleanup passes during which a browser PID looked dead.
// We only reap a worker after it has been missed in N consecutive scans —
// a single sysinfo blip under load shouldn't kill a still-running worker.
dead_browser_misses: Mutex<HashMap<u32, u8>>,
}
impl ProxyManager {
@@ -183,6 +187,7 @@ impl ProxyManager {
profile_proxies: Mutex::new(HashMap::new()),
profile_active_proxy_ids: Mutex::new(HashMap::new()),
stored_proxies: Mutex::new(HashMap::new()),
dead_browser_misses: Mutex::new(HashMap::new()),
};
// Load stored proxies on initialization
@@ -825,6 +830,42 @@ impl ProxyManager {
Ok(updated_proxy)
}
/// Update the in-memory `sync_enabled` / `last_sync` fields of a stored
/// proxy and persist the change to disk. Returns the updated proxy or
/// `Err` if the proxy isn't found / is cloud-managed.
///
/// This is the canonical write path for sync-state changes — direct
/// `fs::write` from a sync command would leave the in-memory cache
/// (`stored_proxies`) stale, and the next `get_stored_proxies()` would
/// return the old `sync_enabled`, breaking the UI toggle.
pub fn set_stored_proxy_sync_state(
&self,
proxy_id: &str,
sync_enabled: bool,
last_sync: Option<u64>,
) -> Result<StoredProxy, String> {
let updated_proxy = {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
let proxy = stored_proxies
.get_mut(proxy_id)
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
if proxy.is_cloud_managed {
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
}
proxy.sync_enabled = sync_enabled;
proxy.last_sync = last_sync;
proxy.clone()
};
self
.save_proxy(&updated_proxy)
.map_err(|e| format!("Failed to save proxy: {e}"))?;
Ok(updated_proxy)
}
// Delete a stored proxy
pub fn delete_stored_proxy(
&self,
@@ -1074,149 +1115,6 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
pub async fn fetch_proxy_from_url(
&self,
url: &str,
timeout: std::time::Duration,
) -> Result<Option<ProxySettings>, String> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!("Launch hook returned status {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
let body = body.trim();
if body.is_empty() {
return Err("Launch hook returned empty response".to_string());
}
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
return Ok(Some(settings));
}
match Self::parse_dynamic_proxy_text(body) {
Ok(settings) => Ok(Some(settings)),
Err(text_error) => Err(format!(
"Failed to parse launch hook response: {text_error}"
)),
}
}
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
let obj = json
.as_object()
.ok_or_else(|| "JSON response is not an object".to_string())?;
let raw_host = obj
.get("ip")
.or_else(|| obj.get("host"))
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
// and extract the proxy type from it if no explicit type field is provided
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
(rest.to_string(), None)
} else if let Some((proto, rest)) = raw_host.split_once("://") {
(rest.to_string(), Some(proto.to_lowercase()))
} else {
(raw_host.to_string(), None)
};
let port = obj
.get("port")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
as u16;
let proxy_type = obj
.get("type")
.or_else(|| obj.get("proxy_type"))
.or_else(|| obj.get("protocol"))
.and_then(|v| v.as_str())
.map(|s| s.to_lowercase())
.or(protocol_from_host)
.unwrap_or_else(|| "http".to_string());
let username = obj
.get("username")
.or_else(|| obj.get("user"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let password = obj
.get("password")
.or_else(|| obj.get("pass"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Ok(ProxySettings {
proxy_type,
host,
port,
username,
password,
})
}
// Parse plain text proxy payload using the same logic as proxy import
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
let line = body
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if line.is_empty() {
return Err("Empty text response".to_string());
}
match Self::parse_single_proxy_line(line) {
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
}),
ProxyParseResult::Ambiguous {
possible_formats, ..
} => Err(format!(
"Ambiguous proxy format. Could be: {}",
possible_formats.join(" or ")
)),
ProxyParseResult::Invalid { reason, .. } => {
Err(format!("Failed to parse proxy response: {reason}"))
}
}
}
// Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2095,17 +1993,52 @@ impl ProxyManager {
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
);
let dead_browser_entries: Vec<(u32, String, Option<String>)> = snapshot
.into_iter()
.filter(|(browser_pid, _, _)| {
// The sentinel PID=0 is used as a placeholder during launch,
// before update_proxy_pid has recorded the real browser PID.
*browser_pid != 0
&& system
.process(sysinfo::Pid::from_u32(*browser_pid))
.is_none()
})
.collect();
// Two-state classification: alive PIDs reset their miss counter,
// dead PIDs increment it. A worker is only reaped after MISS_THRESHOLD
// consecutive misses (~60s by default given the 30s cleanup cadence),
// so a single sysinfo blip under heavy load doesn't kill a healthy worker.
const MISS_THRESHOLD: u8 = 2;
let mut alive_pids: Vec<u32> = Vec::new();
let mut dead_candidates: Vec<(u32, String, Option<String>)> = Vec::new();
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
for (browser_pid, proxy_id, profile_id) in snapshot {
snapshot_pids.insert(browser_pid);
// The sentinel PID=0 is used as a placeholder during launch,
// before update_proxy_pid has recorded the real browser PID.
if browser_pid == 0 {
continue;
}
if system
.process(sysinfo::Pid::from_u32(browser_pid))
.is_some()
{
alive_pids.push(browser_pid);
} else {
dead_candidates.push((browser_pid, proxy_id, profile_id));
}
}
let dead_browser_entries: Vec<(u32, String, Option<String>)> = {
let mut misses = self.dead_browser_misses.lock().unwrap();
// Forget PIDs no longer tracked at all (worker already torn down elsewhere).
misses.retain(|pid, _| snapshot_pids.contains(pid));
// Reset miss count for any PID that's currently alive.
for pid in &alive_pids {
misses.remove(pid);
}
// Increment dead candidates and select those past threshold.
let mut to_reap = Vec::new();
for (browser_pid, proxy_id, profile_id) in dead_candidates {
let count = misses.entry(browser_pid).or_insert(0);
*count = count.saturating_add(1);
if *count >= MISS_THRESHOLD {
misses.remove(&browser_pid);
to_reap.push((browser_pid, proxy_id, profile_id));
}
}
to_reap
};
for (browser_pid, proxy_id, profile_id) in dead_browser_entries {
log::info!(
@@ -2241,8 +2174,6 @@ mod tests {
use hyper::Response;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Helper function to build donut-proxy binary for testing
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -3511,263 +3442,4 @@ mod tests {
delete_proxy_config(&id);
}
#[test]
fn test_parse_dynamic_proxy_json_standard_format() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 8080);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_json_host_alias() {
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.username.as_deref(), Some("u"));
assert_eq!(result.password.as_deref(), Some("p"));
}
#[test]
fn test_parse_dynamic_proxy_json_port_as_string() {
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.port, 9090);
}
#[test]
fn test_parse_dynamic_proxy_json_with_proxy_type() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "socks4");
// "protocol" field alias
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_normalizes_case() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "http");
}
#[test]
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.port, 1080);
// Protocol in host should be used as proxy_type when no explicit type field
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.host, "10.0.0.1");
assert_eq!(result2.proxy_type, "http");
// Explicit type field takes precedence over protocol in host
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.host, "10.0.0.1");
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_missing_ip() {
let body = r#"{"port": 8080}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("ip") || err.contains("host"));
}
#[test]
fn test_parse_dynamic_proxy_json_missing_port() {
let body = r#"{"ip": "1.2.3.4"}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn test_parse_dynamic_proxy_json_invalid_json() {
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
assert!(err.contains("Invalid JSON"));
}
#[test]
fn test_parse_dynamic_proxy_json_not_object() {
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
assert!(err.contains("not an object"));
}
#[test]
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
let body = "proxy.example.com:8080:user1:pass1";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_text_protocol_url_format() {
let body = "http://user:pass@proxy.example.com:3128";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_dynamic_proxy_text_with_whitespace() {
let body = " \n proxy.example.com:8080:user:pass \n ";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
}
#[test]
fn test_parse_dynamic_proxy_text_empty() {
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_parse_dynamic_proxy_text_whitespace_only() {
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
assert!(err.contains("Empty"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_json_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_text_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 1080);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_fetch_proxy_from_url_respects_timeout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let err = pm
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
.await
.unwrap_err();
assert!(err.contains("Failed to fetch launch hook"));
}
}
+102 -88
View File
@@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpListener;
use tokio::net::TcpStream;
/// Combined read+write trait for tunnel target streams, allowing
@@ -919,8 +918,8 @@ async fn handle_http(
return Ok(response);
}
log::error!(
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
log::trace!(
"Handling HTTP request: {} {} (host: {:?})",
req.method(),
req.uri(),
req.uri().host()
@@ -1183,7 +1182,7 @@ pub async fn handle_proxy_connection(
}
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
log::error!(
log::info!(
"Proxy worker starting, looking for config id: {}",
config.id
);
@@ -1197,7 +1196,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}
};
log::error!(
log::info!(
"Found config: id={}, port={:?}, upstream={}, profile_id={:?}",
config.id,
config.local_port,
@@ -1205,49 +1204,67 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
config.profile_id
);
log::error!("Starting proxy server for config id: {}", config.id);
// Initialize traffic tracker with profile ID if available
// This can now be called multiple times to update the tracker
// Initialize traffic tracker with profile ID if available.
// This can be called multiple times to update the tracker.
init_traffic_tracker(config.id.clone(), config.profile_id.clone());
log::error!(
"Traffic tracker initialized for proxy: {} (profile_id: {:?})",
config.id,
config.profile_id
);
// Verify tracker was initialized correctly
if let Some(tracker) = crate::traffic_stats::get_traffic_tracker() {
log::error!(
"Tracker verified: proxy_id={}, profile_id={:?}",
tracker.proxy_id,
tracker.profile_id
);
} else {
log::error!("WARNING: Tracker was not initialized!");
}
// Determine the bind address
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0)));
log::error!("Attempting to bind proxy server to {}", bind_addr);
log::info!("Attempting to bind proxy server to {}", bind_addr);
// Bind to the port
let listener = TcpListener::bind(bind_addr).await?;
// Bind to the port. Use SO_REUSEADDR so that a freshly-restarted worker
// can bind a port that the previous worker left in TIME_WAIT, and retry
// briefly to absorb transient races with the OS releasing the socket.
let listener = {
let mut attempts: u32 = 0;
loop {
let socket = tokio::net::TcpSocket::new_v4()?;
let _ = socket.set_reuseaddr(true);
match socket.bind(bind_addr) {
Ok(()) => match socket.listen(1024) {
Ok(l) => break l,
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"listen() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(e) => {
return Err(format!("Failed to listen on {bind_addr} after 5 attempts: {e}").into())
}
},
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"bind() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(e) => return Err(format!("Failed to bind {bind_addr} after 5 attempts: {e}").into()),
}
}
};
let actual_port = listener.local_addr()?.port();
log::error!("Successfully bound to port {}", actual_port);
log::info!("Successfully bound to port {}", actual_port);
// Update config with actual port and local_url
let mut updated_config = config.clone();
updated_config.local_port = Some(actual_port);
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
// Save the updated config
log::error!(
"Saving updated config with local_url={:?}",
updated_config.local_url
);
if !crate::proxy_storage::update_proxy_config(&updated_config) {
log::error!("Failed to update proxy config");
return Err("Failed to update proxy config".into());
@@ -1259,12 +1276,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
Some(updated_config.upstream_url.clone())
};
log::error!("Proxy server bound to 127.0.0.1:{}", actual_port);
log::error!(
log::info!(
"Proxy server listening on 127.0.0.1:{} (ready to accept connections)",
actual_port
);
log::error!("Proxy server entering accept loop - process should stay alive");
log::info!("Proxy server entering accept loop - process should stay alive");
// Start a background task to write lightweight session snapshots for real-time updates
// These are much smaller than full stats and can be written frequently (~100 bytes every 2 seconds)
@@ -1295,52 +1311,54 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
loop {
interval.tick().await;
if let Some(tracker) = get_traffic_tracker() {
let (sent, recv, requests) = tracker.get_snapshot();
let current_bytes = sent + recv;
let time_since_activity = last_activity_time.elapsed();
let time_since_flush = last_flush_time.elapsed();
let has_traffic = current_bytes > 0 || requests > 0;
// Catch panics so a poisoned lock or unexpected error inside
// flush_to_disk doesn't abort the flush task and leave stats
// unwritten for the lifetime of the worker. The captured state
// is all Copy or atomic-assignment, so AssertUnwindSafe is sound.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if let Some(tracker) = get_traffic_tracker() {
let (sent, recv, requests) = tracker.get_snapshot();
let current_bytes = sent + recv;
let time_since_activity = last_activity_time.elapsed();
let time_since_flush = last_flush_time.elapsed();
let has_traffic = current_bytes > 0 || requests > 0;
// Determine flush frequency based on activity
// When active: flush every 5 seconds
// When idle: flush every 30 seconds
let desired_interval_secs =
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
5u64
} else {
30u64
};
let desired_interval_secs =
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
5u64
} else {
30u64
};
// Update interval if needed
if desired_interval_secs != current_interval_secs {
current_interval_secs = desired_interval_secs;
interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
}
if desired_interval_secs != current_interval_secs {
current_interval_secs = desired_interval_secs;
interval =
tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
}
// Only flush if enough time has passed since last flush
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
let should_flush = time_since_flush >= flush_interval;
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
let should_flush = time_since_flush >= flush_interval;
if should_flush {
match tracker.flush_to_disk() {
Ok(Some((sent, recv))) => {
// Successful flush with data
last_flush_time = std::time::Instant::now();
if sent > 0 || recv > 0 {
last_activity_time = std::time::Instant::now();
if should_flush {
match tracker.flush_to_disk() {
Ok(Some((sent, recv))) => {
last_flush_time = std::time::Instant::now();
if sent > 0 || recv > 0 {
last_activity_time = std::time::Instant::now();
}
}
Ok(None) => {
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
}
}
Ok(None) => {
// No data to flush - this is normal
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
// Don't update flush time on error - retry sooner
}
}
}
}));
if let Err(panic) = result {
log::error!("Panic caught in proxy traffic flush task; continuing: {panic:?}");
}
}
});
@@ -1581,7 +1599,7 @@ async fn handle_connect_from_buffer(
.await?;
client_stream.flush().await?;
log::error!("DEBUG: Sent 200 Connection Established response, starting tunnel");
log::trace!("Sent 200 Connection Established response, starting tunnel");
// Now tunnel data bidirectionally with counting
// Wrap streams to count bytes transferred
@@ -1598,17 +1616,17 @@ async fn handle_connect_from_buffer(
let (mut client_read, mut client_write) = tokio::io::split(counting_client);
let (mut target_read, mut target_write) = tokio::io::split(counting_target);
log::error!("DEBUG: Starting bidirectional tunnel");
log::trace!("Starting bidirectional tunnel");
// Spawn two tasks to forward data in both directions
let client_to_target = tokio::spawn(async move {
let result = tokio::io::copy(&mut client_read, &mut target_write).await;
match result {
Ok(bytes) => {
log::error!("DEBUG: Tunneled {} bytes from client->target", bytes);
log::trace!("Tunneled {bytes} bytes from client->target");
}
Err(e) => {
log::error!("Error forwarding client->target: {:?}", e);
log::debug!("Error forwarding client->target: {e:?}");
}
}
});
@@ -1617,10 +1635,10 @@ async fn handle_connect_from_buffer(
let result = tokio::io::copy(&mut target_read, &mut client_write).await;
match result {
Ok(bytes) => {
log::error!("DEBUG: Tunneled {} bytes from target->client", bytes);
log::trace!("Tunneled {bytes} bytes from target->client");
}
Err(e) => {
log::error!("Error forwarding target->client: {:?}", e);
log::debug!("Error forwarding target->client: {e:?}");
}
}
});
@@ -1628,10 +1646,10 @@ async fn handle_connect_from_buffer(
// Wait for either direction to finish (connection closed)
tokio::select! {
_ = client_to_target => {
log::error!("DEBUG: Client->target tunnel closed");
log::trace!("Client->target tunnel closed");
}
_ = target_to_client => {
log::error!("DEBUG: Target->client tunnel closed");
log::trace!("Target->client tunnel closed");
}
}
@@ -1640,11 +1658,7 @@ async fn handle_connect_from_buffer(
client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed);
let final_recv =
target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed);
log::error!(
"DEBUG: Tunnel closed - sent: {} bytes, received: {} bytes",
final_sent,
final_recv
);
log::trace!("Tunnel closed - sent: {final_sent} bytes, received: {final_recv} bytes");
// Update domain-specific byte counts now that tunnel is complete
if let Some(tracker) = get_traffic_tracker() {
+117
View File
@@ -57,6 +57,11 @@ pub struct AppSettings {
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub disable_auto_updates: bool,
/// When true, the decrypted in-RAM copy of a password-protected profile is
/// preserved between launches for faster subsequent startups. The on-disk
/// copy is always re-encrypted regardless of this flag.
#[serde(default)]
pub keep_decrypted_profiles_in_ram: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -92,6 +97,7 @@ impl Default for AppSettings {
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
}
}
}
@@ -814,6 +820,105 @@ pub async fn save_app_settings(
Ok(settings)
}
/// Read the most recent N log files concatenated into a single string,
/// suitable for paste-into-issue-tracker. Newest entries appear LAST so the
/// reader sees fresh context at the bottom of the buffer. Capped at 5 MB to
/// keep clipboard payloads sane.
#[tauri::command]
pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, String> {
let dir = crate::app_dirs::log_dir(&app_handle);
if !dir.exists() {
return Err("Log directory does not exist yet".to_string());
}
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
.map_err(|e| format!("Failed to read log dir: {e}"))?
.filter_map(|r| r.ok())
.filter_map(|e| {
let p = e.path();
let m = e.metadata().ok()?.modified().ok()?;
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
if p.is_file() && (ext == "log" || ext == "txt") {
Some((p, m))
} else {
None
}
})
.collect();
entries.sort_by_key(|(_, m)| *m);
const MAX_BYTES: usize = 5 * 1024 * 1024;
let mut out = String::with_capacity(64 * 1024);
for (path, _) in entries.iter().rev() {
let header = format!("===== {} =====\n", path.display());
if out.len() + header.len() >= MAX_BYTES {
break;
}
out.push_str(&header);
if let Ok(content) = std::fs::read_to_string(path) {
let take = MAX_BYTES.saturating_sub(out.len());
if take == 0 {
break;
}
if content.len() > take {
// Tail truncation — keep the END of older files so newest data is preserved.
out.push_str("[…truncated — older content elided…]\n");
out.push_str(&content[content.len() - take + 64..]);
} else {
out.push_str(&content);
}
if !out.ends_with('\n') {
out.push('\n');
}
}
}
// Reverse the per-file order so chronological newest is at the bottom.
// (We pushed newest-first above to budget the tail; flip now.)
let mut sections: Vec<&str> = out.split("===== ").filter(|s| !s.is_empty()).collect();
sections.reverse();
let final_out = sections
.into_iter()
.map(|s| format!("===== {s}"))
.collect::<String>();
Ok(final_out)
}
/// Reveal the log directory in the OS file manager.
#[tauri::command]
pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), String> {
let dir = crate::app_dirs::log_dir(&app_handle);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create log dir: {e}"))?;
}
let path = dir.to_string_lossy().to_string();
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
Ok(())
}
#[tauri::command]
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
let manager = SettingsManager::instance();
@@ -886,6 +991,17 @@ pub async fn save_sync_settings(
sync_server_url: Option<String>,
sync_token: Option<String>,
) -> Result<SyncSettings, String> {
// Cloud login and self-hosted sync share the same sync engine and a
// profile can't be sync'd to two backends at once. Block any *write*
// (non-null URL or token) while the user is signed into their cloud
// account — the clearing path (both `None`) is always allowed so logged-
// in users can wipe a stale self-hosted config that pre-dates their
// sign-in.
let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some();
if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string());
}
let manager = SettingsManager::instance();
manager
@@ -1070,6 +1186,7 @@ mod tests {
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
};
let save_result = manager.save_settings(&test_settings);
+135 -3
View File
@@ -4,10 +4,40 @@ use aes_gcm::{
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use std::collections::HashMap;
use std::sync::Mutex;
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
const E2E_FILE_VERSION: u8 = 1;
/// Argon2id is intentionally expensive (~80150 ms per call). During an
/// encryption rollover, every synced entity (proxy, group, vpn, extension,
/// extension group, profile metadata) goes through `derive_profile_key`,
/// which without caching means hundreds of sequential 100 ms derivations.
///
/// Cache the derived key keyed on (sha256(password), salt). Entries are
/// evicted on `set_e2e_password` / `delete_e2e_password` so a password
/// change cannot use stale keys.
type DerivedKeyCache = HashMap<([u8; 32], String), [u8; 32]>;
static KEY_CACHE: std::sync::LazyLock<Mutex<DerivedKeyCache>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
fn password_fingerprint(pwd: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(pwd.as_bytes());
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
fn invalidate_key_cache() {
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.clear();
}
}
fn get_e2e_password_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("e2e_password.dat")
}
@@ -17,6 +47,7 @@ fn get_vault_password() -> String {
}
pub fn store_e2e_password(password: &str) -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path();
if let Some(parent) = file_path.parent() {
@@ -149,6 +180,7 @@ pub fn has_e2e_password() -> bool {
}
pub fn remove_e2e_password() -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path();
if file_path.exists() {
std::fs::remove_file(&file_path)
@@ -157,8 +189,20 @@ pub fn remove_e2e_password() -> Result<(), String> {
Ok(())
}
/// Derive a per-profile encryption key using Argon2id
/// Derive a per-profile encryption key using Argon2id, with an in-process
/// cache keyed on `(sha256(password), salt)`. Repeated calls with the same
/// password+salt are O(1); a password change calls `invalidate_key_cache`
/// to drop stale entries.
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
let pwd_fp = password_fingerprint(user_password);
let cache_key = (pwd_fp, profile_salt.to_string());
if let Ok(cache) = KEY_CACHE.lock() {
if let Some(cached) = cache.get(&cache_key) {
return Ok(*cached);
}
}
let salt_bytes = BASE64
.decode(profile_salt)
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
@@ -175,6 +219,11 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8
let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes[..32]);
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.insert(cache_key, key);
}
Ok(key)
}
@@ -220,13 +269,75 @@ pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String
.map_err(|e| format!("Decryption failed: {e}"))
}
/// Versioned encryption envelope used for non-profile entities (proxies,
/// VPNs, groups, extensions, extension groups). Each upload has its own
/// random per-entity salt so the bucket can't be rainbow-table-attacked
/// even with a shared password across many entities.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct EncryptedEnvelope {
/// Format version. Increment when changing how `ct` is structured.
pub v: u32,
/// Base64 of the per-entity salt. Plaintext on the wire — salts are public.
pub salt: String,
/// Base64 of `nonce(12B) || AES-256-GCM ciphertext` (output of `encrypt_bytes`).
pub ct: String,
}
/// Wrap a plaintext JSON byte slice into an encrypted envelope if the user
/// has E2E enabled. Returns `(payload_bytes, content_type)` ready to upload.
/// On no-password, returns the original JSON unchanged.
pub fn maybe_seal_for_upload(json: &[u8]) -> Result<(Vec<u8>, &'static str), String> {
let pwd = match load_e2e_password()? {
Some(p) => p,
None => return Ok((json.to_vec(), "application/json")),
};
let salt = generate_salt();
let key = derive_profile_key(&pwd, &salt)?;
let ct = encrypt_bytes(&key, json)?;
let envelope = EncryptedEnvelope {
v: 1,
salt,
ct: BASE64.encode(&ct),
};
let payload =
serde_json::to_vec(&envelope).map_err(|e| format!("Failed to serialize envelope: {e}"))?;
Ok((payload, "application/json"))
}
/// Reverse of `maybe_seal_for_upload`. Returns the inner plaintext JSON
/// bytes regardless of whether `raw` was an envelope or legacy plaintext.
///
/// Distinguishes three cases:
/// - `raw` is plaintext JSON, no password set → returns `raw` unchanged.
/// - `raw` is an envelope, password set → decrypts and returns plaintext.
/// - `raw` is an envelope, no password set → returns `Err(EncryptedEnvelope)`
/// so callers (subscription / startup probe) can show "enter password to
/// continue syncing" UI.
pub fn maybe_unseal_after_download(raw: &[u8]) -> Result<Vec<u8>, String> {
// Try parsing as envelope first; envelopes are JSON objects with a "v" field.
if let Ok(env) = serde_json::from_slice::<EncryptedEnvelope>(raw) {
if env.v != 1 {
return Err(format!("Unsupported envelope version: {}", env.v));
}
let pwd = load_e2e_password()?.ok_or_else(|| "ENCRYPTION_PASSWORD_REQUIRED".to_string())?;
let key = derive_profile_key(&pwd, &env.salt)?;
let ct = BASE64
.decode(&env.ct)
.map_err(|e| format!("Invalid envelope ciphertext: {e}"))?;
return decrypt_bytes(&key, &ct);
}
// Not an envelope — legacy plaintext. Caller will JSON-parse it directly.
Ok(raw.to_vec())
}
// Tauri commands
#[tauri::command]
pub fn set_e2e_password(password: String) -> Result<(), String> {
pub async fn set_e2e_password(password: String) -> Result<(), String> {
if password.len() < 8 {
return Err("Password must be at least 8 characters".to_string());
}
enforce_team_owner_for_encryption_change().await?;
store_e2e_password(&password)
}
@@ -236,10 +347,31 @@ pub fn check_has_e2e_password() -> bool {
}
#[tauri::command]
pub fn delete_e2e_password() -> Result<(), String> {
pub fn verify_e2e_password(password: String) -> Result<bool, String> {
match load_e2e_password()? {
Some(stored) => Ok(stored == password),
None => Err(serde_json::json!({ "code": "NO_E2E_PASSWORD_SET" }).to_string()),
}
}
#[tauri::command]
pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?;
remove_e2e_password()
}
/// On Team plans, only the team owner is allowed to flip the E2E password
/// state — otherwise members could lock each other out by changing the key.
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
use crate::cloud_auth::CLOUD_AUTH;
if let Some(state) = CLOUD_AUTH.get_user().await {
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
return Err("TEAM_OWNER_ONLY".to_string());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+370 -63
View File
@@ -716,7 +716,9 @@ impl SyncEngine {
}
let presign = self.client.presign_download(key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal profile metadata: {e}")))?;
let profile: BrowserProfile = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
@@ -794,15 +796,18 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&sanitized)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
Ok(())
@@ -1392,17 +1397,20 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
let remote_key = format!("proxies/{}.json", proxy.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local proxy with new last_sync
// Update local proxy with new last_sync (always write plaintext locally)
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
fs::write(&proxy_file, &json).map_err(|e| {
@@ -1423,7 +1431,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("proxies/{}.json", proxy_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal proxy: {e}")))?;
let mut proxy: crate::proxy_manager::StoredProxy = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse proxy JSON: {e}")))?;
@@ -1534,14 +1545,17 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_group)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
let remote_key = format!("groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local group with new last_sync
@@ -1563,7 +1577,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("groups/{}.json", group_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal group: {e}")))?;
let mut group: crate::group_manager::ProfileGroup = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse group JSON: {e}")))?;
@@ -1738,14 +1755,17 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
let remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local VPN with new last_sync
@@ -1767,7 +1787,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("vpns/{}.json", vpn_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal VPN: {e}")))?;
let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?;
@@ -1883,17 +1906,21 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_ext)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
let remote_key = format!("extensions/{}.json", ext.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(meta_content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
.await?;
// Also upload the extension file data
// Also upload the extension file data — encrypted as a sealed envelope
// when E2E is on (the binary is the secret here, not just the metadata).
let file_path = {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let file_dir = manager.get_file_dir_public(&ext.id);
@@ -1908,18 +1935,17 @@ impl SyncEngine {
))
})?;
let (file_payload, file_content_type) = encryption::maybe_seal_for_upload(&file_data)
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension file: {e}")))?;
let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name);
let file_presign = self
.client
.presign_upload(&file_remote_key, Some("application/octet-stream"))
.presign_upload(&file_remote_key, Some(file_content_type))
.await?;
self
.client
.upload_bytes(
&file_presign.url,
&file_data,
Some("application/octet-stream"),
)
.upload_bytes(&file_presign.url, &file_payload, Some(file_content_type))
.await?;
}
@@ -1942,7 +1968,9 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("extensions/{}.json", ext_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension: {e}")))?;
let mut ext: crate::extension_manager::Extension = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse extension JSON: {e}")))?;
@@ -1960,7 +1988,9 @@ impl SyncEngine {
let file_stat = self.client.stat(&file_remote_key).await?;
if file_stat.exists {
let file_presign = self.client.presign_download(&file_remote_key).await?;
let file_data = self.client.download_bytes(&file_presign.url).await?;
let file_raw = self.client.download_bytes(&file_presign.url).await?;
let file_data = encryption::maybe_unseal_after_download(&file_raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension file: {e}")))?;
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let file_dir = manager.get_file_dir_public(&ext.id);
@@ -2085,14 +2115,17 @@ impl SyncEngine {
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
})?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
let remote_key = format!("extension_groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local group with new last_sync
@@ -2114,7 +2147,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("extension_groups/{}.json", group_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension group: {e}")))?;
let mut group: crate::extension_manager::ExtensionGroup = serde_json::from_slice(&data)
.map_err(|e| {
@@ -2742,10 +2778,7 @@ pub fn is_group_used_by_synced_profile(group_id: &str) -> bool {
}
/// Enable sync for proxy if not already enabled
pub async fn enable_proxy_sync_if_needed(
proxy_id: &str,
_app_handle: &tauri::AppHandle,
) -> Result<(), String> {
pub async fn enable_proxy_sync_if_needed(proxy_id: &str) -> Result<(), String> {
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxies = proxy_manager.get_stored_proxies();
let proxy = proxies
@@ -2754,15 +2787,7 @@ pub async fn enable_proxy_sync_if_needed(
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
if !proxy.sync_enabled {
let mut updated_proxy = proxy.clone();
updated_proxy.sync_enabled = true;
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| format!("Failed to serialize proxy: {e}"))?;
std::fs::write(&proxy_file, &json)
.map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?;
proxy_manager.set_stored_proxy_sync_state(proxy_id, true, proxy.last_sync)?;
let _ = events::emit("stored-proxies-changed", ());
log::info!("Auto-enabled sync for proxy {}", proxy_id);
}
@@ -2783,10 +2808,7 @@ pub fn is_vpn_used_by_synced_profile(vpn_id: &str) -> bool {
}
/// Enable sync for VPN if not already enabled
pub async fn enable_vpn_sync_if_needed(
vpn_id: &str,
_app_handle: &tauri::AppHandle,
) -> Result<(), String> {
pub async fn enable_vpn_sync_if_needed(vpn_id: &str) -> Result<(), String> {
let vpn = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
@@ -2808,10 +2830,7 @@ pub async fn enable_vpn_sync_if_needed(
}
/// Enable sync for group if not already enabled
pub async fn enable_group_sync_if_needed(
group_id: &str,
_app_handle: &tauri::AppHandle,
) -> Result<(), String> {
pub async fn enable_group_sync_if_needed(group_id: &str) -> Result<(), String> {
let group = {
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
let groups = group_manager.get_all_groups().unwrap_or_default();
@@ -2840,6 +2859,66 @@ pub async fn enable_group_sync_if_needed(
Ok(())
}
/// Enable sync for extension group (and its member extensions) if not
/// already enabled. Mirrors the proxy/vpn/group helpers — call from any
/// site where a synced profile gains an `extension_group_id`.
pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) -> Result<(), String> {
let (group_already_synced, extension_ids) = {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = manager
.get_group(extension_group_id)
.map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))?;
(group.sync_enabled, group.extension_ids.clone())
};
if !group_already_synced {
let mut updated_group = {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_group(extension_group_id)
.map_err(|e| format!("Failed to load extension group: {e}"))?
};
updated_group.sync_enabled = true;
{
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.update_group_internal(&updated_group)
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
}
let _ = events::emit("extensions-changed", ());
log::info!(
"Auto-enabled sync for extension group {}",
extension_group_id
);
}
// Cascade to every extension referenced by the group so the other device
// has the actual extension binaries when it pulls the group.
for ext_id in extension_ids {
let already_synced = {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_extension(&ext_id)
.ok()
.map(|e| e.sync_enabled)
.unwrap_or(true)
};
if !already_synced {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
if let Ok(mut ext) = manager.get_extension(&ext_id) {
ext.sync_enabled = true;
if let Err(e) = manager.update_extension_internal(&ext) {
log::warn!("Failed to auto-enable sync for extension {}: {e}", ext_id);
} else {
log::info!("Auto-enabled sync for extension {}", ext_id);
}
}
}
}
Ok(())
}
#[tauri::command]
pub async fn set_profile_sync_mode(
app_handle: tauri::AppHandle,
@@ -2968,26 +3047,39 @@ pub async fn set_profile_sync_mode(
.await;
if let Some(ref proxy_id) = profile.proxy_id {
if let Err(e) = enable_proxy_sync_if_needed(proxy_id, &app_handle).await {
if let Err(e) = enable_proxy_sync_if_needed(proxy_id).await {
log::warn!("Failed to enable sync for proxy {}: {}", proxy_id, e);
} else {
scheduler.queue_proxy_sync(proxy_id.clone()).await;
}
}
if let Some(ref group_id) = profile.group_id {
if let Err(e) = enable_group_sync_if_needed(group_id, &app_handle).await {
if let Err(e) = enable_group_sync_if_needed(group_id).await {
log::warn!("Failed to enable sync for group {}: {}", group_id, e);
} else {
scheduler.queue_group_sync(group_id.clone()).await;
}
}
if let Some(ref vpn_id) = profile.vpn_id {
if let Err(e) = enable_vpn_sync_if_needed(vpn_id, &app_handle).await {
if let Err(e) = enable_vpn_sync_if_needed(vpn_id).await {
log::warn!("Failed to enable sync for VPN {}: {}", vpn_id, e);
} else {
scheduler.queue_vpn_sync(vpn_id.clone()).await;
}
}
if let Some(ref ext_group_id) = profile.extension_group_id {
if let Err(e) = enable_extension_group_sync_if_needed(ext_group_id).await {
log::warn!(
"Failed to enable sync for extension group {}: {}",
ext_group_id,
e
);
} else {
scheduler
.queue_extension_group_sync(ext_group_id.clone())
.await;
}
}
} else {
log::warn!("Scheduler not initialized, sync will not start");
}
@@ -3165,18 +3257,8 @@ pub async fn set_proxy_sync_enabled(
}
}
let mut updated_proxy = proxy.clone();
updated_proxy.sync_enabled = enabled;
if !enabled {
updated_proxy.last_sync = None;
}
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| format!("Failed to serialize proxy: {e}"))?;
std::fs::write(&proxy_file, &json)
.map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?;
let new_last_sync = if enabled { proxy.last_sync } else { None };
proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?;
let _ = events::emit("stored-proxies-changed", ());
@@ -3444,6 +3526,49 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all eligible profiles. Without this the user would see
// groups/proxies/vpns syncing while their profiles stay local-only — the
// long-standing source of issue #352. Encrypted mode wins when an E2E
// password is already configured; otherwise we fall back to plain Regular.
{
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let desired_mode = if encryption::has_e2e_password() {
SyncMode::Encrypted
} else {
SyncMode::Regular
};
let desired_mode_str = match desired_mode {
SyncMode::Encrypted => "Encrypted",
SyncMode::Regular => "Regular",
SyncMode::Disabled => "Disabled",
};
for profile in &profiles {
// Skip profiles that are already syncing (any non-Disabled mode),
// ephemeral profiles (data wipes on quit, sync is meaningless), and
// cross-OS profiles (the OS-specific binary isn't installed locally
// so a sync round-trip would be one-sided).
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
continue;
}
if let Err(e) = set_profile_sync_mode(
app_handle.clone(),
profile.id.to_string(),
desired_mode_str.to_string(),
)
.await
{
log::warn!(
"Failed to enable sync for profile {} ({}): {e}",
profile.name,
profile.id
);
}
}
}
// Enable sync for all unsynced proxies
{
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
@@ -3643,6 +3768,188 @@ pub async fn set_extension_group_sync_enabled(
Ok(())
}
/// Re-upload every sync-enabled entity under the current encryption state.
/// Called after the user sets, changes, or clears their E2E password —
/// existing remote bytes are still in the prior state, so without this they'd
/// remain plaintext (or worse, undecryptable) until the next per-entity edit.
///
/// Order: profiles first (so the user can resume work as soon as profile sync
/// completes), then proxies, groups, VPNs, extensions, extension groups.
/// Running profiles' associated entities are deferred by 5s so the active
/// browser session isn't disrupted mid-keystroke.
///
/// Progress is emitted via `e2e-rollover-progress` events with `{ stage, done, total }`.
#[tauri::command]
pub async fn rollover_encryption_for_all_entities(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let _ = events::emit("e2e-rollover-started", ());
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let synced_profiles: Vec<_> = profiles
.iter()
.filter(|p| p.sync_mode != SyncMode::Disabled)
.collect();
let total_profiles = synced_profiles.len();
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
std::collections::HashSet::new();
for (i, profile) in synced_profiles.iter().enumerate() {
if profile.process_id.is_some() {
running_profile_ids.insert(profile.id);
}
let id_str = profile.id.to_string();
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({
"stage": "profiles",
"done": i + 1,
"total": total_profiles,
}),
);
}
// Determine which entity ids are referenced by running profiles, so we can
// defer their re-upload (changing their files mid-session would cause the
// running browser to see a different proxy/extension config than what it
// launched with).
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in &profiles {
if running_profile_ids.contains(&p.id) {
if let Some(id) = &p.proxy_id {
deferred_proxy_ids.insert(id.clone());
}
if let Some(id) = &p.vpn_id {
deferred_vpn_ids.insert(id.clone());
}
if let Some(id) = &p.group_id {
deferred_group_ids.insert(id.clone());
}
}
}
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
let total_proxies = synced_proxies.len();
let mut deferred = Vec::new();
for (i, proxy) in synced_proxies.iter().enumerate() {
if deferred_proxy_ids.contains(&proxy.id) {
deferred.push(proxy.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_proxy_sync(proxy.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
);
}
let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?
};
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
let total_groups = synced_groups.len();
let mut deferred_groups = Vec::new();
for (i, group) in synced_groups.iter().enumerate() {
if deferred_group_ids.contains(&group.id) {
deferred_groups.push(group.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_group_sync(group.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
);
}
let vpns = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
};
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
let total_vpns = synced_vpns.len();
let mut deferred_vpns = Vec::new();
for (i, config) in synced_vpns.iter().enumerate() {
if deferred_vpn_ids.contains(&config.id) {
deferred_vpns.push(config.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_vpn_sync(config.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
);
}
let extensions = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_extensions()
.map_err(|e| format!("Failed to list extensions: {e}"))?
};
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
let total_exts = synced_exts.len();
for (i, ext) in synced_exts.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_sync(ext.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
);
}
let ext_groups = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_groups()
.map_err(|e| format!("Failed to list extension groups: {e}"))?
};
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
let total_eg = synced_ext_groups.len();
for (i, group) in synced_ext_groups.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_group_sync(group.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
);
}
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(scheduler) = super::get_global_scheduler() {
for id in deferred {
scheduler.queue_proxy_sync(id).await;
}
for id in deferred_groups {
scheduler.queue_group_sync(id).await;
}
for id in deferred_vpns {
scheduler.queue_vpn_sync(id).await;
}
}
});
}
let _ = events::emit("e2e-rollover-completed", ());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+4
View File
@@ -52,6 +52,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*",
"**/.DS_Store",
".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's
// fingerprint. Each device decides its own refresh cadence, so syncing
// this would cause one device's refresh to silence others.
".last-fp-refresh",
];
/// A single file entry in the manifest
+27 -6
View File
@@ -7,13 +7,16 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities,
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile,
is_vpn_used_by_synced_profile, request_profile_sync, set_extension_group_sync_enabled,
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
};
@@ -21,3 +24,21 @@ pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, Syn
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
/// reassignment shows up on other devices without waiting for the next
/// scheduled tick. Spawns the async queue call so this helper is callable
/// from both sync and async contexts.
pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
let profile_id = profile.id.to_string();
tauri::async_runtime::spawn(async move {
if let Some(scheduler) = get_global_scheduler() {
scheduler.queue_profile_sync(profile_id).await;
}
});
}
+13 -2
View File
@@ -639,14 +639,25 @@ impl WayfernManager {
.has_active_paid_subscription()
.await
{
log::info!("Wayfern token not ready for paid user, waiting...");
for _ in 0..15 {
// Brief wait for the background token fetch — when the API is healthy
// the token usually lands in well under a second. If api.donutbrowser.com
// is unreachable we don't want to gate the whole launch on it; the
// browser still works without the token (cross-OS fingerprinting just
// won't be enabled for this session, and the next launch will pick it
// up once the token arrives).
log::info!("Wayfern token not ready for paid user, waiting briefly...");
for _ in 0..3 {
tokio::time::sleep(Duration::from_secs(1)).await;
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if wayfern_token.is_some() {
break;
}
}
if wayfern_token.is_none() {
log::warn!(
"Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)"
);
}
}
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.22.6",
"version": "0.24.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+446 -126
View File
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
@@ -12,10 +13,10 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
import { GroupBadges } from "@/components/group-badges";
import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
@@ -23,10 +24,15 @@ import { IntegrationsDialog } from "@/components/integrations-dialog";
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
type PasswordDialogMode,
ProfilePasswordDialog,
} from "@/components/profile-password-dialog";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { type AppPage, RailNav } from "@/components/rail-nav";
import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
@@ -46,6 +52,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import {
dismissToast,
showErrorToast,
@@ -135,6 +142,13 @@ export default function Home() {
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
// Tracks which tab inside the shared proxy-management page should be active.
// The VPN rail item routes to the same page but pre-selects the VPN tab.
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
"proxies" | "vpns"
>("proxies");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -169,7 +183,7 @@ export default function Home() {
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
string[]
>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
const [selectedGroupId, setSelectedGroupId] = useState<string>("__all__");
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
string[]
>([]);
@@ -182,6 +196,11 @@ export default function Home() {
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
useState<BrowserProfile | null>(null);
const [cloneProfile, setCloneProfile] = useState<BrowserProfile | null>(null);
const [passwordDialogProfile, setPasswordDialogProfile] =
useState<BrowserProfile | null>(null);
const [passwordDialogMode, setPasswordDialogMode] =
useState<PasswordDialogMode>("set");
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
@@ -197,6 +216,7 @@ export default function Home() {
useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] =
@@ -209,6 +229,53 @@ export default function Home() {
setSelectedProfiles([]);
}, []);
const handleRailNavigate = useCallback((page: AppPage) => {
// Always reset every sub-page-able dialog before opening the next one,
// so navigating from one rail item to another doesn't stack two
// sub-pages on top of each other.
setSettingsDialogOpen(false);
setProxyManagementDialogOpen(false);
setExtensionManagementDialogOpen(false);
setGroupManagementDialogOpen(false);
setIntegrationsDialogOpen(false);
setImportProfileDialogOpen(false);
setAccountDialogOpen(false);
setCurrentPage(page);
switch (page) {
case "profiles":
break;
case "settings":
setSettingsDialogOpen(true);
break;
case "proxies":
setProxyManagementInitialTab("proxies");
setProxyManagementDialogOpen(true);
break;
case "extensions":
setExtensionManagementDialogOpen(true);
break;
case "groups":
setGroupManagementDialogOpen(true);
break;
case "integrations":
setIntegrationsDialogOpen(true);
break;
case "import":
setImportProfileDialogOpen(true);
break;
case "vpns":
// VPNs share the proxy management page; pre-select the VPN tab so
// the user lands directly on the right list.
setProxyManagementInitialTab("vpns");
setProxyManagementDialogOpen(true);
break;
case "account":
setAccountDialogOpen(true);
break;
}
}, []);
// Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => {
try {
@@ -394,21 +461,32 @@ export default function Home() {
}
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
const checkNextPermission = useCallback(() => {
try {
if (!isMicrophoneAccessGranted) {
setCurrentPermissionType("microphone");
setPermissionDialogOpen(true);
} else if (!isCameraAccessGranted) {
setCurrentPermissionType("camera");
setPermissionDialogOpen(true);
} else {
setPermissionDialogOpen(false);
const checkNextPermission = useCallback(
(justGranted?: PermissionType) => {
try {
// Treat the just-granted permission as already granted even if our
// own usePermissions instance hasn't observed it yet — it polls on a
// 5 s cadence and would otherwise leave the dialog stuck on the
// permission the user just successfully granted.
const micGranted =
isMicrophoneAccessGranted || justGranted === "microphone";
const camGranted = isCameraAccessGranted || justGranted === "camera";
if (!micGranted) {
setCurrentPermissionType("microphone");
setPermissionDialogOpen(true);
} else if (!camGranted) {
setCurrentPermissionType("camera");
setPermissionDialogOpen(true);
} else {
setPermissionDialogOpen(false);
}
} catch (error) {
console.error("Failed to check next permission:", error);
}
} catch (error) {
console.error("Failed to check next permission:", error);
}
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
},
[isMicrophoneAccessGranted, isCameraAccessGranted],
);
const listenForUrlEvents = useCallback(async () => {
try {
@@ -519,6 +597,7 @@ export default function Home() {
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
password?: string;
}) => {
try {
const profile = await invoke<BrowserProfile>(
@@ -534,7 +613,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined),
(selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook,
@@ -552,6 +633,21 @@ export default function Home() {
}
}
if (profileData.password && !profileData.ephemeral) {
try {
await invoke("set_profile_password", {
profileId: profile.id,
password: profileData.password,
});
} catch (err) {
showErrorToast(
t("errors.setProfilePasswordFailed", {
error: translateBackendError(t, err),
}),
);
}
}
// No need to manually reload - useProfileEvents will handle the update
} catch (error) {
showErrorToast(
@@ -568,6 +664,23 @@ export default function Home() {
async (profile: BrowserProfile) => {
console.log("Starting launch for profile:", profile.name);
// Password-protected: must be unlocked before launch
if (profile.password_protected) {
try {
const isLocked = await invoke<boolean>("is_profile_locked", {
profileId: profile.id,
});
if (isLocked) {
pendingLaunchAfterUnlockRef.current = profile;
setPasswordDialogMode("unlock");
setPasswordDialogProfile(profile);
return;
}
} catch (err) {
console.error("Failed to check profile lock state:", err);
}
}
// Show one-time warning about window resizing for fingerprinted browsers
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
try {
@@ -610,6 +723,24 @@ export default function Home() {
setCloneProfile(profile);
}, []);
const handleSetPassword = useCallback((profile: BrowserProfile) => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogMode("set");
setPasswordDialogProfile(profile);
}, []);
const handleChangePassword = useCallback((profile: BrowserProfile) => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogMode("change");
setPasswordDialogProfile(profile);
}, []);
const handleRemovePassword = useCallback((profile: BrowserProfile) => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogMode("remove");
setPasswordDialogProfile(profile);
}, []);
const handleDeleteProfile = useCallback(
async (profile: BrowserProfile) => {
console.log("Attempting to delete profile:", profile.name);
@@ -968,6 +1099,84 @@ export default function Home() {
profiles.length,
]);
// E2E encryption listeners — surface password-required prompts and rollover
// progress so the user isn't left guessing whether sealing finished.
useEffect(() => {
let unlistenRequired: (() => void) | undefined;
let unlistenStarted: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
let unlistenCompleted: (() => void) | undefined;
void (async () => {
unlistenRequired = await listen(
"profile-sync-e2e-password-required",
() => {
showToast({
id: "e2e-password-required",
type: "error",
title: t("encryption.required.title"),
description: t("encryption.required.description"),
duration: 12000,
action: {
label: t("encryption.required.openSettings"),
onClick: () => {
setSettingsDialogOpen(true);
setCurrentPage("settings");
},
},
});
},
);
unlistenStarted = await listen("e2e-rollover-started", () => {
showToast({
id: "e2e-rollover",
type: "loading",
title: t("encryption.rollover.startedTitle"),
description: t("encryption.rollover.startedDescription"),
duration: Number.POSITIVE_INFINITY,
});
});
unlistenProgress = await listen<{
stage: string;
done: number;
total: number;
}>("e2e-rollover-progress", (event) => {
const { stage, done, total } = event.payload;
showToast({
id: "e2e-rollover",
type: "loading",
title: t("encryption.rollover.progressTitle", {
stage: t(`encryption.rollover.stage.${stage}`),
}),
description: t("encryption.rollover.progressDescription", {
done,
total,
}),
duration: Number.POSITIVE_INFINITY,
});
});
unlistenCompleted = await listen("e2e-rollover-completed", () => {
showToast({
id: "e2e-rollover",
type: "success",
title: t("encryption.rollover.completedTitle"),
description: t("encryption.rollover.completedDescription"),
duration: 5000,
});
});
})();
return () => {
unlistenRequired?.();
unlistenStarted?.();
unlistenProgress?.();
unlistenCompleted?.();
};
}, [t]);
// Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
useEffect(() => {
if (profiles.length === 0) return;
@@ -1035,9 +1244,11 @@ export default function Home() {
const filteredProfiles = useMemo(() => {
let filtered = profiles;
// Filter by group
if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
// Filter by group. "__all__" is a virtual filter that shows every
// profile (including ungrouped ones). Any other value is a real
// group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles;
} else {
filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId,
@@ -1068,64 +1279,163 @@ export default function Home() {
// Update loading states
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
const subPageTitle =
currentPage === "profiles"
? undefined
: currentPage === "import"
? t("pageTitle.import")
: t(`pageTitle.${currentPage}`);
return (
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
<main className="flex flex-col items-center w-full max-w-4xl px-3">
<div className="w-full">
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
onImportProfileDialogOpen={setImportProfileDialogOpen}
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
onSettingsDialogOpen={setSettingsDialogOpen}
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
</div>
<div className="w-full mt-2.5">
<GroupBadges
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
groups={groupsData}
isLoading={isLoading}
/>
<ProfilesDataTable
profiles={filteredProfiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
onCopyCookiesToProfile={handleCopyCookiesToProfile}
onOpenCookieManagement={handleOpenCookieManagement}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
onBulkCopyCookies={handleBulkCopyCookies}
onBulkExtensionGroupAssignment={handleBulkExtensionGroupAssignment}
onAssignExtensionGroup={handleAssignExtensionGroup}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => {
setSyncLeaderProfile(profile);
}}
/>
</div>
</main>
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
groups={groupsData}
totalProfiles={profiles.length}
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle}
/>
<div className="flex flex-1 min-h-0">
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
{currentPage === "profiles" && (
<div className="px-3 pt-2.5 flex flex-col flex-1 min-h-0">
{isLoading && groupsData.length === 0 ? null : null}
<ProfilesDataTable
profiles={filteredProfiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
onSetPassword={handleSetPassword}
onChangePassword={handleChangePassword}
onRemovePassword={handleRemovePassword}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
onCopyCookiesToProfile={handleCopyCookiesToProfile}
onOpenCookieManagement={handleOpenCookieManagement}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
onBulkCopyCookies={handleBulkCopyCookies}
onBulkExtensionGroupAssignment={
handleBulkExtensionGroupAssignment
}
onAssignExtensionGroup={handleAssignExtensionGroup}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => {
setSyncLeaderProfile(profile);
}}
/>
</div>
)}
{settingsDialogOpen && (
<SettingsDialog
isOpen={settingsDialogOpen}
onClose={() => {
setSettingsDialogOpen(false);
setCurrentPage("profiles");
}}
onIntegrationsOpen={() => {
setSettingsDialogOpen(false);
setIntegrationsDialogOpen(true);
setCurrentPage("integrations");
}}
subPage={currentPage === "settings"}
/>
)}
{integrationsDialogOpen && (
<IntegrationsDialog
isOpen={integrationsDialogOpen}
onClose={() => {
setIntegrationsDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "integrations"}
/>
)}
{proxyManagementDialogOpen && (
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "proxies" || currentPage === "vpns"}
initialTab={proxyManagementInitialTab}
/>
)}
{groupManagementDialogOpen && (
<GroupManagementDialog
isOpen={groupManagementDialogOpen}
onClose={() => {
setGroupManagementDialogOpen(false);
setCurrentPage("profiles");
}}
onGroupManagementComplete={handleGroupManagementComplete}
subPage={currentPage === "groups"}
/>
)}
{extensionManagementDialogOpen && (
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => {
setExtensionManagementDialogOpen(false);
setCurrentPage("profiles");
}}
limitedMode={false}
subPage={currentPage === "extensions"}
/>
)}
{importProfileDialogOpen && (
<ImportProfileDialog
isOpen={importProfileDialogOpen}
onClose={() => {
setImportProfileDialogOpen(false);
setCurrentPage("profiles");
}}
crossOsUnlocked={crossOsUnlocked}
subPage={currentPage === "import"}
/>
)}
{accountDialogOpen && (
<AccountPage
isOpen={accountDialogOpen}
onClose={() => {
setAccountDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "account"}
onOpenSignIn={() => {
setAccountDialogOpen(false);
setCurrentPage("profiles");
setDeviceCodeDialogOpen(true);
}}
/>
)}
</main>
</div>
<CreateProfileDialog
isOpen={createProfileDialogOpen}
@@ -1137,39 +1447,6 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
/>
<SettingsDialog
isOpen={settingsDialogOpen}
onClose={() => {
setSettingsDialogOpen(false);
}}
onIntegrationsOpen={() => {
setSettingsDialogOpen(false);
setIntegrationsDialogOpen(true);
}}
/>
<IntegrationsDialog
isOpen={integrationsDialogOpen}
onClose={() => {
setIntegrationsDialogOpen(false);
}}
/>
<ImportProfileDialog
isOpen={importProfileDialogOpen}
onClose={() => {
setImportProfileDialogOpen(false);
}}
crossOsUnlocked={crossOsUnlocked}
/>
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
@@ -1202,6 +1479,44 @@ export default function Home() {
profile={cloneProfile}
/>
<ProfilePasswordDialog
isOpen={!!passwordDialogProfile}
onClose={() => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogProfile(null);
}}
profile={passwordDialogProfile}
mode={passwordDialogMode}
onSuccess={(p) => {
// Resume pending launch after unlock.
if (
passwordDialogMode === "unlock" &&
pendingLaunchAfterUnlockRef.current?.id === p.id
) {
const target = pendingLaunchAfterUnlockRef.current;
pendingLaunchAfterUnlockRef.current = null;
void launchProfile(target);
}
// On set/change/remove, the profile's encryption state changed.
// Push that state to the sync server immediately so other devices
// see the new envelope before they next pull. Skip if the profile
// is currently running — its files would be in flux.
if (
(passwordDialogMode === "set" ||
passwordDialogMode === "change" ||
passwordDialogMode === "remove") &&
!runningProfiles.has(p.id) &&
p.sync_mode !== "Disabled"
) {
void invoke("request_profile_sync", { profileId: p.id }).catch(
(err: unknown) => {
console.error("post-password sync failed", err);
},
);
}
}}
/>
<CamoufoxConfigDialog
isOpen={camoufoxConfigDialogOpen}
onClose={() => {
@@ -1218,22 +1533,6 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
/>
<GroupManagementDialog
isOpen={groupManagementDialogOpen}
onClose={() => {
setGroupManagementDialogOpen(false);
}}
onGroupManagementComplete={handleGroupManagementComplete}
/>
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => {
setExtensionManagementDialogOpen(false);
}}
limitedMode={!crossOsUnlocked}
/>
<GroupAssignmentDialog
isOpen={groupAssignmentDialogOpen}
onClose={() => {
@@ -1316,8 +1615,29 @@ export default function Home() {
setSyncAllDialogOpen(true);
}
}}
onLoginStarted={() => {
// Hand the verify step off to its own dialog. We close this one
// first so the verify dialog isn't stacked on top of it (and
// can't end up stacked on top of the profile selector either).
setSyncConfigDialogOpen(false);
setDeviceCodeDialogOpen(true);
}}
/>
{/* Only render while no profile-selector flow is in progress, so the
verify dialog never lands on top of a deep-link-triggered selector. */}
{pendingUrls.length === 0 && (
<DeviceCodeVerifyDialog
isOpen={deviceCodeDialogOpen}
onClose={(loginOccurred) => {
setDeviceCodeDialogOpen(false);
if (loginOccurred) {
setSyncAllDialogOpen(true);
}
}}
/>
)}
<SyncAllDialog
isOpen={syncAllDialogOpen}
onClose={() => {
+476
View File
@@ -0,0 +1,476 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuCloud,
LuEye,
LuEyeOff,
LuLogOut,
LuRefreshCw,
LuUser,
} from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { SyncSettings } from "@/types";
interface AccountPageProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
onOpenSignIn: () => void;
}
type ConnectionStatus = "unknown" | "testing" | "connected" | "error";
export function AccountPage({
isOpen,
onClose,
subPage,
onOpenSignIn,
}: AccountPageProps) {
const { t } = useTranslation();
const {
user,
isLoggedIn,
isLoading: isCloudLoading,
logout,
refreshProfile,
} = useCloudAuth();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Self-hosted server state. Loaded once when the dialog opens and persisted
// via `save_sync_settings` so the rest of the app picks up the new URL/token
// from `SettingsManager`.
const [serverUrl, setServerUrl] = useState("");
const [token, setToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [isSavingSelfHosted, setIsSavingSelfHosted] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("unknown");
const hasConfig = Boolean(serverUrl && token);
// Self-hosted and cloud are mutually exclusive — both share the same sync
// engine and a profile can't be sync'd to two backends. The tab trigger is
// disabled here AND the backend rejects mixed state (see `save_sync_settings`
// / `cloud_logout`), so even if someone bypasses the UI we don't end up
// with split-brain.
const selfHostedDisabled = isLoggedIn || isCloudLoading;
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refreshProfile();
showSuccessToast(t("account.refreshed"));
} catch (e) {
showErrorToast(String(e));
} finally {
setIsRefreshing(false);
}
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await logout();
// The backend wipes sync URL + token as part of cloud_logout (see
// `cloud_auth::cloud_logout`); pull the now-empty settings back into
// the form so a user who flips to the Self-hosted tab doesn't see the
// pre-logout production URL still sitting there.
await loadSelfHostedSettings();
showSuccessToast(t("account.loggedOut"));
} catch (e) {
showErrorToast(String(e));
} finally {
setIsLoggingOut(false);
}
};
const loadSelfHostedSettings = useCallback(async () => {
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
setServerUrl(settings.sync_server_url ?? "");
setToken(settings.sync_token ?? "");
setConnectionStatus(
settings.sync_server_url && settings.sync_token ? "unknown" : "unknown",
);
} catch (error) {
console.error("Failed to load sync settings:", error);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadSelfHostedSettings();
}
}, [isOpen, loadSelfHostedSettings]);
const handleTestConnection = useCallback(async () => {
if (!serverUrl) {
showErrorToast(t("sync.config.serverUrlRequired"));
return;
}
setIsTestingConnection(true);
setConnectionStatus("testing");
try {
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
if (response.ok) {
setConnectionStatus("connected");
showSuccessToast(t("sync.config.connectionSuccess"));
} else {
setConnectionStatus("error");
showErrorToast(t("sync.config.serverError"));
}
} catch {
setConnectionStatus("error");
showErrorToast(t("sync.config.connectFailed"));
} finally {
setIsTestingConnection(false);
}
}, [serverUrl, t]);
const handleSaveSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: serverUrl || null,
syncToken: token || null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
showSuccessToast(t("sync.config.settingsSaved"));
} catch (error) {
console.error("Failed to save sync settings:", error);
// Use the structured backend-error translator so the cloud-vs-self-
// hosted mutex (`SELF_HOSTED_REQUIRES_LOGOUT`) shows a clear message
// instead of the generic "save failed" toast.
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsSavingSelfHosted(false);
}
}, [serverUrl, token, t]);
const handleDisconnectSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: null,
syncToken: null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
setServerUrl("");
setToken("");
setConnectionStatus("unknown");
showSuccessToast(t("sync.config.disconnected"));
} catch (error) {
console.error("Failed to disconnect:", error);
showErrorToast(t("sync.config.disconnectFailed"));
} finally {
setIsSavingSelfHosted(false);
}
}, [t]);
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
selfHostedDisabled
? t("account.selfHosted.disabledWhileLoggedIn")
: undefined
}
>
{t("account.tabs.selfHosted")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="size-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="text-base font-semibold truncate">
{user.email}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">
{user.plan}
</p>
</div>
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="size-3" />
{t("account.refresh")}
</Button>
<LoadingButton
size="sm"
variant="destructive"
isLoading={isLoggingOut}
disabled={isRefreshing}
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="size-3" />
{t("account.logout")}
</LoadingButton>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<LuCloud className="size-3" />
{t("account.signIn")}
</Button>
)}
</div>
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
// but if state flips mid-render (e.g. a cloud login finishes
// while the tab is open), show the explanation instead of
// a silent empty card.
<p className="text-sm text-muted-foreground">
{t("account.selfHosted.disabledWhileLoggedIn")}
</p>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="text-sm font-medium">
{t("account.selfHosted.title")}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.selfHosted.description")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-server-url" className="text-xs">
{t("sync.serverUrl")}
</Label>
<Input
id="self-hosted-server-url"
type="url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-token" className="text-xs">
{t("sync.token")}
</Label>
<div className="relative">
<Input
id="self-hosted-token"
type={showToken ? "text" : "password"}
placeholder={t("sync.tokenPlaceholder")}
value={token}
onChange={(e) => {
setToken(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
className="pr-9"
/>
<button
type="button"
onClick={() => {
setShowToken((v) => !v);
}}
aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<LuEyeOff className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("account.selfHosted.connectionStatus")}
</span>
{connectionStatus === "connected" && (
<Badge
variant="default"
className="text-success-foreground bg-success"
>
{t("sync.status.connected")}
</Badge>
)}
{connectionStatus === "error" && (
<Badge variant="destructive">
{t("sync.status.error")}
</Badge>
)}
{connectionStatus === "testing" && (
<Badge variant="secondary">
{t("sync.status.syncing")}
</Badge>
)}
{connectionStatus === "unknown" && (
<Badge variant="secondary">
{t("account.selfHosted.statusUnknown")}
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
size="sm"
variant="outline"
isLoading={isTestingConnection}
disabled={!serverUrl || isSavingSelfHosted}
onClick={() => void handleTestConnection()}
className="h-8 text-xs"
>
{t("account.selfHosted.testConnection")}
</LoadingButton>
<LoadingButton
size="sm"
isLoading={isSavingSelfHosted}
disabled={!serverUrl || !token || isTestingConnection}
onClick={() => void handleSaveSelfHosted()}
className="h-8 text-xs"
>
{t("common.buttons.save")}
</LoadingButton>
{hasConfig && (
<Button
size="sm"
variant="destructive"
disabled={isSavingSelfHosted || isTestingConnection}
onClick={() => void handleDisconnectSelfHosted()}
className="h-8 text-xs"
>
{t("account.selfHosted.disconnect")}
</Button>
)}
</div>
</div>
)}
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
);
}
+5 -5
View File
@@ -37,7 +37,7 @@ export function AppUpdateToast({
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 w-5 h-5" />
<LuCheckCheck className="flex-shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
@@ -59,9 +59,9 @@ export function AppUpdateToast({
variant="ghost"
size="sm"
onClick={onDismiss}
className="p-0 w-6 h-6 shrink-0"
className="p-0 size-6 shrink-0"
>
<FaTimes className="w-3 h-3" />
<FaTimes className="size-3" />
</Button>
</div>
@@ -72,7 +72,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<LuCheckCheck className="w-3 h-3" />
<LuCheckCheck className="size-3" />
{t("appUpdate.toast.restartNow")}
</RippleButton>
) : (
@@ -83,7 +83,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
<FaExternalLinkAlt className="size-3" />
{t("appUpdate.toast.viewRelease")}
</RippleButton>
)
+10 -8
View File
@@ -36,16 +36,18 @@ export function CloneProfileDialog({
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen && profile) {
const defaultName = `${profile.name} (Copy)`;
setName(defaultName);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
} else {
if (!(isOpen && profile)) {
setIsLoading(false);
return;
}
setName(`${profile.name} (Copy)`);
const handle = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen, profile]);
if (!profile) return null;
+6 -6
View File
@@ -335,7 +335,7 @@ export function CookieCopyDialog({
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" />
<LuCookie className="size-5" />
{t("cookies.copy.title")}
</DialogTitle>
<DialogDescription>
@@ -372,7 +372,7 @@ export function CookieCopyDialog({
disabled={isRunning}
>
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="w-4 h-4" />}
{IconComponent && <IconComponent className="size-4" />}
<span>{profile.name}</span>
{isRunning && (
<span className="text-xs text-muted-foreground">
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
</div>
<div className="relative">
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t("cookies.copy.searchPlaceholder")}
value={searchQuery}
@@ -450,7 +450,7 @@ export function CookieCopyDialog({
{isLoadingCookies ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
@@ -565,9 +565,9 @@ function DomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-4 h-4" />
<LuChevronDown className="size-4" />
) : (
<LuChevronRight className="w-4 h-4" />
<LuChevronRight className="size-4" />
)}
<span className="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground">
+7 -7
View File
@@ -15,9 +15,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -429,7 +429,7 @@ export function CookieManagementDialog({
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("cookies.management.dropPrompt")}
<br />
@@ -556,14 +556,14 @@ export function CookieManagementDialog({
{isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
{t("cookies.management.noCookies")}
</div>
) : (
<ScrollArea className="h-[200px] border rounded-md">
<FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/>
))}
</div>
</ScrollArea>
</FadingScrollArea>
)}
</div>
@@ -645,9 +645,9 @@ function ExportDomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" />
<LuChevronDown className="size-3.5" />
) : (
<LuChevronRight className="w-3.5 h-3.5" />
<LuChevronRight className="size-3.5" />
)}
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
+139 -29
View File
@@ -1,7 +1,14 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
@@ -86,6 +93,7 @@ interface CreateProfileDialogProps {
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
password?: string;
}) => Promise<void>;
selectedGroupId?: string;
crossOsUnlocked?: boolean;
@@ -115,6 +123,8 @@ export function CreateProfileDialog({
crossOsUnlocked = false,
}: CreateProfileDialogProps) {
const { t } = useTranslation();
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState("");
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
@@ -170,6 +180,11 @@ export function CreateProfileDialog({
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [enablePassword, setEnablePassword] = useState(false);
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [passwordError, setPasswordError] = useState<string | null>(null);
const PASSWORD_MIN_LEN = 8;
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
useState<string>();
const [extensionGroups, setExtensionGroups] = useState<
@@ -370,12 +385,30 @@ export function CreateProfileDialog({
const handleCreate = async () => {
if (!profileName.trim()) return;
if (enablePassword && !ephemeral) {
if (password.length < PASSWORD_MIN_LEN) {
setPasswordError(
t("profilePassword.errors.tooShort", { min: PASSWORD_MIN_LEN }),
);
return;
}
if (password !== passwordConfirm) {
setPasswordError(t("profilePassword.errors.mismatch"));
return;
}
}
setPasswordError(null);
setIsCreating(true);
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
: undefined;
try {
if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected
@@ -398,11 +431,14 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
} else {
// Default to Camoufox
@@ -425,11 +461,14 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
}
} else {
@@ -452,9 +491,13 @@ export function CreateProfileDialog({
version: bestVersion.version,
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
}
@@ -488,6 +531,10 @@ export function CreateProfileDialog({
os: getCurrentOS() as WayfernOS, // Reset to current OS
});
setEphemeral(false);
setEnablePassword(false);
setPassword("");
setPasswordConfirm("");
setPasswordError(null);
onClose();
};
@@ -537,7 +584,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-full max-h-[90vh] flex flex-col">
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
@@ -574,11 +621,11 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
) : null;
})()}
</div>
@@ -600,11 +647,11 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
) : null;
})()}
</div>
@@ -645,9 +692,9 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{IconComponent && (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
)}
</div>
<div className="text-left">
@@ -698,7 +745,7 @@ export function CreateProfileDialog({
{/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
@@ -709,22 +756,81 @@ export function CreateProfileDialog({
<Label htmlFor="ephemeral" className="font-medium">
{t("profiles.ephemeral")}
</Label>
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
{t("profiles.ephemeralAlpha")}
</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("profiles.ephemeralDescription")}
</p>
</div>
{/* Password Option */}
{!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center gap-x-2">
<Checkbox
id="enable-password"
checked={enablePassword}
onCheckedChange={(checked) => {
setEnablePassword(checked === true);
if (checked !== true) {
setPassword("");
setPasswordConfirm("");
setPasswordError(null);
}
}}
/>
<Label
htmlFor="enable-password"
className="font-medium"
>
{t("createProfile.passwordProtect.label")}
</Label>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("createProfile.passwordProtect.description")}
</p>
{enablePassword && (
<div className="ml-6 space-y-2">
<Input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setPasswordError(null);
}}
placeholder={t(
"profilePassword.fields.newPassword",
)}
autoComplete="new-password"
/>
<Input
type="password"
value={passwordConfirm}
onChange={(e) => {
setPasswordConfirm(e.target.value);
setPasswordError(null);
}}
placeholder={t(
"profilePassword.fields.confirm",
)}
autoComplete="new-password"
/>
{passwordError && (
<p className="text-sm text-destructive">
{passwordError}
</p>
)}
</div>
)}
</div>
)}
{selectedBrowser === "wayfern" ? (
// Wayfern Configuration
<div className="space-y-6">
{/* Wayfern Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -832,7 +938,7 @@ export function CreateProfileDialog({
{/* Camoufox Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -951,7 +1057,7 @@ export function CreateProfileDialog({
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -1064,7 +1170,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1078,6 +1184,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdAntiDetect}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1100,10 +1207,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdAntiDetect}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1127,7 +1235,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1146,7 +1254,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1171,7 +1279,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
@@ -1322,7 +1430,7 @@ export function CreateProfileDialog({
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
Fetching available versions...
</p>
@@ -1430,7 +1538,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1444,6 +1552,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdRegular}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1466,10 +1575,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdRegular}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1493,7 +1603,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1512,7 +1622,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1537,7 +1647,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
+14 -14
View File
@@ -174,42 +174,42 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />;
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
);
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
);
}
return <LuDownload className="flex-shrink-0 w-4 h-4 text-foreground" />;
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -235,7 +235,7 @@ export function UnifiedToast(props: ToastProps) {
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label={t("common.buttons.cancel")}
>
<LuX className="w-3 h-3" />
<LuX className="size-3" />
</button>
)}
</div>
@@ -255,7 +255,7 @@ export function UnifiedToast(props: ToastProps) {
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
style={{ width: `${progress.percentage}%` }}
/>
</div>
@@ -272,10 +272,10 @@ export function UnifiedToast(props: ToastProps) {
<>Looking for updates for {progress.current_browser}</>
)}
</p>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
+1 -1
View File
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
{...props}
>
{isPending ? (
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3.5 rounded-full border border-current animate-spin border-t-transparent" />
) : (
children
)}
+2 -2
View File
@@ -155,13 +155,13 @@ export function DeleteGroupDialog({
setDeleteAction(value as "move" | "delete");
}}
>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="move" id="move" />
<Label htmlFor="move" className="text-sm">
{t("groups.moveToDefault")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
@@ -0,0 +1,146 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
interface DeviceCodeVerifyDialogProps {
isOpen: boolean;
onClose: (loginOccurred?: boolean) => void;
}
/**
* Dedicated dialog for pasting and verifying the cloud device-link code.
* Opens after the user clicks "Login" in the sync config dialog so the
* verify step is a focused step on its own and so it doesn't visually
* stack with other dialogs (e.g. the profile selector triggered by a
* deep link) sharing the same view.
*/
export function DeviceCodeVerifyDialog({
isOpen,
onClose,
}: DeviceCodeVerifyDialogProps) {
const { t } = useTranslation();
const { exchangeDeviceCode } = useCloudAuth();
const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [isOpeningLogin, setIsOpeningLogin] = useState(false);
const handleOpenLogin = async () => {
setIsOpeningLogin(true);
try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
} catch (error) {
console.error("Failed to open login link:", error);
showErrorToast(String(error));
} finally {
setIsOpeningLogin(false);
}
};
// Reset the field when the dialog reopens so a stale code from a
// previous attempt doesn't auto-populate.
useEffect(() => {
if (isOpen) {
setLinkCode("");
}
}, [isOpen]);
const handleVerify = async () => {
const trimmed = linkCode.trim();
if (!trimmed) return;
setIsVerifying(true);
try {
await exchangeDeviceCode(trimmed);
showSuccessToast(t("sync.cloud.loginSuccess"));
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
onClose(true);
} catch (error) {
console.error("Device-code exchange failed:", error);
showErrorToast(String(error));
} finally {
setIsVerifying(false);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose(false);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.cloud.signInTitle")}</DialogTitle>
<DialogDescription>
{t("sync.cloud.deviceLinkInstructions")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<Button
type="button"
variant="outline"
onClick={() => void handleOpenLogin()}
disabled={isOpeningLogin}
className="w-full gap-1.5"
>
<LuExternalLink className="size-3.5" />
{t("sync.cloud.openLogin")}
</Button>
<div className="space-y-2">
<Label htmlFor="device-link-code">
{t("sync.cloud.linkCodeLabel")}
</Label>
<Input
id="device-link-code"
placeholder={t("sync.cloud.linkCodePlaceholder")}
value={linkCode}
onChange={(e) => {
setLinkCode(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && linkCode.trim()) {
void handleVerify();
}
}}
autoComplete="off"
spellCheck={false}
autoFocus
/>
<LoadingButton
onClick={() => void handleVerify()}
isLoading={isVerifying}
disabled={!linkCode.trim()}
className="w-full"
>
{isVerifying
? t("sync.cloud.loggingIn")
: t("sync.cloud.verifyAndLogin")}
</LoadingButton>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -137,7 +137,7 @@ export function DnsBlocklistDialog({
className="w-full"
>
<LuRefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("dnsBlocklist.refreshAll")}
</Button>
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -77,7 +77,7 @@ export function GroupAssignmentDialog({
const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name ||
t("groups.unknownGroup")
: t("groups.defaultGroup");
: t("groups.noGroup");
toast.success(
t("groups.assignSuccess", {
@@ -165,7 +165,7 @@ export function GroupAssignmentDialog({
setCreateDialogOpen(true);
}}
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("groupManagement.createGroup")}
</RippleButton>
</div>
@@ -175,17 +175,17 @@ export function GroupAssignmentDialog({
</div>
) : (
<Select
value={selectedGroupId ?? "default"}
value={selectedGroupId ?? "__none__"}
onValueChange={(value) => {
setSelectedGroupId(value === "default" ? null : value);
setSelectedGroupId(value === "__none__" ? null : value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("groupAssignment.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("groups.defaultGroupNoGroup")}
<SelectItem value="__none__">
{t("groups.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
+1 -3
View File
@@ -183,9 +183,7 @@ export function GroupBadges({
}
}}
>
<span>
{group.id === "default" ? t("groups.defaultGroup") : group.name}
</span>
<span>{group.name}</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
+423 -146
View File
@@ -1,14 +1,37 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type RowSelectionState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import {
LuChevronDown,
LuChevronUp,
LuFolder,
LuPencil,
LuRefreshCw,
LuTrash2,
} from "react-icons/lu";
import { CreateGroupDialog } from "@/components/create-group-dialog";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "@/components/data-table-action-bar";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
import { EditGroupDialog } from "@/components/edit-group-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -20,8 +43,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import {
Table,
TableBody,
@@ -93,12 +115,14 @@ interface GroupManagementDialogProps {
isOpen: boolean;
onClose: () => void;
onGroupManagementComplete: () => void;
subPage?: boolean;
}
export function GroupManagementDialog({
isOpen,
onClose,
onGroupManagementComplete,
subPage,
}: GroupManagementDialogProps) {
const { t } = useTranslation();
const [groups, setGroups] = useState<GroupWithCount[]>([]);
@@ -109,6 +133,8 @@ export function GroupManagementDialog({
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>(
null,
);
@@ -123,6 +149,12 @@ export function GroupManagementDialog({
{},
);
// Table state
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Listen for group sync status events
useEffect(() => {
let unlisten: (() => void) | undefined;
@@ -244,32 +276,303 @@ export function GroupManagementDialog({
useEffect(() => {
if (isOpen) {
void loadGroups();
} else {
// Drop any selection when the dialog closes so the floating
// action bar (portaled to body) doesn't linger on the page.
setRowSelection({});
}
}, [isOpen, loadGroups]);
const columns = useMemo<ColumnDef<GroupWithCount>[]>(
() => [
{
id: "select",
size: 36,
enableSorting: false,
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => {
table.toggleAllRowsSelected(!!value);
}}
aria-label={t("common.aria.selectAll")}
disabled={table.getRowModel().rows.length === 0}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => {
row.toggleSelected(!!value);
}}
aria-label={t("common.aria.selectRow")}
/>
),
},
{
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
>
{t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 size-4" />
) : null}
</Button>
),
cell: ({ row }) => {
const group = row.original;
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<div className="flex items-center gap-2 font-medium">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`size-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
<LuFolder className="size-4 text-muted-foreground" />
{group.name}
</div>
);
},
},
{
id: "count",
size: 80,
enableSorting: false,
header: () => t("groupManagement.profilesCol"),
cell: ({ row }) => (
<Badge variant="secondary">{row.original.count}</Badge>
),
},
{
id: "sync",
size: 96,
enableSorting: false,
header: () => t("proxies.management.syncCol"),
cell: ({ row }) => {
const group = row.original;
const locked = groupInUse[group.id];
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center">
<AnimatedSwitch
checked={group.sync_enabled}
onCheckedChange={() => handleToggleSync(group)}
disabled={isTogglingSync[group.id] || locked}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{locked ? (
<p>{t("syncTooltips.lockedInUse")}</p>
) : (
<p>
{group.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
</p>
)}
</TooltipContent>
</Tooltip>
);
},
},
{
id: "actions",
size: 96,
enableSorting: false,
header: () => t("common.labels.actions"),
cell: ({ row }) => {
const group = row.original;
return (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.editGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.deleteGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
);
},
},
],
[
t,
groupSyncStatus,
groupSyncErrors,
groupInUse,
isTogglingSync,
handleToggleSync,
handleEditGroup,
handleDeleteGroup,
],
);
const table = useReactTable({
data: groups,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedGroupsForBulk = useMemo(
() => selectedRows.map((row) => row.original),
[selectedRows],
);
const selectedNames = useMemo(
() => selectedGroupsForBulk.map((g) => g.name).join(", "),
[selectedGroupsForBulk],
);
const handleBulkDelete = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
setIsBulkDeleting(true);
try {
const ids = selectedGroupsForBulk.map((g) => g.id);
const results = await Promise.allSettled(
ids.map((groupId) => invoke("delete_profile_group", { groupId })),
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
showErrorToast(t("groups.deleteFailed"));
} else {
showSuccessToast(t("groups.deleteSuccess"));
}
table.toggleAllRowsSelected(false);
setBulkDeleteOpen(false);
await loadGroups();
onGroupManagementComplete();
} catch (err) {
console.error("Bulk group delete failed:", err);
showErrorToast(
err instanceof Error ? err.message : t("groups.deleteFailed"),
);
} finally {
setIsBulkDeleting(false);
}
}, [selectedGroupsForBulk, table, loadGroups, onGroupManagementComplete, t]);
const handleBulkToggleSync = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
const allOn = selectedGroupsForBulk.every((g) => g.sync_enabled);
const targetEnabled = !allOn;
const targets = selectedGroupsForBulk.filter((g) =>
targetEnabled ? !g.sync_enabled : g.sync_enabled && !groupInUse[g.id],
);
if (targets.length === 0) return;
const results = await Promise.allSettled(
targets.map((group) =>
invoke("set_group_sync_enabled", {
groupId: group.id,
enabled: targetEnabled,
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
} else {
showSuccessToast(
targetEnabled
? t("proxies.management.syncEnabled")
: t("proxies.management.syncDisabled"),
);
}
await loadGroups();
}, [selectedGroupsForBulk, groupInUse, loadGroups, t]);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("groups.management")}</DialogTitle>
<DialogDescription>
{t("groups.noGroupDescription")}
</DialogDescription>
</DialogHeader>
{!subPage && (
<DialogHeader>
<DialogTitle>{t("groups.management")}</DialogTitle>
<DialogDescription>
{t("groups.noGroupDescription")}
</DialogDescription>
</DialogHeader>
)}
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>{t("groupManagement.groupsLabel")}</Label>
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-semibold">
{t("groups.pageTitle")}
</h2>
<p className="text-xs text-muted-foreground">
{t("groups.pageDescription")}
</p>
</div>
<RippleButton
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
className="flex gap-2 items-center"
className="flex gap-2 items-center shrink-0"
>
<GoPlus className="w-4 h-4" />
<GoPlus className="size-4" />
{t("proxies.management.create")}
</RippleButton>
</div>
@@ -290,142 +593,116 @@ export function GroupManagementDialog({
{t("groups.noGroupsDescription")}
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-20">
{t("groupManagement.profilesCol")}
</TableHead>
<TableHead className="w-24">
{t("proxies.management.syncCol")}
</TableHead>
<TableHead className="w-24">
{t("common.labels.actions")}
</TableHead>
<FadingScrollArea
className="flex-1 min-h-0"
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
{t("groupManagement.syncCannotDisable")}
</p>
) : (
<p>
{group.sync_enabled
? t("proxies.management.disableSync")
: t("proxies.management.enableSync")}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.editGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.deleteGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
{!subPage && (
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
{isOpen && (
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} />
<DataTableActionBarAction
tooltip={t("syncTooltips.bulkToggle")}
onClick={() => {
void handleBulkToggleSync();
}}
size="icon"
>
<LuRefreshCw />
</DataTableActionBarAction>
<DataTableActionBarAction
tooltip={t("common.buttons.delete")}
onClick={() => setBulkDeleteOpen(true)}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
>
<LuTrash2 />
</DataTableActionBarAction>
</DataTableActionBar>
)}
<DeleteConfirmationDialog
isOpen={bulkDeleteOpen}
onClose={() => {
if (!isBulkDeleting) setBulkDeleteOpen(false);
}}
onConfirm={handleBulkDelete}
title={t("groupManagement.bulkDelete.title")}
description={t("groupManagement.bulkDelete.description", {
count: selectedGroupsForBulk.length,
names: selectedNames,
})}
confirmButtonText={t("groupManagement.bulkDelete.confirmButton")}
isLoading={isBulkDeleting}
/>
<CreateGroupDialog
isOpen={createDialogOpen}
onClose={() => {
+281 -310
View File
@@ -1,245 +1,295 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuSearch,
LuUsers,
LuX,
} from "react-icons/lu";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import { getCurrentOS } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import type { GroupWithCount } from "@/types";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const CLICK_THRESHOLD = 5;
const CLICK_WINDOW_MS = 2000;
const GRAVITY = 2200;
const BOUNCE_DAMPING = 0.6;
const INITIAL_HORIZONTAL_SPEED = 350;
const SPIN_SPEED = 720;
const MIN_BOUNCE_VELOCITY = 60;
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
const HOLD_MS = 150;
const DRAG_THRESHOLD_PX = 3;
function useLogoEasterEgg() {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
const isTextInputTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
const el = target.closest(
"input, select, textarea, [contenteditable=''], [contenteditable='true']",
);
return el !== null;
};
const triggerFall = useCallback(() => {
const el = logoRef.current;
if (!el || isFalling) return;
setIsFalling(true);
const rect = el.getBoundingClientRect();
const startX = rect.left;
const startY = rect.top;
const floorY = window.innerHeight;
const leftWall = 0;
const rightWall = window.innerWidth;
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "fixed";
clone.style.left = `${startX}px`;
clone.style.top = `${startY}px`;
clone.style.zIndex = "9999";
clone.style.pointerEvents = "none";
clone.style.margin = "0";
document.body.appendChild(clone);
el.style.visibility = "hidden";
let x = 0;
let y = 0;
let vy = -500;
let vx = -INITIAL_HORIZONTAL_SPEED;
let rotation = 0;
let lastTime = performance.now();
const animate = (time: number) => {
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
// Floor bounce
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) {
vy = -Math.abs(vy) * BOUNCE_DAMPING;
} else {
vy = -MIN_BOUNCE_VELOCITY * 3;
}
}
// Left wall bounce only — right wall lets it fly off screen
const currentLeft = startX + x;
if (currentLeft <= leftWall && vx < 0) {
x = leftWall - startX;
vx = Math.abs(vx) * 1.1;
}
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
// Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow)
const currentTop = startY + y;
const offScreenRight = startX + x > rightWall + 50;
const offScreenBottom = currentTop > floorY + 100;
const offScreenTop = currentTop + rect.height < -200;
if (offScreenRight || offScreenBottom || offScreenTop) {
clone.remove();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
triggerFall();
} else {
setWobbleKey((k) => k + 1);
}
}, [isFalling, isHidden, triggerFall]);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
};
}
const ALL_FILTER_ID = "__all__";
interface Props {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
onGroupManagementDialogOpen: (open: boolean) => void;
onImportProfileDialogOpen: (open: boolean) => void;
onCreateProfileDialogOpen: (open: boolean) => void;
onSyncConfigDialogOpen: (open: boolean) => void;
onIntegrationsDialogOpen: (open: boolean) => void;
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
totalProfiles: number;
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
pageTitle?: string;
}
const HomeHeader = ({
onSettingsDialogOpen,
onProxyManagementDialogOpen,
onGroupManagementDialogOpen,
onImportProfileDialogOpen,
onCreateProfileDialogOpen,
onSyncConfigDialogOpen,
onIntegrationsDialogOpen,
onExtensionManagementDialogOpen,
searchQuery,
onSearchQueryChange,
groups,
totalProfiles,
selectedGroupId,
onGroupSelect,
pageTitle,
}: Props) => {
const { t } = useTranslation();
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
} = useLogoEasterEgg();
const [platform, setPlatform] = useState<string>("macos");
useEffect(() => {
setPlatform(getCurrentOS());
}, []);
const isMacOS = platform === "macos";
const showProfileToolbar = !pageTitle;
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragStartedRef = useRef(false);
const activePointerIdRef = useRef<number | null>(null);
const dragRootRef = useRef<HTMLDivElement | null>(null);
const clearHold = useCallback(() => {
if (holdTimeoutRef.current !== null) {
window.clearTimeout(holdTimeoutRef.current);
holdTimeoutRef.current = null;
}
}, []);
const beginDrag = useCallback(() => {
if (dragStartedRef.current) return;
dragStartedRef.current = true;
clearHold();
void getCurrentWindow().startDragging();
}, [clearHold]);
useEffect(() => {
return () => {
clearHold();
};
}, [clearHold]);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (isTextInputTarget(e.target)) return;
dragStartedRef.current = false;
dragStartRef.current = { x: e.clientX, y: e.clientY };
activePointerIdRef.current = e.pointerId;
clearHold();
holdTimeoutRef.current = window.setTimeout(() => {
holdTimeoutRef.current = null;
beginDrag();
}, HOLD_MS);
},
[beginDrag, clearHold],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (
dragStartedRef.current ||
dragStartRef.current === null ||
activePointerIdRef.current !== e.pointerId
) {
return;
}
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
if (Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
beginDrag();
}
},
[beginDrag],
);
const handlePointerEnd = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current !== e.pointerId) return;
clearHold();
dragStartRef.current = null;
activePointerIdRef.current = null;
dragStartedRef.current = false;
},
[clearHold],
);
// Horizontal scroll fades for the group filter strip — when the user
// has more groups than fit, the right edge fades to hint at overflow.
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
useEffect(() => {
const el = groupsScrollRef.current;
if (!el) return;
const update = () => {
setGroupsFadeLeft(el.scrollLeft > 1);
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, []);
const isWindows = platform === "windows";
return (
<div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center">
{!isHidden ? (
<button
ref={logoRef}
type="button"
className="p-1 cursor-pointer select-none"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
}}
onPointerUp={() => {
setIsPressed(false);
}}
onPointerLeave={() => {
setIsPressed(false);
<div
ref={dragRootRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
className={cn(
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
// Windows: WindowDragArea renders two 44px native-style controls
// (minimize + close) fixed at top-right with z-50, total 88px wide.
// Reserve 100px on the right edge so the "+ New" button and search
// input clear them with a few pixels of breathing room — issues
// #358, #361, #362 all reported the same overlap before this fix.
isWindows ? "pr-[100px]" : "pr-3",
)}
>
{isMacOS && (
<div
aria-hidden="true"
className="flex items-center gap-[7px] mr-1 shrink-0"
>
{/* Reserve space for the macOS native traffic lights the OS draws
the colored buttons here through the transparent titlebar. */}
<div className="w-[11px] h-[11px] rounded-full" />
<div className="w-[11px] h-[11px] rounded-full" />
<div className="w-[11px] h-[11px] rounded-full" />
</div>
)}
{pageTitle ? (
<span className="text-xs font-semibold text-card-foreground ml-2">
{pageTitle}
</span>
) : null}
{showProfileToolbar && (
<div className="relative flex-1 min-w-0 flex items-center">
{groupsFadeLeft && (
<button
type="button"
aria-label={t("header.scrollGroupsLeft")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: -el.clientWidth * 0.6,
behavior: "smooth",
});
}}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronLeft className="size-3" />
</button>
)}
<div
ref={groupsScrollRef}
className="flex items-center gap-3 ml-2 overflow-x-auto scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{
paddingLeft: groupsFadeLeft ? 22 : 0,
paddingRight: groupsFadeRight ? 22 : 0,
}}
>
<Logo
key={wobbleKey}
className={cn(
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
isPressed && "scale-90",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
/>
</button>
) : (
<div className="p-1 w-10 h-10" />
)}
<CardTitle>Donut</CardTitle>
</div>
<div className="flex gap-2 items-center">
<div className="relative">
{/* "All" filter — shows every profile regardless of group. */}
{(() => {
const active = selectedGroupId === ALL_FILTER_ID;
return (
<button
key="__all__"
type="button"
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
}}
className={cn(
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
active
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{t("groups.all")}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{totalProfiles}
</span>
</button>
);
})()}
{groups.map((group) => {
const active = selectedGroupId === group.id;
return (
<button
key={group.id}
type="button"
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
className={cn(
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
active
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
</button>
);
})}
</div>
{groupsFadeRight && (
<button
type="button"
aria-label={t("header.scrollGroupsRight")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: el.clientWidth * 0.6,
behavior: "smooth",
});
}}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronRight className="size-3" />
</button>
)}
</div>
)}
{!showProfileToolbar && <div className="flex-1" />}
{showProfileToolbar && (
<div className="relative shrink-0">
<Input
type="text"
placeholder={t("header.searchPlaceholder")}
@@ -247,122 +297,43 @@ const HomeHeader = ({
onChange={(e) => {
onSearchQueryChange(e.target.value);
}}
className="pr-8 pl-10 w-48"
className="pr-7 pl-8 w-52 h-7 text-xs"
/>
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
{searchQuery && (
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
{searchQuery ? (
<button
type="button"
onClick={() => {
onSearchQueryChange("");
}}
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
className="absolute right-1.5 top-1/2 p-0.5 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={t("header.clearSearch")}
>
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
<LuX className="size-3.5 text-muted-foreground hover:text-foreground" />
</button>
)}
) : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center h-[36px]"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t("header.moreActions")}</TooltipContent>
</Tooltip>
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onSettingsDialogOpen(true);
}}
>
<GoGear className="mr-2 w-4 h-4" />
{t("header.menu.settings")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onProxyManagementDialogOpen(true);
}}
>
<FiWifi className="mr-2 w-4 h-4" />
{t("header.menu.proxies")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onGroupManagementDialogOpen(true);
}}
>
<LuUsers className="mr-2 w-4 h-4" />
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onSyncConfigDialogOpen(true);
}}
>
<LuCloud className="mr-2 w-4 h-4" />
{t("header.menu.syncService")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onIntegrationsDialogOpen(true);
}}
>
<LuPlug className="mr-2 w-4 h-4" />
{t("header.menu.integrations")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onImportProfileDialogOpen(true);
}}
>
<FaDownload className="mr-2 w-4 h-4" />
{t("header.menu.importProfile")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{showProfileToolbar && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<span className="shrink-0">
<Button
size="sm"
onClick={() => {
onCreateProfileDialogOpen(true);
}}
className="flex gap-2 items-center h-[36px]"
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
>
<GoPlus className="w-4 h-4" />
<GoPlus className="size-3.5" />
{t("header.newProfile")}
</Button>
</span>
</TooltipTrigger>
<TooltipContent
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
{t("header.createProfile")}
</TooltipContent>
<TooltipContent>{t("header.createProfile")}</TooltipContent>
</Tooltip>
</div>
)}
</div>
);
};
+48 -39
View File
@@ -9,11 +9,16 @@ import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
@@ -30,6 +35,7 @@ import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -43,12 +49,14 @@ interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
crossOsUnlocked?: boolean;
subPage?: boolean;
}
export function ImportProfileDialog({
isOpen,
onClose,
crossOsUnlocked,
subPage,
}: ImportProfileDialogProps) {
const { t } = useTranslation();
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
@@ -292,39 +300,33 @@ export function ImportProfileDialog({
}, [isOpen, loadDetectedProfiles]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="flex-shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && (
<>
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
<AnimatedTabs
value={importMode}
onValueChange={(v) =>
setImportMode(v as "auto-detect" | "manual")
}
className="flex flex-col gap-6"
>
<AnimatedTabsList>
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
{t("importProfile.autoDetect")}
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
{t("importProfile.manualImport")}
</RippleButton>
</div>
</AnimatedTabsTrigger>
</AnimatedTabsList>
{importMode === "auto-detect" && (
<AnimatedTabsContent value="auto-detect">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")}
@@ -379,7 +381,7 @@ export function ImportProfileDialog({
>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
@@ -435,9 +437,9 @@ export function ImportProfileDialog({
</div>
)}
</div>
)}
</AnimatedTabsContent>
{importMode === "manual" && (
<AnimatedTabsContent value="manual">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
@@ -471,7 +473,7 @@ export function ImportProfileDialog({
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
@@ -503,7 +505,7 @@ export function ImportProfileDialog({
onClick={() => void handleBrowseFolder()}
title={t("importProfile.browseFolderTitle")}
>
<FaFolder className="w-4 h-4" />
<FaFolder className="size-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
@@ -535,8 +537,8 @@ export function ImportProfileDialog({
</div>
</div>
</div>
)}
</>
</AnimatedTabsContent>
</AnimatedTabs>
)}
{currentStep === "configure" && currentMappedBrowser && (
@@ -600,12 +602,19 @@ export function ImportProfileDialog({
)}
</div>
<DialogFooter className="flex-shrink-0">
<div
className={cn(
"flex-shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
)}
>
{currentStep === "select" ? (
<>
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
{!subPage && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
<RippleButton
disabled={!canProceedToNext}
onClick={() => {
@@ -635,7 +644,7 @@ export function ImportProfileDialog({
</LoadingButton>
</>
)}
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
+394 -290
View File
@@ -4,8 +4,23 @@ import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuAppWindow,
LuCheck,
LuCodeXml,
LuPlug,
LuTerminal,
LuTrash2,
LuZap,
} from "react-icons/lu";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -14,8 +29,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
@@ -33,14 +48,56 @@ interface McpConfig {
token: string;
}
type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext";
interface McpAgentInfo {
id: string;
display_name: string;
category: AgentCategory;
connected: boolean;
detected: boolean;
}
interface IntegrationsDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
}
function AgentIcon({ category }: { category: AgentCategory }) {
const className = "size-4 text-muted-foreground";
switch (category) {
case "desktop-app":
return <LuAppWindow className={className} />;
case "editor":
return <LuCodeXml className={className} />;
case "editor-ext":
return <LuPlug className={className} />;
case "cli":
return <LuTerminal className={className} />;
}
}
function categoryLabel(
t: (k: string) => string,
category: AgentCategory,
): string {
switch (category) {
case "desktop-app":
return t("integrations.mcp.category.desktopApp");
case "editor":
return t("integrations.mcp.category.editor");
case "editor-ext":
return t("integrations.mcp.category.editorExt");
case "cli":
return t("integrations.mcp.category.cli");
}
}
export function IntegrationsDialog({
isOpen,
onClose,
subPage,
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
@@ -55,11 +112,11 @@ export function IntegrationsDialog({
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
const [, setMcpRunning] = useState(false);
const [showApiToken, setShowApiToken] = useState(false);
const [showMcpToken, setShowMcpToken] = useState(false);
const [showMcpUrl, setShowMcpUrl] = useState(false);
const [isApiStarting, setIsApiStarting] = useState(false);
const [isMcpStarting, setIsMcpStarting] = useState(false);
const [mcpInClaudeDesktop, setMcpInClaudeDesktop] = useState(false);
const [mcpInClaudeCode, setMcpInClaudeCode] = useState(false);
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const { termsAccepted } = useWayfernTerms();
@@ -99,21 +156,12 @@ export function IntegrationsDialog({
}
}, []);
const loadClaudeDesktopStatus = useCallback(async () => {
const loadAgents = useCallback(async () => {
try {
const exists = await invoke<boolean>("is_mcp_in_claude_desktop");
setMcpInClaudeDesktop(exists);
} catch {
// Not critical
}
}, []);
const loadClaudeCodeStatus = useCallback(async () => {
try {
const exists = await invoke<boolean>("is_mcp_in_claude_code");
setMcpInClaudeCode(exists);
} catch {
// Claude CLI may not be installed
const list = await invoke<McpAgentInfo[]>("list_mcp_agents");
setAgents(list);
} catch (e) {
console.error("Failed to list MCP agents:", e);
}
}, []);
@@ -123,8 +171,7 @@ export function IntegrationsDialog({
void loadApiServerStatus();
void loadMcpConfig();
void loadMcpServerStatus();
void loadClaudeDesktopStatus();
void loadClaudeCodeStatus();
void loadAgents();
}
}, [
isOpen,
@@ -132,8 +179,7 @@ export function IntegrationsDialog({
loadApiServerStatus,
loadMcpConfig,
loadMcpServerStatus,
loadClaudeDesktopStatus,
loadClaudeCodeStatus,
loadAgents,
]);
const handleApiToggle = async (enabled: boolean) => {
@@ -179,6 +225,7 @@ export function IntegrationsDialog({
});
setSettings(next);
void loadMcpConfig();
void loadAgents();
showSuccessToast(t("integrations.mcpStarted", { port }));
} else {
await invoke("stop_mcp_server");
@@ -200,213 +247,298 @@ export function IntegrationsDialog({
}
};
const markAgentBusy = (id: string, busy: boolean) => {
setBusyAgentIds((prev) => {
const next = new Set(prev);
if (busy) next.add(id);
else next.delete(id);
return next;
});
};
const handleAddAgent = async (agent: McpAgentInfo) => {
markAgentBusy(agent.id, true);
try {
await invoke("add_mcp_to_agent", { agentId: agent.id });
showSuccessToast(
t("integrations.mcp.addedToClient", { name: agent.display_name }),
);
void loadAgents();
} catch (e) {
showErrorToast(translateBackendError(t, e), {
description: agent.display_name,
});
} finally {
markAgentBusy(agent.id, false);
}
};
const handleRemoveAgent = async (agent: McpAgentInfo) => {
markAgentBusy(agent.id, true);
try {
await invoke("remove_mcp_from_agent", { agentId: agent.id });
showSuccessToast(
t("integrations.mcp.removedFromClient", { name: agent.display_name }),
);
void loadAgents();
} catch (e) {
showErrorToast(translateBackendError(t, e), {
description: agent.display_name,
});
} finally {
markAgentBusy(agent.id, false);
}
};
const mcpUrl = mcpConfig
? `http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`
: "";
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
subPage={subPage}
>
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
<DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
</TabsList>
<AnimatedTabs defaultValue="api">
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="api" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="api-enabled"
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(!!checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
<AnimatedTabsContent
value="api"
className="mt-4 flex flex-col gap-4"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuPlug className="size-5 mt-0.5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
</div>
</div>
<AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(checked)}
/>
</div>
{apiServerPort && (
<div className="flex items-center gap-2 text-xs">
<span className="size-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">
{t("integrations.apiRunningOn")}
</span>
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
http://127.0.0.1:{apiServerPort}
</code>
</div>
)}
</div>
{settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center space-x-2">
<Button
size="sm"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{t("common.buttons.save")}
</Button>
<Input
type="number"
value={settings.api_port}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, api_port: val });
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
{apiServerPort && (
<span className="text-xs text-muted-foreground">
{t("common.status.running")}
</span>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiTokenLabel")}
</Label>
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
type="number"
value={settings.api_port}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, api_port: val });
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
variant="outline"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{showApiToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{t("common.buttons.save")}
</Button>
</div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiTokenLabel")}
</Label>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
}}
>
{showApiToken ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
/>
</div>
</div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiExampleRequest")}
</Label>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("common.buttons.copied")}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("integrations.apiTokenHint", {
tokenSlot: "<token>",
})}
</p>
<pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
{`curl -H "Authorization: Bearer \${TOKEN}" \\
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
</pre>
</div>
</div>
</>
)}
</TabsContent>
</AnimatedTabsContent>
<TabsContent value="mcp" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="mcp-enabled"
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(!!checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
<AnimatedTabsContent
value="mcp"
className="mt-4 flex flex-col gap-5"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuZap className="size-5 mt-0.5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
</div>
</div>
<AnimatedSwitch
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(checked)}
/>
</div>
</div>
{mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
<>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.url")}
</Label>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="relative flex-1">
<Input
type={showMcpToken ? "text" : "password"}
value={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
type={showMcpUrl ? "text" : "password"}
value={mcpUrl}
readOnly
className="font-mono text-xs pr-10"
/>
@@ -416,116 +548,88 @@ export function IntegrationsDialog({
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowMcpToken(!showMcpToken);
setShowMcpUrl(!showMcpUrl);
}}
>
{showMcpToken ? (
<EyeOff className="h-4 w-4" />
{showMcpUrl ? (
<EyeOff className="size-4" />
) : (
<Eye className="h-4 w-4" />
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
text={mcpUrl}
successMessage={t("integrations.mcp.urlCopied")}
/>
</div>
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeDesktopTitle")}
</p>
{mcpInClaudeDesktop ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_desktop");
setMcpInClaudeDesktop(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeDesktop")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_desktop");
setMcpInClaudeDesktop(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeDesktop")}
</Button>
)}
<div className="flex flex-col gap-3">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.clientsLabel")}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{agents.map((agent) => {
const busy = busyAgentIds.has(agent.id);
return (
<div
key={agent.id}
className="rounded-md border bg-card px-3 py-2.5 flex items-center gap-3"
>
<div className="grid place-items-center size-8 rounded-md bg-muted shrink-0">
<AgentIcon category={agent.category} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{agent.display_name}
</p>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{categoryLabel(t, agent.category)}
</p>
</div>
{agent.connected ? (
<div className="flex items-center gap-1">
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-foreground">
<LuCheck className="size-3" />
{t("integrations.mcp.connected")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void handleRemoveAgent(agent)}
aria-label={t(
"integrations.mcp.removeAriaLabel",
{
name: agent.display_name,
},
)}
>
<LuTrash2 className="size-4" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void handleAddAgent(agent)}
>
{t("integrations.mcp.add")}
</Button>
)}
</div>
);
})}
</div>
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeCodeTitle")}
</p>
{mcpInClaudeCode ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_code");
setMcpInClaudeCode(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeCode")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_code");
setMcpInClaudeCode(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeCode")}
</Button>
)}
</div>
</div>
</>
)}
</TabsContent>
</Tabs>
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+1 -1
View File
@@ -17,7 +17,7 @@ export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
disabled={props.disabled || isLoading}
>
{isLoading ? (
<LuLoaderCircle className="h-4 w-4 animate-spin" />
<LuLoaderCircle className="size-4 animate-spin" />
) : (
props.children
)}
+4 -4
View File
@@ -26,6 +26,10 @@ interface LocationProxyDialogProps {
onClose: () => void;
}
function LoadingSpinner() {
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
}
export function LocationProxyDialog({
isOpen,
onClose,
@@ -219,10 +223,6 @@ export function LocationProxyDialog({
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
const LoadingSpinner = () => (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
+1 -1
View File
@@ -434,7 +434,7 @@ const MultipleSelector = React.forwardRef<
handleUnselect(option);
}}
>
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
<LuX className="size-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
+99 -29
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
@@ -21,7 +21,14 @@ interface PermissionDialogProps {
isOpen: boolean;
onClose: () => void;
permissionType: PermissionType;
onPermissionGranted?: () => void;
/**
* Fired when the displayed permission becomes granted. The just-granted
* type is passed through so the parent can act optimistically its own
* usePermissions instance polls on a 5 s cadence and would otherwise be
* stale right after the macOS system prompt is accepted, leaving the
* dialog open in a confusing state.
*/
onPermissionGranted?: (justGranted: PermissionType) => void;
}
export function PermissionDialog({
@@ -32,6 +39,7 @@ export function PermissionDialog({
}: PermissionDialogProps) {
const { t } = useTranslation();
const [isRequesting, setIsRequesting] = useState(false);
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
const {
requestPermission,
@@ -57,19 +65,75 @@ export function PermissionDialog({
? isMicrophoneAccessGranted
: isCameraAccessGranted;
// Auto-close dialog when permission is granted
// Mirror the latest permission state into a ref so the deferred timeout
// callback can read it without being recreated on every state change.
const isCurrentPermissionGrantedRef = useRef(isCurrentPermissionGranted);
useEffect(() => {
if (isCurrentPermissionGranted && isOpen) {
onPermissionGranted?.();
isCurrentPermissionGrantedRef.current = isCurrentPermissionGranted;
}, [isCurrentPermissionGranted]);
// When the permission becomes granted, fire a success toast and let the
// parent decide what to do next (progress to the other permission, or close).
// We deliberately do NOT keep the dialog around to show a "Done" state —
// the toast is the confirmation, and the dialog closes immediately.
// Use a ref to ensure we only fire the toast once per grant transition.
const grantedToastFiredForRef = useRef<PermissionType | null>(null);
useEffect(() => {
if (!isOpen) {
grantedToastFiredForRef.current = null;
return;
}
}, [isCurrentPermissionGranted, isOpen, onPermissionGranted]);
if (
isCurrentPermissionGranted &&
grantedToastFiredForRef.current !== permissionType
) {
grantedToastFiredForRef.current = permissionType;
showSuccessToast(
permissionType === "microphone"
? t("permissionDialog.grantedToastMicrophone")
: t("permissionDialog.grantedToastCamera"),
);
onPermissionGranted?.(permissionType);
}
}, [
isCurrentPermissionGranted,
isOpen,
onPermissionGranted,
permissionType,
t,
]);
// Pending-grant timeout: triggered after the user clicks "Grant Access"
// to give the macOS permission state a few seconds to propagate to our poll.
const waitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// If permission becomes granted during the wait window, end the wait early.
useEffect(() => {
if (isWaitingForGrant && isCurrentPermissionGranted) {
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
waitTimeoutRef.current = null;
}
setIsWaitingForGrant(false);
}
}, [isWaitingForGrant, isCurrentPermissionGranted]);
// Clear any pending timeout on unmount.
useEffect(() => {
return () => {
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
waitTimeoutRef.current = null;
}
};
}, []);
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-8 h-8" />;
return <BsMic className="size-8" />;
case "camera":
return <BsCamera className="w-8 h-8" />;
return <BsCamera className="size-8" />;
}
};
@@ -95,11 +159,25 @@ export function PermissionDialog({
setIsRequesting(true);
try {
await requestPermission(permissionType);
showSuccessToast(
permissionType === "microphone"
? t("permissionDialog.requestSuccessMicrophone")
: t("permissionDialog.requestSuccessCamera"),
);
// The macOS permission poll runs every 5 s, so the new state can take
// a moment to surface. Keep the grant button in its busy state for
// that window so the user has clear feedback, and notify them if the
// grant still hasn't landed by the end.
setIsWaitingForGrant(true);
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
}
waitTimeoutRef.current = setTimeout(() => {
waitTimeoutRef.current = null;
setIsWaitingForGrant(false);
if (!isCurrentPermissionGrantedRef.current) {
showErrorToast(
permissionType === "microphone"
? t("permissionDialog.stillNotGrantedMicrophone")
: t("permissionDialog.stillNotGrantedCamera"),
);
}
}, 5000);
} catch (error) {
console.error("Failed to request permission:", error);
showErrorToast(t("permissionDialog.requestFailed"));
@@ -117,7 +195,7 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-primary/15 rounded-full">
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
@@ -129,16 +207,6 @@ export function PermissionDialog({
</DialogHeader>
<div className="space-y-4">
{isCurrentPermissionGranted && (
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-sm text-success">
{permissionType === "microphone"
? t("permissionDialog.grantedMicrophone")
: t("permissionDialog.grantedCamera")}
</p>
</div>
)}
{!isCurrentPermissionGranted && (
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-sm text-warning">
@@ -151,15 +219,17 @@ export function PermissionDialog({
</div>
<DialogFooter className="gap-2">
<RippleButton variant="outline" onClick={onClose}>
{isCurrentPermissionGranted
? t("permissionDialog.doneButton")
: t("permissionDialog.cancelButton")}
<RippleButton
variant="outline"
onClick={onClose}
className="min-w-24"
>
{t("permissionDialog.cancelButton")}
</RippleButton>
{!isCurrentPermissionGranted && (
<LoadingButton
isLoading={isRequesting}
isLoading={isRequesting || isWaitingForGrant}
onClick={() => {
handleRequestPermission().catch((err: unknown) => {
console.error(err);
+472 -139
View File
@@ -9,6 +9,7 @@ import {
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
@@ -23,7 +24,9 @@ import {
LuCookie,
LuInfo,
LuLock,
LuPlay,
LuPuzzle,
LuSquare,
LuTrash2,
LuTriangleAlert,
LuUsers,
@@ -51,7 +54,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
@@ -68,12 +70,12 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { useTeamLocks } from "@/hooks/use-team-locks";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import {
getBrowserDisplayName,
getCurrentOS,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
@@ -83,6 +85,7 @@ import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
ExtensionGroup,
LocationItem,
ProxyCheckResult,
StoredProxy,
@@ -154,6 +157,15 @@ interface TableMeta {
vpnId: string | null,
) => void | Promise<void>;
// Extension groups (for Ext column lookup)
extensionGroups: ExtensionGroup[];
// Click handlers for inline Ext / DNS cell editing
onAssignExtensionGroup?: (profileIds: string[]) => void;
setDnsBlocklistProfile: React.Dispatch<
React.SetStateAction<BrowserProfile | null>
>;
// Selection helpers
isProfileSelected: (id: string) => boolean;
handleToggleAll: (checked: boolean) => void;
@@ -298,6 +310,189 @@ function getProfileSyncStatusDot(
}
}
// Inline extension-group dropdown for the Ext column. Matches the
// proxy column's Popover-style picker — no nested dialog.
function ExtCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const groupId = profile.extension_group_id ?? null;
const group = groupId
? meta.extensionGroups.find((g) => g.id === groupId)
: undefined;
const label = group?.name ?? meta.t("profiles.table.extDefault");
const onPick = async (nextId: string | null) => {
setIsSaving(true);
try {
await invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: nextId,
});
} catch (err) {
console.error("Failed to assign extension group:", err);
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
>
<LuPuzzle className="size-3 shrink-0" />
<span className="truncate flex-1" title={label}>
{label}
</span>
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
<Command>
<CommandInput placeholder={meta.t("profiles.table.extSearch")} />
<CommandList>
<CommandEmpty>{meta.t("profiles.table.extEmpty")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="__default__"
onSelect={() => {
void onPick(null);
}}
>
{groupId === null && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === null ? "" : "ml-5"}>
{meta.t("profiles.table.extDefault")}
</span>
</CommandItem>
{meta.extensionGroups.map((g) => (
<CommandItem
key={g.id}
value={g.name}
onSelect={() => {
void onPick(g.id);
}}
>
{groupId === g.id && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === g.id ? "" : "ml-5"}>
{g.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// Inline DNS blocklist dropdown — same Popover/Command pattern as Ext.
function DnsCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const level = profile.dns_blocklist ?? null;
// Backend levels are: light, normal, pro, pro_plus, ultimate (+ null).
// Keep the list ordered from least to most restrictive.
const LEVELS: { value: string; labelKey: string }[] = [
{ value: "light", labelKey: "dnsBlocklist.light" },
{ value: "normal", labelKey: "dnsBlocklist.normal" },
{ value: "pro", labelKey: "dnsBlocklist.pro" },
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
];
const currentLabel =
level === null
? null
: (LEVELS.find((l) => l.value === level)?.labelKey ?? null);
const onPick = async (nextLevel: string | null) => {
setIsSaving(true);
try {
await invoke("update_profile_dns_blocklist", {
profileId: profile.id,
dnsBlocklist: nextLevel,
});
} catch (err) {
console.error("Failed to update DNS blocklist:", err);
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
title={
level
? meta.t("profiles.table.dnsLevel", { level })
: meta.t("dnsBlocklist.none")
}
>
<FiWifi className="size-3 shrink-0" />
<span className="flex-1 truncate text-[11px] tracking-wide">
{currentLabel ? meta.t(currentLabel) : "—"}
</span>
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
void onPick(null);
}}
>
{level === null && <LuCheck className="mr-2 size-3.5" />}
<span className={level === null ? "" : "ml-5"}>
{meta.t("dnsBlocklist.none")}
</span>
</CommandItem>
{LEVELS.map((l) => (
<CommandItem
key={l.value}
value={l.value}
onSelect={() => {
void onPick(l.value);
}}
>
{level === l.value && <LuCheck className="mr-2 size-3.5" />}
<span className={level === l.value ? "" : "ml-5"}>
{meta.t(l.labelKey)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TagsCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
@@ -854,6 +1049,9 @@ interface ProfilesDataTableProps {
}
| undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void;
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
}
export function ProfilesDataTable({
@@ -883,6 +1081,9 @@ export function ProfilesDataTable({
syncUnlocked = false,
getProfileSyncInfo,
onLaunchWithSync,
onSetPassword,
onChangePassword,
onRemovePassword,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -907,6 +1108,13 @@ export function ProfilesDataTable({
}
setRowSelection(newSelection);
prevSelectedProfilesRef.current = selectedProfiles;
// When the parent clears the selection (e.g. after a bulk action like
// delete / move-to-group), collapse the checkbox column back to icons.
// Otherwise the row checkboxes stay visible and only revert after the
// user clicks one — which the per-checkbox handler resets.
if (selectedProfiles.length === 0) {
setShowCheckboxes(false);
}
}
}, [selectedProfiles]);
@@ -1010,6 +1218,36 @@ export function ProfilesDataTable({
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
// Extension groups for the Ext column lookup. Refreshed when the
// backend emits 'extensions-changed' (group rename/create/delete).
const [extensionGroups, setExtensionGroups] = React.useState<
ExtensionGroup[]
>([]);
React.useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
const load = async () => {
try {
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
if (mounted) setExtensionGroups(data);
} catch (e) {
console.error("Failed to load extension groups:", e);
}
};
void load();
void listen("extensions-changed", () => {
void load();
}).then((u) => {
if (mounted) unlisten = u;
else u();
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
@@ -1539,6 +1777,11 @@ export function ProfilesDataTable({
vpnOverrides,
handleVpnSelection,
// Extension groups
extensionGroups,
onAssignExtensionGroup,
setDnsBlocklistProfile,
// Selection helpers
isProfileSelected: (id: string) => selectedProfiles.includes(id),
handleToggleAll,
@@ -1630,6 +1873,8 @@ export function ProfilesDataTable({
vpnConfigs,
vpnOverrides,
handleVpnSelection,
extensionGroups,
onAssignExtensionGroup,
handleToggleAll,
handleCheckboxChange,
handleIconClick,
@@ -1717,7 +1962,7 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
@@ -1726,9 +1971,9 @@ export function ProfilesDataTable({
}}
aria-label={t("common.aria.selectProfile")}
>
<span className="w-4 h-4 group">
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
<span className="size-4 group">
<OsIcon className="size-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1756,14 +2001,14 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value);
}}
aria-label={t("common.aria.selectRow")}
className="w-4 h-4"
className="size-4"
/>
</span>
</NonHoverableTooltip>
@@ -1782,9 +2027,9 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4 cursor-not-allowed">
<span className="flex justify-center items-center size-4 cursor-not-allowed">
{IconComponent && (
<IconComponent className="w-4 h-4 opacity-50" />
<IconComponent className="size-4 opacity-50" />
)}
</span>
</TooltipTrigger>
@@ -1804,14 +2049,14 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value);
}}
aria-label={t("common.aria.selectRow")}
className="w-4 h-4"
className="size-4"
/>
</span>
</NonHoverableTooltip>
@@ -1824,7 +2069,7 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex relative justify-center items-center w-4 h-4">
<span className="flex relative justify-center items-center size-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
@@ -1833,11 +2078,11 @@ export function ProfilesDataTable({
}}
aria-label={t("common.aria.selectProfile")}
>
<span className="w-4 h-4 group">
<span className="size-4 group">
{IconComponent && (
<IconComponent className="w-4 h-4 group-hover:hidden" />
<IconComponent className="size-4 group-hover:hidden" />
)}
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1846,11 +2091,11 @@ export function ProfilesDataTable({
},
enableSorting: false,
enableHiding: false,
size: 40,
size: 28,
},
{
id: "actions",
size: 100,
size: 48,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
@@ -1951,7 +2196,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="w-4 h-4 text-warning" />
<LuTriangleAlert className="size-4 text-warning" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -1968,11 +2213,18 @@ export function ProfilesDataTable({
variant={buttonVariant}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
aria-label={
isRunning
? meta.t("profiles.actions.stop")
: meta.t("profiles.actions.launch")
}
className={cn(
"min-w-[80px] h-7 px-3",
"size-7 p-0 grid place-items-center",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
isRunning &&
"bg-destructive/10 text-destructive hover:bg-destructive/20",
)}
onClick={() =>
isRunning
@@ -1981,13 +2233,11 @@ export function ProfilesDataTable({
}
>
{isLaunching || isStopping ? (
<div className="flex gap-1 items-center">
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
</div>
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : isRunning ? (
meta.t("profiles.actions.stop")
<LuSquare className="size-3.5 fill-current" />
) : (
meta.t("profiles.actions.launch")
<LuPlay className="size-3.5 fill-current" />
)}
</RippleButton>
</span>
@@ -2017,9 +2267,9 @@ export function ProfilesDataTable({
>
{meta.t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 w-4 h-4" />
<LuChevronUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 w-4 h-4" />
<LuChevronDown className="ml-2 size-4" />
) : null}
</Button>
);
@@ -2077,11 +2327,15 @@ export function ProfilesDataTable({
const display =
name.length < 14 ? (
<div className="font-medium text-left leading-none">{name}</div>
<div className="font-medium text-left leading-none truncate">
{name}
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="leading-none">{trimName(name, 14)}</span>
<span className="leading-none block truncate">
{trimName(name, 14)}
</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
@@ -2099,11 +2353,11 @@ export function ProfilesDataTable({
const isLocked = meta.isProfileLockedByAnother(profile.id);
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5 min-w-0 max-w-full overflow-hidden">
<button
type="button"
className={cn(
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none h-6 min-w-0 max-w-full overflow-hidden",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
@@ -2130,7 +2384,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuLock className="w-3 h-3 text-muted-foreground" />
<LuLock className="size-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -2144,7 +2398,7 @@ export function ProfilesDataTable({
},
{
id: "tags",
size: 110,
size: 100,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.tagsHeader");
@@ -2177,7 +2431,7 @@ export function ProfilesDataTable({
},
{
id: "note",
size: 110,
size: 80,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.noteHeader");
@@ -2208,7 +2462,7 @@ export function ProfilesDataTable({
},
{
id: "proxy",
size: 130,
size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.proxy");
@@ -2267,17 +2521,19 @@ export function ProfilesDataTable({
(snapshot?.current_bytes_received ?? 0);
return (
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
<div className="overflow-hidden min-w-0">
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
</div>
);
}
return (
<div className="flex gap-2 items-center">
<div className="flex overflow-hidden gap-2 items-center min-w-0">
<Popover
open={isSelectorOpen}
onOpenChange={(open) => {
@@ -2352,7 +2608,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedId === null
? "opacity-100"
: "opacity-0",
@@ -2379,7 +2635,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
effectiveProxyId === proxy.id &&
!effectiveVpn
? "opacity-100"
@@ -2405,7 +2661,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
effectiveVpnId === vpn.id
? "opacity-100"
: "opacity-0",
@@ -2447,7 +2703,7 @@ export function ProfilesDataTable({
)
}
>
<span className="mr-2 h-4 w-4" />+{" "}
<span className="mr-2 size-4" />+{" "}
{country.name}
</CommandItem>
))}
@@ -2483,10 +2739,36 @@ export function ProfilesDataTable({
);
},
},
{
id: "ext",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.ext");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <ExtCell profile={profile} meta={meta} />;
},
},
{
id: "dns",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.dns");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <DnsCell profile={profile} meta={meta} />;
},
},
{
id: "sync",
header: "",
size: 24,
size: 28,
cell: ({ row, table }) => {
const profile = row.original;
const meta = table.options.meta as TableMeta;
@@ -2510,14 +2792,14 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-3 h-3">
<span className="flex justify-center items-center h-9 w-full">
{dot.encrypted ? (
<LuLock
className={`w-3 h-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
className={`size-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
/>
) : (
<span
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
className={`size-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
/>
)}
</span>
@@ -2529,16 +2811,16 @@ export function ProfilesDataTable({
},
{
id: "settings",
size: 40,
size: 32,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return (
<div className="flex justify-end items-center">
<div className="flex justify-end items-center h-9 w-full">
<Button
variant="ghost"
className="p-0 w-8 h-8"
className="p-0 size-7"
disabled={!meta.isClient}
onClick={() => {
setProfileForInfoDialog(profile);
@@ -2547,7 +2829,7 @@ export function ProfilesDataTable({
<span className="sr-only">
{t("profiles.aria.profileInfo")}
</span>
<LuInfo className="w-4 h-4" />
<LuInfo className="size-4" />
</Button>
</div>
);
@@ -2580,98 +2862,144 @@ export function ProfilesDataTable({
meta: tableMeta,
});
const platform = getCurrentOS();
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
const sortedRows = table.getRowModel().rows;
useScrollFade(scrollParentRef);
// Compact 36px row from the redesign spec; estimateSize must match the
// actual rendered row height or virtualizer placement drifts under scroll.
const ROW_HEIGHT = 36;
const rowVirtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - virtualRows[virtualRows.length - 1].end
: 0;
return (
<>
<ScrollArea
className={cn(
"rounded-md border [&>div[data-slot='scroll-area-viewport']>div]:overflow-visible",
platform === "macos" ? "h-[340px]" : "h-[280px]",
)}
>
<Table className="overflow-visible table-fixed">
<TableHeader className="overflow-visible">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="overflow-visible">
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-visible">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
className={cn(
"overflow-visible hover:bg-accent/50",
rowIsCrossOs && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-visible"
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollParentRef}
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
style={
{
// Sticky table header is 32px tall (h-8); shift the top
// fade band below it so the header stays fully opaque and
// only body rows fade as they scroll past.
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table className="table-fixed">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="overflow-visible !border-0"
>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-visible">
{sortedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
</TableCell>
</TableRow>
) : (
<>
{paddingTop > 0 && (
<tr style={{ height: `${paddingTop}px` }}>
<td colSpan={columns.length} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = sortedRows[virtualRow.index];
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
style={{ height: `${ROW_HEIGHT}px` }}
className={cn(
"overflow-visible hover:bg-accent/50 !border-0",
rowIsCrossOs && "opacity-60",
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-visible py-0"
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<tr style={{ height: `${paddingBottom}px` }}>
<td colSpan={columns.length} />
</tr>
)}
</>
)}
</TableBody>
</Table>
</div>
</div>
<DeleteConfirmationDialog
isOpen={profileToDelete !== null}
onClose={() => {
@@ -2687,7 +3015,9 @@ export function ProfilesDataTable({
/>
{profileForInfoDialog &&
(() => {
const infoProfile = profileForInfoDialog;
const infoProfile =
profiles.find((p) => p.id === profileForInfoDialog.id) ??
profileForInfoDialog;
const infoIsRunning =
browserState.isClient && runningProfiles.has(infoProfile.id);
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
@@ -2725,6 +3055,9 @@ export function ProfilesDataTable({
}}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onSetPassword={onSetPassword}
onChangePassword={onChangePassword}
onRemovePassword={onRemovePassword}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
setProfileToDelete(profile);
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
extractLockoutSeconds,
formatLockoutDuration,
translateBackendError,
} from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { BrowserProfile } from "@/types";
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
export type PasswordDialogMode = "set" | "unlock" | "change" | "remove";
interface ProfilePasswordDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
mode: PasswordDialogMode;
onSuccess?: (profile: BrowserProfile) => void;
}
const MIN_LEN = 8;
export function ProfilePasswordDialog({
isOpen,
onClose,
profile,
mode,
onSuccess,
}: ProfilePasswordDialogProps) {
const { t } = useTranslation();
const [oldPassword, setOldPassword] = React.useState("");
const [password, setPassword] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [lockoutSecondsRemaining, setLockoutSecondsRemaining] = React.useState<
number | null
>(null);
const firstInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (!isOpen) return;
setOldPassword("");
setPassword("");
setConfirm("");
setIsSubmitting(false);
setLockoutSecondsRemaining(null);
const handle = window.setTimeout(() => firstInputRef.current?.focus(), 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen]);
// Tick down the lockout timer
React.useEffect(() => {
if (lockoutSecondsRemaining == null) return;
if (lockoutSecondsRemaining <= 0) {
setLockoutSecondsRemaining(null);
return;
}
const handle = window.setTimeout(() => {
setLockoutSecondsRemaining((prev) => (prev == null ? null : prev - 1));
}, 1000);
return () => {
window.clearTimeout(handle);
};
}, [lockoutSecondsRemaining]);
if (!profile) return null;
const needsConfirm = mode === "set" || mode === "change";
const needsOldPassword = mode === "change" || mode === "remove";
const validate = (): string | null => {
if (needsOldPassword && !oldPassword) {
return t("profilePassword.errors.oldPasswordRequired");
}
if (mode === "set" || mode === "change") {
if (password.length < MIN_LEN) {
return t("profilePassword.errors.tooShort", { min: MIN_LEN });
}
if (password !== confirm) {
return t("profilePassword.errors.mismatch");
}
}
if (mode === "unlock" && !password) {
return t("profilePassword.errors.passwordRequired");
}
if (mode === "remove" && !oldPassword) {
return t("profilePassword.errors.passwordRequired");
}
return null;
};
const handleSubmit = async () => {
if (isSubmitting || lockoutSecondsRemaining != null) return;
const error = validate();
if (error) {
showErrorToast(error);
return;
}
setIsSubmitting(true);
try {
switch (mode) {
case "set":
await invoke("set_profile_password", {
profileId: profile.id,
password,
});
showSuccessToast(t("profilePassword.toasts.set"));
break;
case "unlock":
await invoke("unlock_profile", {
profileId: profile.id,
password,
});
break;
case "change":
await invoke("change_profile_password", {
profileId: profile.id,
oldPassword,
newPassword: password,
});
showSuccessToast(t("profilePassword.toasts.changed"));
break;
case "remove":
await invoke("remove_profile_password", {
profileId: profile.id,
password: oldPassword,
});
showSuccessToast(t("profilePassword.toasts.removed"));
break;
}
onSuccess?.(profile);
onClose();
} catch (err: unknown) {
const lockoutSeconds = extractLockoutSeconds(err);
if (lockoutSeconds != null) {
setLockoutSecondsRemaining(lockoutSeconds);
} else {
showErrorToast(translateBackendError(t, err));
}
} finally {
setIsSubmitting(false);
}
};
const titleKey =
mode === "set"
? "profilePassword.set.title"
: mode === "unlock"
? "profilePassword.unlock.title"
: mode === "change"
? "profilePassword.change.title"
: "profilePassword.remove.title";
const descriptionKey =
mode === "set"
? "profilePassword.set.description"
: mode === "unlock"
? "profilePassword.unlock.description"
: mode === "change"
? "profilePassword.change.description"
: "profilePassword.remove.description";
const submitLabelKey =
mode === "set"
? "profilePassword.set.button"
: mode === "unlock"
? "profilePassword.unlock.button"
: mode === "change"
? "profilePassword.change.button"
: "profilePassword.remove.button";
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t(titleKey)}</DialogTitle>
<DialogDescription>
{t(descriptionKey, { name: profile.name })}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{(mode === "set" || mode === "change") && (
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
<p className="font-medium text-warning-foreground">
{t("profilePassword.warnings.forgetWarningTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("profilePassword.warnings.forgetWarningBody")}
</p>
</div>
)}
{lockoutSecondsRemaining != null && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{t("backendErrors.lockedOut", {
duration: formatLockoutDuration(t, lockoutSecondsRemaining),
})}
</div>
)}
{needsOldPassword && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="profile-pw-old">
{mode === "remove"
? t("profilePassword.fields.password")
: t("profilePassword.fields.currentPassword")}
</Label>
<Input
ref={firstInputRef}
id="profile-pw-old"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSubmit();
}}
disabled={isSubmitting}
autoComplete="current-password"
/>
</div>
)}
{(mode === "set" || mode === "change" || mode === "unlock") && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="profile-pw-new">
{mode === "unlock"
? t("profilePassword.fields.password")
: t("profilePassword.fields.newPassword")}
</Label>
<Input
ref={!needsOldPassword ? firstInputRef : undefined}
id="profile-pw-new"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSubmit();
}}
disabled={isSubmitting}
autoComplete={
mode === "unlock" ? "current-password" : "new-password"
}
/>
</div>
)}
{needsConfirm && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="profile-pw-confirm">
{t("profilePassword.fields.confirm")}
</Label>
<Input
id="profile-pw-confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSubmit();
}}
disabled={isSubmitting}
autoComplete="new-password"
/>
</div>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
onClick={() => void handleSubmit()}
isLoading={isSubmitting}
disabled={lockoutSecondsRemaining != null}
variant={mode === "remove" ? "destructive" : "default"}
>
{t(submitLabelKey)}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -237,7 +237,7 @@ export function ProfileSelectorDialog({
profile.browser,
);
return IconComponent ? (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
) : null;
})()}
</div>
+4 -4
View File
@@ -188,7 +188,7 @@ export function ProfileSyncDialog({
{isCheckingConfig ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
@@ -216,7 +216,7 @@ export function ProfileSyncDialog({
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
@@ -228,7 +228,7 @@ export function ProfileSyncDialog({
</Label>
</div>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
@@ -240,7 +240,7 @@ export function ProfileSyncDialog({
</Label>
</div>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem
value="Encrypted"
id="sync-encrypted"
+12 -6
View File
@@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { toast } from "sonner";
@@ -55,6 +55,7 @@ export function ProxyAssignmentDialog({
vpnConfigs = [],
}: ProxyAssignmentDialogProps) {
const { t } = useTranslation();
const proxyListboxId = useId();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
"none",
@@ -183,6 +184,7 @@ export function ProxyAssignmentDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxId}
className="w-full justify-between font-normal"
>
{(() => {
@@ -199,10 +201,14 @@ export function ProxyAssignmentDialog({
);
return proxy ? proxy.name : t("proxyAssignment.noneOption");
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
<PopoverContent
id={proxyListboxId}
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput
placeholder={t("proxyAssignment.searchPlaceholder")}
@@ -219,7 +225,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "none"
? "opacity-100"
: "opacity-0",
@@ -243,7 +249,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
@@ -269,7 +275,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "vpn" && selectedId === vpn.id
? "opacity-100"
: "opacity-0",
+3 -3
View File
@@ -118,12 +118,12 @@ export function ProxyCheckButton({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
className="size-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center">
<FlagIcon countryCode={result.country_code} className="h-2.5" />
@@ -132,7 +132,7 @@ export function ProxyCheckButton({
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
<FiCheck className="size-3" />
)}
</Button>
</TooltipTrigger>
+5 -5
View File
@@ -108,13 +108,13 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
}}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="json" id="format-json" />
<Label htmlFor="format-json" className="cursor-pointer">
{t("proxies.exportDialog.json")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="txt" id="format-txt" />
<Label htmlFor="format-txt" className="cursor-pointer">
{t("proxies.exportDialog.txt")}
@@ -154,9 +154,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
className="flex gap-2 items-center"
>
{copied ? (
<LuCheck className="w-4 h-4" />
<LuCheck className="size-4" />
) : (
<LuCopy className="w-4 h-4" />
<LuCopy className="size-4" />
)}
{copied
? t("proxies.exportDialog.copied")
@@ -167,7 +167,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
disabled={!exportContent || isLoading}
className="flex gap-2 items-center"
>
<LuDownload className="w-4 h-4" />
<LuDownload className="size-4" />
{t("common.buttons.download")}
</RippleButton>
</DialogFooter>
+1 -1
View File
@@ -315,7 +315,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("proxies.importDialog.dropzonePrompt")}
<br />
File diff suppressed because it is too large Load Diff
+449
View File
@@ -0,0 +1,449 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import { LuCloud, LuPlug, LuPuzzle, LuUser, LuUsers } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export type AppPage =
| "profiles"
| "proxies"
| "extensions"
| "groups"
| "vpns"
| "settings"
| "integrations"
| "account"
| "import";
const CLICK_THRESHOLD = 5;
const CLICK_WINDOW_MS = 2000;
const GRAVITY = 2200;
const BOUNCE_DAMPING = 0.6;
const INITIAL_HORIZONTAL_SPEED = 350;
const SPIN_SPEED = 720;
const MIN_BOUNCE_VELOCITY = 60;
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
function useLogoEasterEgg({
currentPage,
onNavigate,
}: {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
}) {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
/**
* Click count toward the bounce trigger while the user is on the profiles
* page. Capped at 4: each click here grows the logo by 25%, so step 4 has
* doubled the original size. Click 5 fires `triggerFall` and resets.
*/
const [growStep, setGrowStep] = useState(0);
const resetTimeoutRef = useRef<number | null>(null);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
const triggerFall = useCallback(() => {
const el = logoRef.current;
if (!el || isFalling) return;
setIsFalling(true);
const rect = el.getBoundingClientRect();
const startX = rect.left;
const startY = rect.top;
const floorY = window.innerHeight;
const rightWall = window.innerWidth;
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "fixed";
clone.style.left = `${startX}px`;
clone.style.top = `${startY}px`;
clone.style.zIndex = "9999";
clone.style.pointerEvents = "none";
clone.style.margin = "0";
document.body.appendChild(clone);
el.style.visibility = "hidden";
let x = 0;
let y = 0;
let vy = -500;
// Roll right first, bounce off the right wall, then escape the left.
let vx = INITIAL_HORIZONTAL_SPEED;
let rotation = 0;
let lastTime = performance.now();
const animate = (time: number) => {
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
vy =
Math.abs(vy) > MIN_BOUNCE_VELOCITY
? -Math.abs(vy) * BOUNCE_DAMPING
: -MIN_BOUNCE_VELOCITY * 3;
}
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
// damping), and keep rolling. Left wall has no bounce — the donut
// exits the window off the left edge.
const currentRight = startX + x + rect.width;
if (currentRight >= rightWall && vx > 0) {
x = rightWall - startX - rect.width;
vx = -Math.abs(vx) * 0.9;
}
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
const offScreenLeft = startX + x + rect.width < -200;
const offScreenBottom = startY + y > floorY + 100;
const offScreenTop = startY + y + rect.height < -200;
if (offScreenLeft || offScreenBottom || offScreenTop) {
clone.remove();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore — sessionStorage unavailable in some Tauri WebViews
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
// First behaviour: any click from elsewhere in the app just routes the
// user back to the profiles list. Growing the donut requires the user
// to already be home — that keeps the easter egg from accidentally
// firing during normal navigation.
if (currentPage !== "profiles") {
onNavigate("profiles");
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
return;
}
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
triggerFall();
} else {
setGrowStep(
Math.min(clickTimestamps.current.length, CLICK_THRESHOLD - 1),
);
setWobbleKey((k) => k + 1);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = window.setTimeout(() => {
clickTimestamps.current = [];
setGrowStep(0);
resetTimeoutRef.current = null;
}, CLICK_WINDOW_MS);
}
}, [currentPage, isFalling, isHidden, onNavigate, triggerFall]);
// Leaving the profiles page mid-streak cancels growth so we never end up
// with an outsized logo when the user returns later.
useEffect(() => {
if (currentPage !== "profiles") {
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
}
}, [currentPage]);
useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
};
}, []);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
growStep,
handleClick,
};
}
interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
}
interface RailItem {
page: AppPage;
Icon: React.ComponentType<{ className?: string }>;
labelKey: string;
}
const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
];
interface MoreMenuItem {
page: AppPage;
Icon: React.ComponentType<{ className?: string }>;
labelKey: string;
hintKey: string;
}
const MORE_ITEMS: MoreMenuItem[] = [
{
page: "import",
Icon: FaDownload,
labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint",
},
];
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
growStep,
handleClick,
} = useLogoEasterEgg({ currentPage, onNavigate });
return (
<nav className="flex flex-col items-center w-10 py-2 gap-1 bg-background border-r border-border shrink-0 relative">
{!isHidden ? (
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
}}
onPointerUp={() => {
setIsPressed(false);
}}
onPointerLeave={() => {
setIsPressed(false);
}}
>
{/* Inner wrapper survives clicks (no `key`) so the scale change
animates smoothly across the wiggle layer's remounts. */}
<span
style={{
transform: isPressed
? `scale(${(1 + growStep * 0.25) * 0.9})`
: `scale(${1 + growStep * 0.25})`,
}}
className="inline-grid place-items-center transition-transform duration-300 ease-out will-change-transform"
>
<span
key={wobbleKey}
className={cn(
"inline-grid place-items-center",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
>
<Logo className="size-5 will-change-transform" />
</span>
</span>
</button>
) : (
<div className="size-7" />
)}
<div className="w-5 h-px bg-border my-1" />
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
const active = currentPage === page;
return (
<Tooltip key={page} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate(page);
}}
aria-label={t(labelKey)}
aria-current={active ? "page" : undefined}
className={cn(
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
active
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
</Tooltip>
);
})}
<div className="flex-1" />
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
setMoreOpen((v) => !v);
}}
aria-label={t("rail.more.label")}
aria-expanded={moreOpen}
className={cn(
"grid place-items-center size-7 rounded-md transition-colors duration-100",
moreOpen
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
<GoKebabHorizontal className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.more.label")}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate("settings");
}}
aria-label={t("rail.settings")}
aria-current={currentPage === "settings" ? "page" : undefined}
className={cn(
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
currentPage === "settings"
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{currentPage === "settings" && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<GoGear className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.settings")}</TooltipContent>
</Tooltip>
{moreOpen && (
<>
<button
type="button"
aria-label={t("rail.more.closeAriaLabel")}
className="fixed inset-0 z-30 bg-transparent cursor-default"
onClick={() => {
setMoreOpen(false);
}}
/>
<div className="absolute bottom-14 left-11 w-56 bg-card border border-border rounded-lg shadow-2xl p-1 z-40 animate-in fade-in-0 slide-in-from-bottom-1 duration-100">
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
<button
key={page}
type="button"
onClick={() => {
setMoreOpen(false);
onNavigate(page);
}}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
>
<span className="grid place-items-center size-5 rounded bg-muted text-muted-foreground shrink-0">
<Icon className="size-3" />
</span>
<span className="flex flex-col min-w-0">
<span className="text-xs font-medium text-foreground truncate">
{t(labelKey)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{t(hintKey)}
</span>
</span>
</button>
))}
</div>
</>
)}
</nav>
);
}
+7 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
@@ -44,6 +44,7 @@ export function ReleaseTypeSelector({
}: ReleaseTypeSelectorProps) {
const { t } = useTranslation();
const [popoverOpen, setPopoverOpen] = useState(false);
const listboxId = useId();
const effectivePlaceholder =
placeholder ?? t("releaseTypeSelector.placeholder");
@@ -91,13 +92,14 @@ export function ReleaseTypeSelector({
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
aria-controls={listboxId}
className="justify-between w-full"
>
{selectedDisplayText}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
<LuChevronsUpDown className="ml-2 size-4 opacity-50 shrink-0" />
</RippleButton>
</PopoverTrigger>
<PopoverContent className="p-0">
<PopoverContent id={listboxId} className="p-0">
<Command>
<CommandEmpty>
{t("releaseTypeSelector.noReleaseTypes")}
@@ -126,7 +128,7 @@ export function ReleaseTypeSelector({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedReleaseType === option.type
? "opacity-100"
: "opacity-0",
@@ -187,7 +189,7 @@ export function ReleaseTypeSelector({
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 w-4 h-4" />
<LuDownload className="mr-2 size-4" />
{isDownloading
? t("releaseTypeSelector.downloading")
: t("releaseTypeSelector.downloadBrowser")}
+289 -33
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import Color from "color";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -23,6 +24,7 @@ import {
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -53,6 +55,7 @@ import {
THEMES,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -63,6 +66,7 @@ interface AppSettings {
api_port: number;
api_token?: string;
disable_auto_updates?: boolean;
keep_decrypted_profiles_in_ram?: boolean;
}
interface CustomThemeState {
@@ -82,12 +86,14 @@ interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onIntegrationsOpen?: () => void;
subPage?: boolean;
}
export function SettingsDialog({
isOpen,
onClose,
onIntegrationsOpen,
subPage,
}: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
@@ -126,6 +132,10 @@ export function SettingsDialog({
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
const [e2eError, setE2eError] = useState("");
const [isSavingE2e, setIsSavingE2e] = useState(false);
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false);
const [verifyE2ePassword, setVerifyE2ePassword] = useState("");
const [isVerifyingE2e, setIsVerifyingE2e] = useState(false);
const [systemInfo, setSystemInfo] = useState<{
app_version: string;
os: string;
@@ -159,9 +169,9 @@ export function SettingsDialog({
const getPermissionIcon = useCallback((type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-4 h-4" />;
return <BsMic className="size-4" />;
case "camera":
return <BsCamera className="w-4 h-4" />;
return <BsCamera className="size-4" />;
}
}, []);
@@ -408,7 +418,12 @@ export function SettingsDialog({
// Update settings with any generated tokens
setSettings(savedSettings);
settingsToSave = savedSettings;
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
// Pass the actual theme value through. Calling setTheme("dark") here
// when the user is on "custom" pushes the provider state to "dark",
// which triggers its clear-custom-vars effect and wipes the CSS
// variables we set just below — that's the bug where saving a custom
// theme made it disappear until the app was restarted.
setTheme(settings.theme);
// Apply or clear custom variables only on Save
if (settings.theme === "custom") {
@@ -539,7 +554,7 @@ export function SettingsDialog({
checkDefaultBrowserStatus().catch((err: unknown) => {
console.error(err);
});
}, 500); // Check every 500ms
}, 2000);
// Cleanup interval on component unmount or dialog close
return () => {
@@ -597,13 +612,20 @@ export function SettingsDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
)}
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
<div
className={cn(
"grid overflow-y-auto flex-1 gap-6 min-h-0",
subPage ? "py-2" : "py-4",
)}
>
{/* Appearance Section */}
<div className="space-y-4">
<Label className="text-base font-medium">
@@ -720,7 +742,7 @@ export function SettingsDialog({
<button
type="button"
aria-label={label}
className="w-8 h-8 rounded-md border shadow-sm cursor-pointer"
className="size-8 rounded-md border shadow-sm cursor-pointer"
style={{ backgroundColor: colorValue }}
/>
</PopoverTrigger>
@@ -869,7 +891,7 @@ export function SettingsDialog({
key={permission.permission_type}
className="flex justify-between items-center p-3 rounded-lg border"
>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-x-3">
{getPermissionIcon(permission.permission_type)}
<div>
<div className="text-sm font-medium">
@@ -882,7 +904,7 @@ export function SettingsDialog({
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
{getStatusBadge(permission.isGranted)}
{!permission.isGranted && (
<LoadingButton
@@ -973,10 +995,22 @@ export function SettingsDialog({
{t("settings.encryption.passwordSetDescription")}
</span>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
disabled={isRemovingE2e}
onClick={() => {
setVerifyE2ePassword("");
setIsVerifyE2eOpen(true);
}}
>
{t("settings.encryption.validatePassword")}
</Button>
<Button
variant="outline"
size="sm"
disabled={isRemovingE2e}
onClick={() => {
setHasE2ePassword(false);
setE2ePassword("");
@@ -986,21 +1020,41 @@ export function SettingsDialog({
>
{t("settings.encryption.changePassword")}
</Button>
<Button
<LoadingButton
variant="destructive"
size="sm"
isLoading={isRemovingE2e}
onClick={async () => {
setIsRemovingE2e(true);
try {
// Await the rollover so the user sees an error if
// re-syncing fails. Previously the rollover was
// fire-and-forget (`void invoke(...)`) which left
// half-removed state on screen with no feedback —
// the source of issue #360 "completely bugged".
await invoke("delete_e2e_password");
setHasE2ePassword(false);
try {
await invoke(
"rollover_encryption_for_all_entities",
);
} catch (rolloverErr) {
console.error(
"Rollover after password removal failed:",
rolloverErr,
);
showErrorToast(String(rolloverErr));
}
showSuccessToast(t("settings.encryption.removed"));
} catch (error) {
showErrorToast(String(error));
} finally {
setIsRemovingE2e(false);
}
}}
>
{t("settings.encryption.removePassword")}
</Button>
</LoadingButton>
</div>
</div>
) : (
@@ -1047,6 +1101,19 @@ export function SettingsDialog({
setHasE2ePassword(true);
setE2ePassword("");
setE2ePasswordConfirm("");
try {
// Await rollover so any failure surfaces to the
// user instead of being lost via fire-and-forget.
// Without this, "change password" leaves entities
// half-re-encrypted with no visible error.
await invoke("rollover_encryption_for_all_entities");
} catch (rolloverErr) {
console.error(
"Rollover after password set failed:",
rolloverErr,
);
showErrorToast(String(rolloverErr));
}
showSuccessToast(
t("settings.encryption.passwordSaved"),
);
@@ -1070,7 +1137,23 @@ export function SettingsDialog({
</Label>
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
{trialStatus?.type === "Active" ? (
{cloudUser != null && cloudUser.plan !== "free" ? (
// Paid Donut plan supersedes the local commercial trial —
// the trial only exists to gate commercial use until the
// user subscribes. Showing "Trial expired" to a paying
// customer reads like a billing error, so swap in a
// subscription-active badge instead.
<div className="space-y-1">
<p className="text-sm font-medium text-success">
{t("settings.commercial.subscriptionActive", {
plan: cloudUser.plan,
})}
</p>
<p className="text-xs text-muted-foreground">
{t("settings.commercial.subscriptionActiveDescription")}
</p>
</div>
) : trialStatus?.type === "Active" ? (
<div className="space-y-1">
<p className="text-sm font-medium">
{t("settings.commercial.trialActive", {
@@ -1102,7 +1185,7 @@ export function SettingsDialog({
</Label>
{!isLinux && (
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
<Checkbox
id="disable-auto-updates"
checked={settings.disable_auto_updates ?? false}
@@ -1124,6 +1207,30 @@ export function SettingsDialog({
</div>
)}
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
<Checkbox
id="keep-decrypted-profiles-in-ram"
checked={settings.keep_decrypted_profiles_in_ram ?? false}
onCheckedChange={(checked) => {
updateSetting(
"keep_decrypted_profiles_in_ram",
checked as boolean,
);
}}
/>
<div className="space-y-1">
<Label
htmlFor="keep-decrypted-profiles-in-ram"
className="text-sm font-medium"
>
{t("settings.keepDecryptedProfilesInRam")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings.keepDecryptedProfilesInRamDescription")}
</p>
</div>
</div>
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
@@ -1140,6 +1247,40 @@ export function SettingsDialog({
<p className="text-xs text-muted-foreground">
{t("settings.advanced.clearCacheDescription")}
</p>
<div className="grid grid-cols-2 gap-2 pt-2">
<RippleButton
variant="outline"
className="text-xs"
onClick={async () => {
try {
const content = await invoke<string>("read_log_files");
await writeClipboardText(content);
showSuccessToast(t("settings.advanced.copyLogsSuccess"));
} catch (err) {
showErrorToast(String(err));
}
}}
>
{t("settings.advanced.copyLogs")}
</RippleButton>
<RippleButton
variant="outline"
className="text-xs"
onClick={async () => {
try {
await invoke("open_log_directory");
} catch (err) {
showErrorToast(String(err));
}
}}
>
{t("settings.advanced.openLogDir")}
</RippleButton>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.copyLogsDescription")}
</p>
</div>
{/* System Info */}
@@ -1152,28 +1293,143 @@ export function SettingsDialog({
)}
</div>
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</DialogFooter>
{subPage ? (
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
<LoadingButton
size="sm"
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</div>
) : (
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
<DnsBlocklistDialog
isOpen={dnsBlocklistDialogOpen}
onClose={() => setDnsBlocklistDialogOpen(false)}
/>
<Dialog
open={isVerifyE2eOpen}
onOpenChange={(open) => {
if (!isVerifyingE2e) {
setIsVerifyE2eOpen(open);
if (!open) setVerifyE2ePassword("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("settings.encryption.validateDialog.title")}
</DialogTitle>
<DialogDescription>
{t("settings.encryption.validateDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
type="password"
placeholder={t("settings.encryption.passwordPlaceholder")}
value={verifyE2ePassword}
autoFocus
onChange={(e) => setVerifyE2ePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && verifyE2ePassword.length > 0) {
e.preventDefault();
void (async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("verify_e2e_password", {
password: verifyE2ePassword,
});
if (ok) {
showSuccessToast(
t("settings.encryption.validateDialog.matchToast"),
);
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
} else {
showErrorToast(
t("settings.encryption.validateDialog.mismatchToast"),
);
}
} catch (error) {
showErrorToast(String(error));
} finally {
setIsVerifyingE2e(false);
}
})();
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifyingE2e}
onClick={() => {
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<LoadingButton
isLoading={isVerifyingE2e}
disabled={verifyE2ePassword.length === 0}
onClick={async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("verify_e2e_password", {
password: verifyE2ePassword,
});
if (ok) {
showSuccessToast(
t("settings.encryption.validateDialog.matchToast"),
);
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
} else {
showErrorToast(
t("settings.encryption.validateDialog.mismatchToast"),
);
}
} catch (error) {
showErrorToast(String(error));
} finally {
setIsVerifyingE2e(false);
}
}}
>
{t("settings.encryption.validateDialog.submit")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -303,7 +303,7 @@ export function SharedCamoufoxConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -323,7 +323,7 @@ export function SharedCamoufoxConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location-advanced"
checked={isAutoLocationEnabled}
@@ -367,7 +367,7 @@ export function SharedCamoufoxConfigForm({
<div className="space-y-3">
<Label>{t("fingerprint.blockingOptions")}</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-images"
checked={config.block_images ?? false}
@@ -379,7 +379,7 @@ export function SharedCamoufoxConfigForm({
{t("fingerprint.blockImages")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-webrtc"
checked={config.block_webrtc ?? false}
@@ -391,7 +391,7 @@ export function SharedCamoufoxConfigForm({
{t("fingerprint.blockWebRTC")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-webgl"
checked={config.block_webgl ?? false}
@@ -1025,7 +1025,7 @@ export function SharedCamoufoxConfigForm({
<Label>{t("fingerprint.battery")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="battery-charging"
checked={fingerprintConfig["battery:charging"] ?? false}
@@ -1176,7 +1176,7 @@ export function SharedCamoufoxConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint-auto"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -1199,7 +1199,7 @@ export function SharedCamoufoxConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location"
checked={isAutoLocationEnabled}
+1 -1
View File
@@ -96,7 +96,7 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="py-4">

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