Compare commits

...

48 Commits

Author SHA1 Message Date
zhom 667a4c99f0 chore: version bump 2026-05-25 02:20:40 +04:00
zhom 9236ad38c8 refactor: cleanup 2026-05-25 02:19:20 +04:00
zhom 6850f2c573 chore: linting 2026-05-23 14:35:55 +04:00
zhom 0add6c2aae chore: update pnpm 2026-05-23 14:22:45 +04:00
zhom f54c359d15 chore: make telegram releases ai-generated 2026-05-23 14:22:45 +04:00
zhom 69da467ce0 refactor: cleanup, korean translation 2026-05-23 14:22:45 +04:00
zhom 375530e358 chore: workflow cleanup 2026-05-23 14:22:45 +04:00
andy d664e5cde6 Merge pull request #377 from zhom/dependabot/cargo/src-tauri/rust-dependencies-fa7ae92db0
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 25 updates
2026-05-23 03:22:35 -07:00
andy 096e4aaf4a Merge pull request #376 from zhom/dependabot/github_actions/github-actions-39fff3f52e
ci(deps): bump the github-actions group with 6 updates
2026-05-23 03:07:33 -07:00
dependabot[bot] 8305c45cb5 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 25 updates:

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Updates `rsqlite-vfs` from 0.1.0 to 0.1.1

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 09:42:11 +00:00
github-actions[bot] 99074280ea chore: update flake.nix for v0.24.2 [skip ci] (#370)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:29:04 +00:00
github-actions[bot] 85586ed8fa docs: update CHANGELOG.md and README.md for v0.24.2 [skip ci] (#369)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:28:42 +00:00
zhom 2e891dd9ec chore: version bump 2026-05-16 02:43:17 +04:00
zhom e5361b6905 fix: camoufox proxy pid connection 2026-05-16 02:41:28 +04:00
zhom f6daa642d0 refactor: browser update 2026-05-15 20:42:25 +04:00
zhom c84d547a8c feat: more mcp integrations 2026-05-15 19:59:44 +04:00
zhom c8a43b43f1 refactor: ui cleanup 2026-05-15 15:44:20 +04:00
zhom 56b0da990b refactor: cleanup 2026-05-14 20:04:19 +04:00
zhom 597efb7e58 chore: cleanup 2026-05-14 20:03:22 +04:00
github-actions[bot] ba72e4cb3b chore: update flake.nix for v0.24.1 [skip ci] (#364)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:28 +00:00
github-actions[bot] c2ace4b8d3 docs: update CHANGELOG.md and README.md for v0.24.1 [skip ci] (#363)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:10 +00:00
zhom 35a874ead0 chore: version bump 2026-05-12 20:52:10 +04:00
zhom f02397dba9 refactor: creation button disaster recovery 2026-05-12 20:50:29 +04:00
github-actions[bot] d5752633c8 chore: update flake.nix for v0.24.0 [skip ci] (#357)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:02:08 +00:00
github-actions[bot] 5752260018 docs: update CHANGELOG.md and README.md for v0.24.0 [skip ci] (#356)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:01:52 +00:00
zhom 405d7c5716 fix: pass correct parameter for dns list selection 2026-05-12 13:17:29 +04:00
zhom 7d9bed2114 chore: version bump 2026-05-12 13:04:51 +04:00
zhom 2633e2ba09 refactor: better error handling and prevention of creating ephemeral password protected profiles 2026-05-12 13:03:34 +04:00
zhom 06b5a41b37 feat: support latest camoufox 2026-05-12 02:19:59 +04:00
zhom bb5f4ea166 chore: update dependencies 2026-05-12 01:35:28 +04:00
zhom 9c1cb011a5 refactor: ui cleanup 2026-05-12 01:22:04 +04:00
zhom ed3c209f35 feat: full ui refresh 2026-05-11 23:13:03 +04:00
zhom 739b5e2449 chore: fix telegram notifications 2026-05-11 23:13:03 +04:00
zhom c3e498fc6e chore: fix issue validation 2026-05-11 23:13:03 +04:00
zhom b5f000849f refactor: sync cleanup 2026-05-11 23:13:03 +04:00
zhom 722aaecbbe refactor: proxy spawn 2026-05-11 23:13:03 +04:00
github-actions[bot] 85e0072915 chore: update flake.nix for v0.23.0 [skip ci] (#351)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-10 04:36:53 +00:00
github-actions[bot] 50d918eeda docs: update CHANGELOG.md and README.md for v0.23.0 [skip ci] (#350)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-10 04:36:34 +00:00
143 changed files with 42368 additions and 7269 deletions
@@ -0,0 +1,23 @@
messages:
- role: system
content: |-
You write short, friendly release summaries for Donut Browser, an anti-detect browser desktop app built with Tauri and Next.js.
Rules:
- Keep it minimal and friendly. No marketing voice, no filler, no superlatives.
- No emojis or pictographic symbols.
- Plain ASCII punctuation only. No em-dashes, en-dashes, ellipses, smart quotes, or any non-ASCII characters. Use a regular hyphen, three dots, or straight quotes instead.
- Plain text only. No markdown (no asterisks for bold, no backticks for code, no headings), no HTML tags.
- Focus on user-visible changes. Skip chore, docs-only, CI, test, dependency, formatting, and purely internal refactor commits unless they have user-visible impact.
- Group related commits into a single bullet when it reads better.
- Use simple, direct language.
- Do not include the version number, download links, or a heading. The surrounding message already has those.
- If nothing in the commits is user-visible, output exactly one bullet: "- Small fixes and internal improvements."
- role: user
content: |-
Write the summary for Donut Browser {{version}} from these commits:
{{commits}}
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
model: openai/gpt-4.1
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+108
View File
@@ -0,0 +1,108 @@
name: Compliance Close
on:
schedule:
# Every 30 minutes; the actual close decision uses comment age, so the cron
# cadence only bounds how stale the closure can get past the 24-hour mark.
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-non-compliant:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 24 hours
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: items } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'needs:compliance',
state: 'open',
per_page: 100,
});
if (items.length === 0) {
core.info('No open issues/PRs with needs:compliance label');
return;
}
const now = Date.now();
const window_ms = 24 * 60 * 60 * 1000;
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
});
// Use the OLDEST compliance sentinel as the start of the 24-hour
// window so back-and-forth edits don't reset the clock.
const sentinel = comments
.filter(c => c.body && c.body.includes('<!-- issue-compliance -->'))
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0];
if (!sentinel) {
core.info(`${kind} #${item.number} has needs:compliance label but no compliance comment; skipping`);
continue;
}
const age_ms = now - new Date(sentinel.created_at).getTime();
if (age_ms < window_ms) {
const hours = (age_ms / (60 * 60 * 1000)).toFixed(1);
core.info(`${kind} #${item.number} still within 24-hour window (${hours}h elapsed)`);
continue;
}
const closeMessage = isPR
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
body: closeMessage,
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {
core.info(`Could not remove needs:compliance label from #${item.number}: ${e.message}`);
}
if (isPR) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: item.number,
state: 'closed',
});
} else {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
core.info(`Closed non-compliant ${kind} #${item.number} after 24-hour window`);
}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
security-scan: security-scan:
name: Security Vulnerability Scan name: Security Vulnerability Scan
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]' if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with: with:
scan-args: |- scan-args: |-
-r -r
+3 -3
View File
@@ -33,10 +33,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
echo "Tags: ${TAGS}" echo "Tags: ${TAGS}"
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
with: with:
context: . context: .
file: ./donut-sync/Dockerfile file: ./donut-sync/Dockerfile
+269
View File
@@ -0,0 +1,269 @@
name: Issue Compliance Check
on:
issues:
types: [opened, edited]
permissions:
contents: read
issues: write
env:
MODEL: z-ai/glm-5.1
jobs:
check-compliance:
if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Gather context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
- name: Build prompt
run: |
cat > /tmp/system.txt <<'PROMPT'
You are reviewing a new GitHub issue for template compliance. Return ONLY a single JSON object, no prose, no markdown fences.
Project: Donut Browser. There are three valid templates:
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
- Feature Request (description + verification checkbox)
- Question (free form)
## Compliance — flag NON-compliant ONLY when at least one of these is true
- The issue body is empty or contains only placeholder text from the template
- The issue is an obvious AI-generated wall of text with no real specifics
- A bug report has no reproduction information or no error description
- A feature request gives no use case at all
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...]
}
If there is nothing to flag, return:
{"is_compliant": true, "non_compliance_reasons": []}
PROMPT
- name: Call OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
PAYLOAD=$(jq -n \
--arg model "$MODEL" \
--rawfile system_prompt /tmp/system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("New issue title: " + $title + "\n\nNew issue body:\n" + $body) }
],
response_format: { type: "json_object" }
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
# Strip accidental markdown fences and parse. On parse failure, fall back
# to a noop result so the workflow doesn't fail the issue author's run.
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
cat /tmp/raw.txt
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi
echo "Result:"
cat /tmp/result.json
- name: Build comment
run: |
python3 - <<'EOF'
import json, os
r = json.load(open('/tmp/result.json'))
compliant = bool(r.get('is_compliant', True))
reasons = r.get('non_compliance_reasons') or []
parts = []
if not compliant:
parts.append('<!-- issue-compliance -->')
parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).")
parts.append('')
parts.append('**What needs to be fixed:**')
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
parts.append('')
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
comment = '\n'.join(parts).strip()
open('/tmp/comment.md', 'w').write(comment)
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
fh.write(f'has_comment={"true" if comment else "false"}\n')
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
EOF
id: build
- name: Post comment
if: steps.build.outputs.has_comment == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
- name: Apply needs:compliance label
if: steps.build.outputs.non_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "needs:compliance"
recheck-compliance:
# When a flagged issue is edited, re-check. If now compliant: remove label,
# delete the previous compliance comment, and thank the author. If still
# non-compliant: leave label and post an updated note.
if: >
github.repository == 'zhom/donutbrowser' &&
github.event.action == 'edited' &&
contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Gather context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
- name: Build prompt
run: |
cat > /tmp/system.txt <<'PROMPT'
You are re-checking a GitHub issue that was previously flagged as not meeting template requirements. Return ONLY a single JSON object, no prose, no markdown fences.
Project: Donut Browser. There are three valid templates:
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
- Feature Request (description + verification checkbox)
- Question (free form)
## Flag NON-compliant ONLY when at least one of these is true
- The issue body is empty or contains only placeholder text from the template
- The issue is an obvious AI-generated wall of text with no real specifics
- A bug report has no reproduction information or no error description
- A feature request gives no use case at all
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...]
}
PROMPT
- name: Call OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
PAYLOAD=$(jq -n \
--arg model "$MODEL" \
--rawfile system_prompt /tmp/system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user", content: ("Title: " + $title + "\n\nBody:\n" + $body) }
],
response_format: { type: "json_object" }
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; assuming still non-compliant"
echo '{"is_compliant": false, "non_compliance_reasons": ["unable to parse model output"]}' > /tmp/result.json
fi
- name: Resolve compliance state
id: resolve
run: |
IS_COMPLIANT=$(jq -r '.is_compliant // false' /tmp/result.json)
echo "is_compliant=$IS_COMPLIANT" >> "$GITHUB_OUTPUT"
- name: Clear compliance label and acknowledge fix
if: steps.resolve.outputs.is_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "needs:compliance" || true
# Delete the previous <!-- issue-compliance --> sentinel comment so
# the thread is clean once the author has addressed the issue.
COMMENT_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
--jq '[.[] | select(.body | contains("<!-- issue-compliance -->"))][-1].id // empty')
if [ -n "$COMMENT_ID" ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" || true
fi
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--body "Thanks for updating the issue."
- name: Build follow-up comment
if: steps.resolve.outputs.is_compliant != 'true'
run: |
python3 - <<'EOF'
import json
r = json.load(open('/tmp/result.json'))
reasons = r.get('non_compliance_reasons') or []
parts = [
'<!-- issue-compliance -->',
'This issue still does not meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).',
'',
'**What still needs to be fixed:**',
]
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
open('/tmp/comment.md', 'w').write('\n'.join(parts))
EOF
- name: Post follow-up comment
if: steps.resolve.outputs.is_compliant != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
+29 -16
View File
@@ -18,8 +18,8 @@ permissions:
env: env:
# Single source of truth for the model used by both triage and composer. # Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: anthropic/claude-opus-4.7 TRIAGE_MODEL: z-ai/glm-5.1
COMPOSER_MODEL: anthropic/claude-opus-4.7 COMPOSER_MODEL: z-ai/glm-5.1
jobs: jobs:
analyze-issue: analyze-issue:
@@ -102,12 +102,14 @@ jobs:
its API, MCP server, and the bundled `donut-sync` self-hosted server. its API, MCP server, and the bundled `donut-sync` self-hosted server.
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern - **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
bugs are in-scope here unless they are obviously upstream Chromium issues. bugs are in-scope here unless they are obviously upstream Chromium issues.
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT - **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
contribute to Camoufox and CANNOT fix bugs in it. separate repository. Bugs about Camoufox's *internal* behavior are outside
the scope of this project.
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine, - Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
dropdowns, form widgets, fingerprinting *as Camoufox implements it*, dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to checkbox/radio quirks) are out of scope here. Ask the user to first
https://github.com/daijro/camoufox/issues. search https://github.com/daijro/camoufox/issues for a matching report,
and if they don't find one, to open it there themselves.
- Bugs about how Donut *launches, configures, or downloads* Camoufox are - Bugs about how Donut *launches, configures, or downloads* Camoufox are
in-scope here. in-scope here.
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT - **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
@@ -146,7 +148,10 @@ jobs:
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
which exact version was the last working one and what changed. which exact version was the last working one and what changed.
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own - **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
behavior. Redirect, do not collect logs. behavior. Tell the user it's outside the scope of this project and ask
them to search the Camoufox repo and, if no matching issue exists, file
one there. Do NOT say the maintainer doesn't contribute / can't fix it
— keep it strictly about project scope. Do not collect logs.
- **Fork-support request**: asks the maintainer to support an alternative - **Fork-support request**: asks the maintainer to support an alternative
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
"clear", "reasonable", "well-thought-out", etc. "clear", "reasonable", "well-thought-out", etc.
@@ -159,10 +164,14 @@ jobs:
numbers. Never speculate about how subscription / paid-plan checks work. numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS) # OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
# Easiest path for the user: Donut → Settings → Advanced → Copy logs
# (puts the latest rotated log on the clipboard). If they prefer to
# attach files directly, the active log is `DonutBrowser.log`; older
# rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-…`).
- macOS: `~/Library/Logs/com.donutbrowser/` - macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/DonutBrowser/logs/` - Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%APPDATA%\DonutBrowser\logs\` - Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these # KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else) # verbatim before suggesting anything else)
@@ -338,7 +347,7 @@ jobs:
The triage classification (`triage.classification`) determines the response shape: The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs. - `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that. - `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then say this is outside the scope of this project — ask the user to first search https://github.com/daijro/camoufox/issues for a matching report and, if none exists, to open one there themselves. Do NOT phrase it as "the maintainer does not contribute" or anything personal — keep it strictly about scope. Do NOT ask for Donut logs. Stop after that.
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com. - `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate. - `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language. - `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
@@ -352,10 +361,14 @@ jobs:
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English. If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths ## OS-specific log paths
Use ONLY the one matching `triage.operating_system`: Recommend Settings → Advanced → Copy logs first — it bundles the
- macos: `~/Library/Logs/com.donutbrowser/` latest rotated log onto the clipboard without the user hunting for
- linux: `~/.local/share/DonutBrowser/logs/` a directory. If they want to attach files directly, point at the
- windows: `%APPDATA%\DonutBrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\DonutBrowser\logs`) path that matches `triage.operating_system`. The active log is
always `DonutBrowser.log`; rotated copies sit next to it.
- macos: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
- unknown: ask the user to share their OS first. - unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions) ## Known error signatures (apply BEFORE asking generic questions)
@@ -607,7 +620,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode - name: Run opencode
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41 uses: anomalyco/opencode/github@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
env: env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+134 -33
View File
@@ -1,17 +1,35 @@
name: Notify Telegram 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: on:
release: workflow_dispatch:
types: [published] 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: permissions:
contents: read contents: read
models: read
jobs: jobs:
notify: notify:
# Only post for stable releases on the canonical repo. Pre-releases if: >
# (rolling builds, RCs) are skipped so the channel stays low-noise. github.repository == 'zhom/donutbrowser' &&
if: github.repository == 'zhom/donutbrowser' && !github.event.release.prerelease (github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -19,20 +37,81 @@ jobs:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
- name: Post release announcement to Telegram - name: Resolve release tag
id: tag
env: env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} INPUT_TAG: ${{ inputs.tag }}
TAG: ${{ github.event.release.tag_name }} # `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 }} REPO: ${{ github.repository }}
run: | run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then if [[ -n "${INPUT_TAG:-}" ]]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification." 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 exit 0
fi fi
# Resolve the previous stable tag the same way notify-discord does # Only stable semver tags vX.Y.Z are eligible. Reject anything
# so the changelog ranges line up. # with a pre-release suffix (`-rc1`, `-beta`, etc.).
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag '${TAG}' is not a stable semver tag, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Confirm the release exists and isn't marked prerelease in the
# GitHub UI — guards against someone manually flipping the flag.
RELEASE_JSON=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isPrerelease,tagName 2>/dev/null || echo "")
if [[ -z "${RELEASE_JSON}" ]]; then
echo "Release ${TAG} not found via gh — skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
IS_PRE=$(jq -r .isPrerelease <<< "${RELEASE_JSON}")
if [[ "${IS_PRE}" == "true" ]]; then
echo "Release ${TAG} is marked prerelease, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Collect commits between previous tag and current tag
id: commits
if: steps.gate.outputs.skip != 'true'
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \ PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \ | grep -v "^${TAG}$" \
@@ -40,30 +119,52 @@ jobs:
if [ -z "$PREV_TAG" ]; then if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD) PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi fi
git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${TAG}" --no-merges > commits.txt
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; } - name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
version: ${{ steps.tag.outputs.tag }}
file_input: |
commits: ./commits.txt
max-tokens: 1024
# Build a plain bullet list from feat / fix / refactor commits. - name: Post release announcement to Telegram
# Other commit types (chore, docs, ci, test, deps) are intentionally if: steps.gate.outputs.skip != 'true'
# filtered out — same convention as the Discord embed. env:
CHANGES="" TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
while IFS= read -r msg; do TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
[ -z "$msg" ] && continue TAG: ${{ steps.tag.outputs.tag }}
case "$msg" in REPO: ${{ github.repository }}
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*) AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n' AI_RESPONSE: ${{ steps.ai.outputs.response }}
;; run: |
esac if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}") echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
if [ -z "$CHANGES" ]; then
CHANGES="• See release notes."$'\n'
fi fi
# HTML-escape the changelog before injecting into Telegram HTML # Prefer the file output — `response` can be truncated for longer summaries.
# mode — commit messages can legitimately contain `<`, `>`, `&`. if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
# The static markup around it (we control it) is left as-is. SUMMARY=$(cat "$AI_RESPONSE_FILE")
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \ else
SUMMARY="$AI_RESPONSE"
fi
if [ -z "${SUMMARY//[[:space:]]/}" ]; then
echo "::error::AI summary is empty"
exit 1
fi
# HTML-escape the AI summary before injecting into Telegram HTML mode —
# commit messages can legitimately contain `<`, `>`, `&` and the AI may echo them.
ESCAPED_CHANGES=$(printf '%s' "$SUMMARY" \
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))") | python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
VERSION="${TAG}" VERSION="${TAG}"
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled: scan-scheduled:
name: Scheduled Security Scan name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with: with:
scan-args: |- scan-args: |-
-r -r
@@ -58,7 +58,7 @@ jobs:
scan-pr: scan-pr:
name: PR Security Scan name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with: with:
scan-args: |- scan-args: |-
-r -r
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
security-scan: security-scan:
name: Security Vulnerability Scan name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with: with:
scan-args: |- scan-args: |-
-r -r
@@ -82,7 +82,7 @@ jobs:
- name: Generate release notes with AI - name: Generate release notes with AI
id: generate-notes id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false' if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
with: with:
prompt-file: .github/prompts/release-notes.prompt.yml prompt-file: .github/prompts/release-notes.prompt.yml
input: | input: |
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
security-scan: security-scan:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with: with:
scan-args: |- scan-args: |-
-r -r
@@ -108,7 +108,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
security-scan: security-scan:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with: with:
scan-args: |- scan-args: |-
-r -r
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository - name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1 uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 #v1.46.2
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open." stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
@@ -94,7 +94,7 @@ jobs:
done done
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with: with:
run_install: false run_install: false
+92
View File
@@ -53,6 +53,8 @@ donutbrowser/
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project - After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken - Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml` - `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
## Code Quality ## Code Quality
@@ -69,6 +71,86 @@ 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. - Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site. - **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix. - Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all 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.
### Cross-component tab control
When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky:
```tsx
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
```
Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses.
## Keyboard shortcuts
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all seven locales.
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
- `matchesGroupDigit(event)` returns 19 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut:
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
4. Add `shortcuts.yourId` (label) to all seven locale files.
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
## Singletons ## Singletons
@@ -93,6 +175,16 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc. - Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50` - For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## App data directory naming
`src-tauri/src/app_dirs.rs::app_name()` returns `"DonutBrowserDev"` when `cfg!(debug_assertions)` is true, `"DonutBrowser"` otherwise. So release builds (anything built via `tauri build` / `cargo build --release`) write to:
- macOS — `~/Library/Application Support/DonutBrowser/`
- Linux — `~/.local/share/DonutBrowser/`
- Windows — `%LOCALAPPDATA%\DonutBrowser\`
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
## Publishing Linux Repositories ## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS: The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
+95
View File
@@ -1,6 +1,101 @@
# Changelog # Changelog
## v0.24.2 (2026-05-16)
### Features
- more mcp integrations
### Bug Fixes
- camoufox proxy pid connection
### Refactoring
- browser update
- ui cleanup
- cleanup
### Maintenance
- chore: version bump
- chore: cleanup
- chore: update flake.nix for v0.24.1 [skip ci] (#364)
## v0.24.1 (2026-05-12)
### Refactoring
- creation button disaster recovery
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.0 [skip ci] (#357)
## v0.24.0 (2026-05-12)
### Features
- support latest camoufox
- full ui refresh
### Bug Fixes
- pass correct parameter for dns list selection
### Refactoring
- better error handling and prevention of creating ephemeral password protected profiles
- ui cleanup
- sync cleanup
- proxy spawn
### Maintenance
- chore: version bump
- chore: update dependencies
- chore: fix telegram notifications
- chore: fix issue validation
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
## v0.23.0 (2026-05-10)
### Features
- password protected profiles
- telegram notifications
### Refactoring
- reduce the number of s3 calls
### Documentation
- remove fossa badge
### Maintenance
- chore: version bump
- chore: logging
- chore: copy
- chore: optimize issue validation
- chore: linting
- ci(deps): bump the github-actions group with 3 updates (#348)
- chore: cleanup issue validation
- chore: update flake.nix for v0.22.7 [skip ci] (#341)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#349)
- deps(rust)(deps): bump tauri from 2.11.0 to 2.11.1 in /src-tauri (#346)
- deps(rust)(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
## v0.22.7 (2026-05-05) ## v0.22.7 (2026-05-05)
### Refactoring ### Refactoring
+5 -5
View File
@@ -48,7 +48,7 @@
| | Apple Silicon | Intel | | | Apple Silicon | Intel |
|---|---|---| |---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_x64.dmg) | | **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64.dmg) |
Or install via Homebrew: Or install via Homebrew:
@@ -58,15 +58,15 @@ brew install --cask donut
### Windows ### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_x64-portable.zip) [Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-portable.zip)
### Linux ### Linux
| Format | x86_64 | ARM64 | | Format | x86_64 | ARM64 |
|---|---|---| |---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_arm64.deb) | | **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut-0.22.7-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut-0.22.7-1.aarch64.rpm) | | **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.AppImage) | | **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage) |
<!-- install-links-end --> <!-- install-links-end -->
Or install via package manager: 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" "test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1024.0", "@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0", "@aws-sdk/s3-request-presigner": "^3.1045.0",
"@nestjs/common": "^11.1.18", "@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.18", "@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.18", "@nestjs/platform-express": "^11.1.19",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.17", "@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.0.10", "@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.18", "@nestjs/testing": "^11.1.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.2", "@types/node": "^25.7.0",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"jest": "^30.3.0", "jest": "^30.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"ts-loader": "^9.5.7", "ts-loader": "^9.5.7",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^6.0.2" "typescript": "^6.0.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" ( pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs pkgConfigLibs ++ map lib.getDev pkgConfigLibs
); );
releaseVersion = "0.22.7"; releaseVersion = "0.24.2";
releaseAppImage = releaseAppImage =
if system == "x86_64-linux" then if system == "x86_64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage";
hash = "sha256-pnIiyXxCY/WxczM5IAjzCq+6C96oXOesmz27y78tJSI="; hash = "sha256-140PSB/1BLGUB4sI/RgfYe7uUjwRFWXtdSnUZz6Wr0U=";
} }
else if system == "aarch64-linux" then else if system == "aarch64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage";
hash = "sha256-CyrujVE925Fr2G1U18PaklXCjKCDi+kOAkak7tZ8CW4="; hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
} }
else else
null; null;
+22 -31
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser", "name": "donutbrowser",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"version": "0.23.0", "version": "0.24.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 12341", "dev": "next dev --turbopack -p 12341",
@@ -45,60 +45,51 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "~2.11.0", "@tauri-apps/api": "~2.11.0",
"@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-deep-link": "^2.4.9",
"@tauri-apps/plugin-fs": "~2.5.0", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "~2.5.1",
"@tauri-apps/plugin-log": "^2.8.0", "@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", "ahooks": "^3.9.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"color": "^5.0.3", "color": "^5.0.3",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"i18next": "^26.0.3", "i18next": "^26.1.0",
"lucide-react": "^1.7.0", "lucide-react": "^1.14.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "^16.2.3", "next": "^16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.6",
"react-dom": "^19.2.4", "react-dom": "^19.2.6",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.7",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "3.8.1", "recharts": "3.8.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.6.0",
"tauri-plugin-macos-permissions-api": "^2.3.0" "tauri-plugin-macos-permissions-api": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.10", "@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.0", "@tauri-apps/cli": "~2.11.1",
"@types/color": "^4.2.1", "@types/color": "^4.2.1",
"@types/node": "^25.5.2", "@types/node": "^25.7.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.4.0", "lint-staged": "^17.0.4",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.3.0",
"ts-unused-exports": "^11.0.1", "ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~6.0.2" "typescript": "~6.0.3"
}, },
"pnpm": { "packageManager": "pnpm@11.2.2",
"overrides": {
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12",
"fast-xml-parser@<5.7.0": ">=5.7.2",
"fast-uri@<3.1.2": ">=3.1.2",
"fast-xml-builder@<1.2.0": ">=1.2.0"
}
},
"packageManager": "pnpm@10.33.2",
"lint-staged": { "lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [ "**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix" "biome check --fix"
+2297 -3015
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
- sharp - sharp
- sqlite3 - sqlite3
- unrs-resolver - unrs-resolver
# Husky and lint-staged shell out to pnpm without a TTY, so the interactive
# "purge modules dir?" prompt errors out (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY)
# and aborts the commit. Skipping the prompt lets the hook proceed.
confirmModulesPurge: false
# Pinned for security. Moved from package.json#pnpm.overrides — pnpm 11
# no longer reads that field; settings live here now.
overrides:
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.10: '>=8.5.12'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
allowBuilds:
'@nestjs/core': true
sharp: true
unrs-resolver: true
+428 -180
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "donutbrowser" name = "donutbrowser"
version = "0.23.0" version = "0.24.3"
description = "Simple Yet Powerful Anti-Detect Browser" description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"] authors = ["zhom@github"]
edition = "2021" edition = "2021"
@@ -44,6 +44,7 @@ tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2" tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-clipboard-manager = "2"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
@@ -99,11 +100,12 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
tokio-tungstenite = { version = "0.29", features = ["native-tls"] } tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
rusqlite = { version = "0.39", features = ["bundled"] } rusqlite = { version = "0.39", features = ["bundled"] }
serde_yaml = "0.9" serde_yaml = "0.9"
toml = "1.1"
thiserror = "2.0" thiserror = "2.0"
regex-lite = "0.1" regex-lite = "0.1"
tempfile = "3" tempfile = "3"
maxminddb = "0.28" maxminddb = "0.28"
quick-xml = { version = "0.39", features = ["serialize"] } quick-xml = { version = "0.40", features = ["serialize"] }
# VPN support # VPN support
boringtun = "0.7" boringtun = "0.7"
+3 -1
View File
@@ -41,6 +41,8 @@
"macos-permissions:allow-request-camera-permission", "macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission", "macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission", "macos-permissions:allow-check-camera-permission",
"log:default" "log:default",
"clipboard-manager:default",
"clipboard-manager:allow-write-text"
] ]
} }
+106
View File
@@ -87,6 +87,8 @@ pub struct UpdateProfileRequest {
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>, pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>, pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -215,6 +217,20 @@ struct OpenUrlRequest {
url: String, url: String,
} }
#[derive(Debug, Deserialize, ToSchema)]
struct ImportCookiesRequest {
/// Raw cookie file content. Format is auto-detected: a JSON array
/// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
content: String,
}
#[derive(Debug, Serialize, ToSchema)]
struct ImportCookiesResponse {
cookies_imported: usize,
cookies_replaced: usize,
errors: Vec<String>,
}
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
@@ -226,6 +242,7 @@ struct OpenUrlRequest {
run_profile, run_profile,
open_url_in_profile, open_url_in_profile,
kill_profile, kill_profile,
import_profile_cookies,
get_groups, get_groups,
get_group, get_group,
create_group, create_group,
@@ -268,6 +285,8 @@ struct OpenUrlRequest {
RunProfileResponse, RunProfileResponse,
RunProfileRequest, RunProfileRequest,
OpenUrlRequest, OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
ProxySettings, ProxySettings,
)), )),
tags( tags(
@@ -277,6 +296,7 @@ struct OpenUrlRequest {
(name = "proxies", description = "Proxy management endpoints"), (name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"), (name = "vpns", description = "VPN management endpoints"),
(name = "browsers", description = "Browser management endpoints"), (name = "browsers", description = "Browser management endpoints"),
(name = "cookies", description = "Cookie management endpoints"),
), ),
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
)] )]
@@ -363,6 +383,7 @@ impl ApiServer {
.routes(routes!(run_profile)) .routes(routes!(run_profile))
.routes(routes!(open_url_in_profile)) .routes(routes!(open_url_in_profile))
.routes(routes!(kill_profile)) .routes(routes!(kill_profile))
.routes(routes!(import_profile_cookies))
.routes(routes!(get_groups, create_group)) .routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group)) .routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_tags)) .routes(routes!(get_tags))
@@ -397,10 +418,15 @@ impl ApiServer {
.route("/events", get(ws_handler)) .route("/events", get(ws_handler))
.with_state(ws_state); .with_state(ws_state);
let api_for_v1 = api.clone();
let app = Router::new() let app = Router::new()
.merge(v1_routes) .merge(v1_routes)
.nest("/ws", ws_routes) .nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) })) .route("/openapi.json", get(move || async move { Json(api) }))
.route(
"/v1/openapi.json",
get(move || async move { Json(api_for_v1) }),
)
// Outermost layer: logs every request so customer reports show what // Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was, // their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers. // and how long it took. Never logs request bodies or auth headers.
@@ -929,6 +955,15 @@ async fn update_profile(
} }
} }
if let Some(sync_mode) = request.sync_mode {
if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode)
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
// Return updated profile // Return updated profile
get_profile(Path(id), State(state)).await get_profile(Path(id), State(state)).await
} }
@@ -1818,6 +1853,77 @@ async fn kill_profile(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
post,
path = "/v1/profiles/{id}/cookies/import",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = ImportCookiesRequest,
responses(
(status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
(status = 400, description = "Invalid cookie file or unsupported browser"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Browser is currently running"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn import_profile_cookies(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<ImportCookiesRequest>,
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !profiles.iter().any(|p| p.id.to_string() == id) {
return Err(StatusCode::NOT_FOUND);
}
match crate::cookie_manager::CookieManager::import_cookies(
&state.app_handle,
&id,
&request.content,
)
.await
{
Ok(result) => {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
if profile.is_sync_enabled() {
let pid = id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
Ok(Json(ImportCookiesResponse {
cookies_imported: result.cookies_imported,
cookies_replaced: result.cookies_replaced,
errors: result.errors,
}))
}
Err(e) => {
let msg = e.to_lowercase();
if msg.contains("running") {
Err(StatusCode::CONFLICT)
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
Err(StatusCode::BAD_REQUEST)
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
// API Handler - Download Browser // API Handler - Download Browser
#[utoipa::path( #[utoipa::path(
post, post,
+11
View File
@@ -108,6 +108,17 @@ pub fn dns_blocklist_dir() -> PathBuf {
cache_dir().join("dns_blocklists") 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)] #[cfg(test)]
thread_local! { thread_local! {
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) }; static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
+1
View File
@@ -702,6 +702,7 @@ mod tests {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
} }
} }
+17 -10
View File
@@ -82,9 +82,14 @@ fn build_proxy_url(
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
async fn main() { 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() env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug) .filter_level(log::LevelFilter::Info)
.format_timestamp_millis() .format_timestamp_millis()
.init(); .init();
@@ -343,8 +348,11 @@ async fn main() {
// Set high priority so this process is killed last under resource pressure // Set high priority so this process is killed last under resource pressure
set_high_priority(); set_high_priority();
log::error!("Proxy worker starting, looking for config id: {}", id); log::info!(
log::error!("Process PID: {}", std::process::id()); "Proxy worker starting (pid {}, config id {})",
std::process::id(),
id
);
// Retry config loading to handle file system race condition on Windows // Retry config loading to handle file system race condition on Windows
// where the config file may not be immediately visible after being written // where the config file may not be immediately visible after being written
@@ -352,7 +360,7 @@ async fn main() {
let mut attempts = 0; let mut attempts = 0;
loop { loop {
if let Some(config) = get_proxy_config(id) { if let Some(config) = get_proxy_config(id) {
log::error!( log::info!(
"Found config: id={}, port={:?}, upstream={}", "Found config: id={}, port={:?}, upstream={}",
config.id, config.id,
config.local_port, config.local_port,
@@ -369,20 +377,19 @@ async fn main() {
); );
process::exit(1); 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)); std::thread::sleep(std::time::Duration::from_millis(50));
} }
}; };
// Run the proxy server - this should never return (infinite loop) // 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 { if let Err(e) = run_proxy_server(config).await {
log::error!("Failed to run proxy server: {}", e); log::error!("Proxy server failed: {} ({:?})", e, e);
log::error!("Error details: {:?}", e);
process::exit(1); process::exit(1);
} }
// This should never be reached - run_proxy_server has an infinite loop // 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); process::exit(1);
} else { } else {
log::error!("Invalid action for proxy-worker. Use 'start'"); log::error!("Invalid action for proxy-worker. Use 'start'");
+1
View File
@@ -1219,6 +1219,7 @@ mod tests {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
}; };
let path = profile.get_profile_data_path(&profiles_dir); let path = profile.get_profile_data_path(&profiles_dir);
+174 -25
View File
@@ -7,10 +7,78 @@ use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager}; use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER; use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager}; use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize; use serde::Serialize;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System; use sysinfo::System;
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
/// low-traffic window for the average user; everyone shares the same UTC
/// instant so the value here doesn't track any one user's local schedule.
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
/// File name of the per-profile marker recording the last fingerprint
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
/// and is excluded from cloud sync (see `sync::manifest`) so each device
/// runs its own refresh schedule.
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
/// Most recent rollover instant on or before `now` — used as a staleness
/// threshold for Wayfern fingerprints. Anything generated before this
/// timestamp is considered stale and gets regenerated on next launch.
fn most_recent_rollover_epoch() -> u64 {
let now = Utc::now();
let today_threshold = Utc
.with_ymd_and_hms(
now.year(),
now.month(),
now.day(),
FINGERPRINT_ROLLOVER_HOUR_UTC,
0,
0,
)
.single()
.unwrap_or(now);
let threshold = if now >= today_threshold {
today_threshold
} else {
today_threshold - chrono::Duration::days(1)
};
threshold.timestamp().max(0) as u64
}
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
}
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
/// Returns `None` if the file doesn't exist or its content can't be parsed —
/// both signal "needs a refresh" to the caller.
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
let path = last_fp_refresh_path(profile_id, profiles_dir);
let content = std::fs::read_to_string(&path).ok()?;
content.trim().parse::<u64>().ok()
}
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
/// this profile. Failure is logged but never propagated — a missing marker
/// only costs an extra regen on the next launch, never blocks one.
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
let path = last_fp_refresh_path(profile_id, profiles_dir);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
return;
}
}
}
if let Err(e) = std::fs::write(&path, ts.to_string()) {
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
}
}
pub struct BrowserRunner { pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager, pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -83,32 +151,73 @@ impl BrowserRunner {
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)) Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
} }
async fn resolve_launch_hook_proxy( fn fire_launch_hook(profile: &BrowserProfile) {
&self, let Some(raw_url) = profile.launch_hook.as_deref() else {
profile: &BrowserProfile, return;
) -> Result<Option<ProxySettings>, String> { };
let Some(url) = profile.launch_hook.as_deref() else { let trimmed = raw_url.trim();
return Ok(None); 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!( if !matches!(parsed.scheme(), "http" | "https") {
"Calling launch hook for profile {} (ID: {})", log::warn!(
profile.name, "Skipping launch hook for profile {} (ID: {}): URL must be http or https",
profile.id profile.name,
); profile.id
);
return;
}
PROXY_MANAGER let url = parsed.to_string();
.fetch_proxy_from_url(url, Duration::from_millis(500)) let profile_name = profile.name.clone();
.await 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( async fn resolve_launch_proxy(
&self, &self,
profile: &BrowserProfile, profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> { ) -> Result<Option<ProxySettings>, String> {
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? { Self::fire_launch_hook(profile);
return Ok(Some(proxy_settings));
}
self self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
@@ -503,12 +612,32 @@ impl BrowserRunner {
wayfern_config.proxy 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(); 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!( log::info!(
"Generating random fingerprint for Wayfern profile: {}", "Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
profile.name profile.name,
randomize_every_launch,
is_stale_profile
); );
// Create a config copy without the existing fingerprint to force generation of a new one // Create a config copy without the existing fingerprint to force generation of a new one
@@ -530,10 +659,24 @@ impl BrowserRunner {
// Update the config with the new fingerprint for launching // Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone()); wayfern_config.fingerprint = Some(new_fingerprint.clone());
// 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(); let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint); updated_wayfern_config.fingerprint = Some(new_fingerprint);
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() { if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone(); updated_wayfern_config.os = wayfern_config.os.clone();
} }
@@ -1439,7 +1582,10 @@ impl BrowserRunner {
} }
if profile.password_protected { if profile.password_protected {
crate::profile::password::complete_after_quit(profile); // Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral { } else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
} }
@@ -1781,7 +1927,10 @@ impl BrowserRunner {
} }
if profile.password_protected { if profile.password_protected {
crate::profile::password::complete_after_quit(profile); // Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral { } else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
} }
+63 -16
View File
@@ -12,6 +12,7 @@ use crate::camoufox::env_vars;
use crate::camoufox::fingerprint::types::*; use crate::camoufox::fingerprint::types::*;
use crate::camoufox::fonts; use crate::camoufox::fonts;
use crate::camoufox::geolocation; use crate::camoufox::geolocation;
use crate::camoufox::presets;
use crate::camoufox::webgl; use crate::camoufox::webgl;
/// Browserforge mapping from YAML. /// Browserforge mapping from YAML.
@@ -307,10 +308,59 @@ impl CamoufoxConfigBuilder {
} }
/// Build the complete Camoufox launch configuration. /// 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> { pub fn build(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
// Generate or use provided fingerprint let mut rng = rand::rng();
let fingerprint = if let Some(fp) = self.fingerprint { let ff_version = self.ff_version;
fp
// 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 { } else {
let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?; let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?;
let options = FingerprintOptions { let options = FingerprintOptions {
@@ -320,21 +370,18 @@ impl CamoufoxConfigBuilder {
screen: self.screen_constraints, screen: self.screen_constraints,
..Default::default() ..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 // Note: we used to spoof `window.history.length` to a random value in
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent); // [1, 5] here. Newer Camoufox builds clamp the docShell session history
// to this value, which disables the toolbar back/forward buttons when
// Convert fingerprint to config // the spoof rolls a small number. The fingerprint value drifts on every
let mut config = from_browserforge(&fingerprint, self.ff_version); // user navigation anyway, so a constant spoof is detectable and not
// worth the broken navigation UX.
// 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)),
);
// Add fonts // Add fonts
if !self.custom_fonts_only { if !self.custom_fonts_only {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -7,3 +7,21 @@ pub const FONTS_JSON: &str = include_str!("fonts.json");
pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml"); pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml");
pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db"); pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db");
pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml"); 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 fonts;
pub mod geolocation; pub mod geolocation;
pub mod launcher; pub mod launcher;
pub mod presets;
pub mod webgl; pub mod webgl;
// Re-export main types for convenience // 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);
}
}
+105 -42
View File
@@ -222,10 +222,16 @@ impl CamoufoxManager {
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?; .map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Parse the fingerprint config JSON // Parse the fingerprint config JSON
let fingerprint_config: HashMap<String, serde_json::Value> = let mut fingerprint_config: HashMap<String, serde_json::Value> =
serde_json::from_str(&custom_config) serde_json::from_str(&custom_config)
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?; .map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
// Strip `window.history.length` even when present in a previously-saved
// fingerprint. Newer Camoufox clamps the docShell session history to the
// spoofed value, which disables the toolbar back/forward buttons. See
// the matching note in camoufox/config.rs.
fingerprint_config.remove("window.history.length");
// Convert to environment variables using CAMOU_CONFIG chunking // Convert to environment variables using CAMOU_CONFIG chunking
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config) let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?; .map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
@@ -287,7 +293,7 @@ impl CamoufoxManager {
} }
} }
let child = command let mut child = command
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?; .map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
@@ -296,6 +302,34 @@ impl CamoufoxManager {
log::info!("Camoufox launched with PID: {:?}", process_id); log::info!("Camoufox launched with PID: {:?}", process_id);
// Watch the child so its exit status (signal / non-zero code) lands in
// the log. Without this, all we see is "PID X is no longer running" via
// the periodic sysinfo poll, with no clue why it died.
let watch_profile_path = profile_path.to_string();
tokio::spawn(async move {
match child.wait().await {
Ok(status) => {
if status.success() {
log::info!(
"Camoufox PID {:?} for {} exited cleanly (status=0)",
process_id,
watch_profile_path
);
} else {
log::warn!(
"Camoufox PID {:?} for {} exited abnormally: {}",
process_id,
watch_profile_path,
status
);
}
}
Err(e) => {
log::warn!("Failed to await Camoufox PID {:?} exit: {}", process_id, e);
}
}
});
// Store the instance // Store the instance
let instance = CamoufoxInstance { let instance = CamoufoxInstance {
id: instance_id.clone(), id: instance_id.clone(),
@@ -557,28 +591,28 @@ impl CamoufoxManager {
for (id, instance) in inner.instances.iter() { for (id, instance) in inner.instances.iter() {
if let Some(process_id) = instance.process_id { if let Some(process_id) = instance.process_id {
// Check if the process is still alive
if !self.is_server_running(process_id).await { if !self.is_server_running(process_id).await {
// Process is dead log::info!(
// Camoufox instance is no longer running "Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
id,
process_id,
instance.profile_path
);
dead_instances.push(id.clone()); dead_instances.push(id.clone());
instances_to_remove.push(id.clone()); instances_to_remove.push(id.clone());
} }
} else { } else {
// No process_id means it's likely a dead instance log::info!("Camoufox instance {} has no PID, marking as dead", id);
// Camoufox instance has no PID, marking as dead
dead_instances.push(id.clone()); dead_instances.push(id.clone());
instances_to_remove.push(id.clone()); instances_to_remove.push(id.clone());
} }
} }
} }
// Remove dead instances
if !instances_to_remove.is_empty() { if !instances_to_remove.is_empty() {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
for id in &instances_to_remove { for id in &instances_to_remove {
inner.instances.remove(id); inner.instances.remove(id);
// Removed dead Camoufox instance
} }
} }
@@ -662,54 +696,83 @@ impl CamoufoxManager {
} }
} }
// Write explicit proxy prefs to user.js so Firefox always uses the local // Patch user.js with Camoufox-specific overrides on every launch. This
// donut-proxy and never falls back to stale proxy settings baked into prefs.js // always runs (not gated on the proxy being set) because Camoufox's
// from a previous session. user.js values override prefs.js on every launch. // bundled camoufox.cfg ships defaults that break basic browser features
if let Some(proxy_str) = &config.proxy { // and we need to override them per-profile.
{
let user_js_path = profile_path.join("user.js"); let user_js_path = profile_path.join("user.js");
let mut prefs = String::new(); let mut prefs = String::new();
// Preserve existing user.js content (ephemeral prefs, etc.) // Preserve existing user.js lines, but strip any keys we're about to
// re-emit so they never duplicate.
let managed_keys = [
"network.proxy.",
"xpinstall.signatures.required",
"extensions.startupScanScopes",
"browser.sessionhistory.max_entries",
"browser.sessionhistory.max_total_viewers",
];
if let Ok(existing) = std::fs::read_to_string(&user_js_path) { if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
// Strip old proxy prefs so we don't duplicate
for line in existing.lines() { for line in existing.lines() {
if !line.contains("network.proxy.") { if !managed_keys.iter().any(|k| line.contains(k)) {
prefs.push_str(line); prefs.push_str(line);
prefs.push('\n'); prefs.push('\n');
} }
} }
} }
if let Ok(parsed) = url::Url::parse(proxy_str) { // Camoufox's bundled camoufox.cfg sets these to 0, which makes
let host = parsed.host_str().unwrap_or("127.0.0.1"); // docShell remember zero prior pages and leaves the toolbar
let port = parsed.port().unwrap_or(8080); // back/forward buttons permanently disabled no matter how much
let scheme = parsed.scheme(); // the user navigates. Restore Firefox defaults.
prefs.push_str(
"user_pref(\"browser.sessionhistory.max_entries\", 50);\n\
user_pref(\"browser.sessionhistory.max_total_viewers\", -1);\n",
);
if scheme == "socks5" || scheme == "socks4" { // Required for sideloaded extensions:
prefs.push_str(&format!( // - signatures.required=false accepts unsigned .xpi (Camoufox is built
"user_pref(\"network.proxy.type\", 1);\n\ // without MOZ_REQUIRE_SIGNING so this is honored).
user_pref(\"network.proxy.socks\", \"{host}\");\n\ // - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly
user_pref(\"network.proxy.socks_port\", {port});\n\ // dropped .xpi files in <profile>/extensions/ get registered.
user_pref(\"network.proxy.socks_version\", {});\n\ prefs.push_str(
user_pref(\"network.proxy.socks_remote_dns\", true);\n", "user_pref(\"xpinstall.signatures.required\", false);\n\
if scheme == "socks5" { 5 } else { 4 } user_pref(\"extensions.startupScanScopes\", 1);\n",
)); );
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
if let Err(e) = std::fs::write(&user_js_path, prefs) { if let Some(proxy_str) = &config.proxy {
log::warn!("Failed to write proxy prefs to user.js: {e}"); if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
} }
} }
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write user.js: {e}");
}
} }
self self
+7 -6
View File
@@ -1215,13 +1215,14 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> { pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?; 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(); let manager = crate::settings_manager::SettingsManager::instance();
if let Ok(sync_settings) = manager.get_sync_settings() { let _ = manager.save_sync_server_url(None);
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
let _ = manager.save_sync_server_url(None);
}
}
let _ = manager.remove_sync_token(&app_handle).await; let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies // Remove cloud-managed and cloud-derived proxies
+148 -1
View File
@@ -1,6 +1,6 @@
use crate::profile::manager::ProfileManager; use crate::profile::manager::ProfileManager;
use crate::profile::BrowserProfile; use crate::profile::BrowserProfile;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
@@ -134,6 +134,24 @@ pub struct CookieReadResult {
pub total_count: usize, 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 /// Request to copy specific cookies
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieCopyRequest { 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 /// Public API: Copy cookies between profiles
pub async fn copy_cookies( pub async fn copy_cookies(
app_handle: &AppHandle, 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 data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> = let mut removal_versions_by_browser: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
for (browser, _) in &to_remove { for (browser, version) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1; removal_versions_by_browser
.entry(browser.clone())
.or_default()
.push(version.clone());
} }
to_remove.retain(|(browser, version)| { let mut keep_per_browser: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (browser, versions) in &removal_versions_by_browser {
let total = data let total = data
.browsers .browsers
.get(browser.as_str()) .get(browser.as_str())
.map(|v| v.len()) .map(|v| v.len())
.unwrap_or(0); .unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0); if versions.len() >= total {
if removing >= total { if let Some(latest) = versions
log::info!("Keeping last available version: {browser} {version}"); .iter()
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1; .max_by(|a, b| crate::api_client::compare_versions(a, b))
{
keep_per_browser.insert(browser.clone(), latest.clone());
}
}
}
drop(data);
to_remove.retain(|(browser, version)| {
if keep_per_browser
.get(browser)
.is_some_and(|keep| keep == version)
{
log::info!("Keeping latest available version: {browser} {version}");
return false; return false;
} }
true true
+1
View File
@@ -280,6 +280,7 @@ mod tests {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
} }
} }
+99 -37
View File
@@ -27,6 +27,11 @@ pub struct Extension {
pub author: Option<String>, pub author: Option<String>,
#[serde(default)] #[serde(default)]
pub homepage_url: Option<String>, pub homepage_url: Option<String>,
/// Firefox extension ID from `browser_specific_settings.gecko.id` (or
/// `applications.gecko.id` in old manifests). Firefox refuses to load a
/// sideloaded .xpi unless the filename matches this value.
#[serde(default)]
pub gecko_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
(name, version, description, author, homepage_url) (name, version, description, author, homepage_url)
} }
/// Read `browser_specific_settings.gecko.id` (or the legacy
/// `applications.gecko.id`) from the extension's manifest.json. Firefox uses
/// this value as the canonical add-on ID; sideloaded .xpi files must be named
/// `<gecko_id>.xpi` to be picked up.
fn extract_gecko_id(file_data: &[u8], file_type: &str) -> Option<String> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut manifest_content = String::new();
std::io::Read::read_to_string(
&mut archive.by_name("manifest.json").ok()?,
&mut manifest_content,
)
.ok()?;
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
manifest
.pointer("/browser_specific_settings/gecko/id")
.or_else(|| manifest.pointer("/applications/gecko/id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> { fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
let zip_start = if file_type == "crx" { let zip_start = if file_type == "crx" {
find_zip_start(file_data) find_zip_start(file_data)
@@ -285,6 +316,7 @@ impl ExtensionManager {
name name
}; };
let gecko_id = extract_gecko_id(&file_data, &file_type);
let ext = Extension { let ext = Extension {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
name: final_name, name: final_name,
@@ -299,6 +331,7 @@ impl ExtensionManager {
description, description,
author, author,
homepage_url, homepage_url,
gecko_id,
}; };
let file_dir = self.get_file_dir(&ext.id); let file_dir = self.get_file_dir(&ext.id);
@@ -415,6 +448,7 @@ impl ExtensionManager {
ext.name = mn; ext.name = mn;
} }
} }
ext.gecko_id = extract_gecko_id(&data, &new_file_type);
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) { if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}")); let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
@@ -893,24 +927,33 @@ impl ExtensionManager {
continue; continue;
} }
let src_file = self.get_file_dir(ext_id).join(&ext.file_name); let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
if src_file.exists() { if !src_file.exists() {
// Firefox expects .xpi files in extensions dir continue;
let dest_name = if ext.file_type == "zip" {
format!(
"{}.xpi",
ext
.file_name
.rsplit('.')
.next_back()
.unwrap_or(&ext.file_name)
)
} else {
ext.file_name.clone()
};
let dest = extensions_dir.join(&dest_name);
fs::copy(&src_file, &dest)?;
extension_paths.push(dest.to_string_lossy().to_string());
} }
// Firefox/Camoufox only loads sideloaded .xpi files whose filename
// matches `browser_specific_settings.gecko.id` from the manifest.
// Prefer the cached value; fall back to reading the manifest now
// for extensions added before the field existed.
let gecko_id = if let Some(ref id) = ext.gecko_id {
Some(id.clone())
} else if let Ok(data) = fs::read(&src_file) {
extract_gecko_id(&data, &ext.file_type)
} else {
None
};
let Some(gecko_id) = gecko_id else {
log::warn!(
"Skipping Firefox extension '{}': could not determine gecko id from manifest.json",
ext.name
);
continue;
};
let dest = extensions_dir.join(format!("{gecko_id}.xpi"));
fs::copy(&src_file, &dest)?;
extension_paths.push(dest.to_string_lossy().to_string());
} }
} }
} }
@@ -1022,30 +1065,49 @@ impl ExtensionManager {
} }
} }
if ext.version.is_none() && ext.description.is_none() { let needs_meta_backfill = ext.version.is_none() && ext.description.is_none();
let needs_gecko_backfill =
ext.gecko_id.is_none() && ext.browser_compatibility.iter().any(|b| b == "firefox");
if needs_meta_backfill || needs_gecko_backfill {
let file_path = file_dir.join(&ext.file_name); let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) { if let Ok(file_data) = fs::read(&file_path) {
let (manifest_name, version, description, author, homepage_url) = let mut updated_ext = ext.clone();
extract_manifest_metadata(&file_data, &ext.file_type); let mut changed = false;
if version.is_some()
|| description.is_some() if needs_meta_backfill {
|| author.is_some() let (manifest_name, version, description, author, homepage_url) =
|| homepage_url.is_some() extract_manifest_metadata(&file_data, &ext.file_type);
|| manifest_name.is_some() if version.is_some()
{ || description.is_some()
let mut updated_ext = ext.clone(); || author.is_some()
if let Some(v) = version { || homepage_url.is_some()
updated_ext.version = Some(v); || manifest_name.is_some()
{
if let Some(v) = version {
updated_ext.version = Some(v);
}
if let Some(d) = description {
updated_ext.description = Some(d);
}
if let Some(a) = author {
updated_ext.author = Some(a);
}
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
}
changed = true;
} }
if let Some(d) = description { }
updated_ext.description = Some(d);
} if needs_gecko_backfill {
if let Some(a) = author { if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) {
updated_ext.author = Some(a); updated_ext.gecko_id = Some(gid);
} changed = true;
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
} }
}
if changed {
let metadata_path = self.get_metadata_path(&ext.id); let metadata_path = self.get_metadata_path(&ext.id);
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) { if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
let _ = fs::write(metadata_path, json); let _ = fs::write(metadata_path, json);
+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(); let mut result = Vec::new();
for group in groups { for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0); let count = group_counts.get(&group.id).copied().unwrap_or(0);
@@ -281,18 +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) Ok(result)
} }
} }
+121 -108
View File
@@ -52,6 +52,7 @@ pub mod daemon_client;
mod daemon_spawn; mod daemon_spawn;
pub mod daemon_ws; pub mod daemon_ws;
pub mod events; pub mod events;
mod mcp_integrations;
mod mcp_server; mod mcp_server;
mod tag_manager; mod tag_manager;
mod team_lock; mod team_lock;
@@ -74,7 +75,7 @@ use profile::manager::{
use profile::password::{ use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password, change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile, set_profile_password, unlock_profile, verify_profile_password,
}; };
use browser_version_manager::{ use browser_version_manager::{
@@ -93,16 +94,17 @@ use downloader::{cancel_download, download_browser};
use settings_manager::{ use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings, 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_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings, get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_table_sorting_settings, should_show_launch_on_login_prompt, save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
}; };
use sync::{ use sync::{
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities, cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password, is_vpn_in_use_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_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
verify_e2e_password,
}; };
use tag_manager::get_all_tags; use tag_manager::get_all_tags;
@@ -310,8 +312,21 @@ async fn import_proxies_from_parsed(
} }
#[tauri::command] #[tauri::command]
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> { async fn read_profile_cookies(
cookie_manager::CookieManager::read_cookies(&profile_id) 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] #[tauri::command]
@@ -490,20 +505,20 @@ fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
} }
} }
#[tauri::command] fn is_mcp_in_claude_desktop_internal() -> bool {
fn is_mcp_in_claude_desktop() -> Result<bool, String> { let Some(dir) = claude_desktop_extension_dir() else {
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?; return false;
Ok(dir.join("manifest.json").exists()) };
dir.join("manifest.json").exists()
} }
#[tauri::command] async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
let mcp_server = mcp_server::McpServer::instance(); let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?; let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance(); let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager let token = settings_manager
.get_mcp_token(&app_handle) .get_mcp_token(app_handle)
.await .await
.map_err(|e| format!("Failed to get MCP token: {e}"))? .map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?; .ok_or("MCP token not found")?;
@@ -592,8 +607,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
Ok(()) Ok(())
} }
#[tauri::command] fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?; let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
if ext_dir.exists() { if ext_dir.exists() {
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?; std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
@@ -655,91 +669,48 @@ fn update_claude_extensions_registry(
Ok(()) Ok(())
} }
fn find_claude_cli() -> Option<std::path::PathBuf> { async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mut candidates: Vec<std::path::PathBuf> = vec![
std::path::PathBuf::from("/usr/local/bin/claude"),
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
];
if let Some(home) = dirs::home_dir() {
candidates.insert(0, home.join(".local/bin/claude"));
candidates.push(home.join(".claude/local/claude"));
}
#[cfg(windows)]
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.insert(
0,
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
);
}
for p in &candidates {
if p.exists() {
return Some(p.clone());
}
}
None
}
#[tauri::command]
async fn is_mcp_in_claude_code() -> Result<bool, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
// `claude mcp list` health-checks every registered MCP server, so a
// missing or stalled server can hang the call for many seconds. Cap it
// — for this dialog, a slow `claude` is treated the same as "not registered".
let fut = tokio::process::Command::new(&cli)
.args(["mcp", "list"])
.output();
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.map_err(|_| "claude mcp list timed out".to_string())?
.map_err(|e| format!("Failed to run claude: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.contains("donut-browser"))
}
#[tauri::command]
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let mcp_server = mcp_server::McpServer::instance(); let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?; let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance(); let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager let token = settings_manager
.get_mcp_token(&app_handle) .get_mcp_token(app_handle)
.await .await
.map_err(|e| format!("Failed to get MCP token: {e}"))? .map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?; .ok_or("MCP token not found")?;
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
let _ = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output();
let output = std::process::Command::new(&cli)
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
}
Ok(())
} }
#[tauri::command] #[tauri::command]
fn remove_mcp_from_claude_code() -> Result<(), String> { async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?; let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
let output = std::process::Command::new(&cli) Ok(mcp_integrations::list_agents_with_status(&[(
.args(["mcp", "remove", "donut-browser"]) "claude-desktop",
.output() claude_desktop_connected,
.map_err(|e| format!("Failed to run claude: {e}"))?; )]))
if !output.status.success() { }
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to remove MCP from Claude Code: {stderr}")); #[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
} }
Ok(()) if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
} }
#[tauri::command] #[tauri::command]
@@ -753,6 +724,15 @@ async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::Traffic
Ok(crate::traffic_stats::get_all_traffic_snapshots_realtime()) 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] #[tauri::command]
async fn clear_all_traffic_stats() -> Result<(), String> { async fn clear_all_traffic_stats() -> Result<(), String> {
crate::traffic_stats::clear_all_traffic_stats() crate::traffic_stats::clear_all_traffic_stats()
@@ -1139,6 +1119,7 @@ async fn generate_sample_fingerprint(
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
}; };
if browser == "camoufox" { if browser == "camoufox" {
@@ -1186,7 +1167,11 @@ pub fn run() {
.target(Target::new(TargetKind::LogDir { .target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()), 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) .level(log::LevelFilter::Info)
.format(|out, message, record| { .format(|out, message, record| {
use chrono::Local; use chrono::Local;
@@ -1222,6 +1207,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| { .setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk) // Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs(); ephemeral_dirs::recover_ephemeral_dirs();
@@ -1244,7 +1230,7 @@ pub fn run() {
#[allow(unused_variables)] #[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser") .title("Donut Browser")
.inner_size(840.0, 500.0) .inner_size(880.0, 500.0)
.resizable(false) .resizable(false)
.fullscreen(false) .fullscreen(false)
.center() .center()
@@ -1735,7 +1721,23 @@ pub fn run() {
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 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 // Check browser status and track changes
match runner match runner
.check_browser_status(app_handle_status.clone(), &profile) .check_browser_status(app_handle_status.clone(), &profile)
@@ -1778,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 // Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() { if let Some(scheduler) = sync::get_global_scheduler() {
if is_running { if is_running {
@@ -1788,13 +1803,6 @@ pub fn run() {
} }
} }
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit(&profile);
}
last_running_states.insert(profile_id, is_running); last_running_states.insert(profile_id, is_running);
} else { } else {
// Update the state even if unchanged to ensure we have it tracked // Update the state even if unchanged to ensure we have it tracked
@@ -1974,6 +1982,8 @@ pub fn run() {
rename_profile, rename_profile,
get_app_settings, get_app_settings,
save_app_settings, save_app_settings,
read_log_files,
open_log_directory,
should_show_launch_on_login_prompt, should_show_launch_on_login_prompt,
enable_launch_on_login, enable_launch_on_login,
decline_launch_on_login, decline_launch_on_login,
@@ -2041,11 +2051,13 @@ pub fn run() {
stop_api_server, stop_api_server,
get_api_server_status, get_api_server_status,
get_all_traffic_snapshots, get_all_traffic_snapshots,
get_profile_traffic_snapshot,
clear_all_traffic_stats, clear_all_traffic_stats,
get_traffic_stats_for_period, get_traffic_stats_for_period,
get_sync_settings, get_sync_settings,
save_sync_settings, save_sync_settings,
set_profile_sync_mode, set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync, request_profile_sync,
set_proxy_sync_enabled, set_proxy_sync_enabled,
set_group_sync_enabled, set_group_sync_enabled,
@@ -2059,8 +2071,11 @@ pub fn run() {
enable_sync_for_all_entities, enable_sync_for_all_entities,
set_e2e_password, set_e2e_password,
check_has_e2e_password, check_has_e2e_password,
verify_e2e_password,
delete_e2e_password, delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies, read_profile_cookies,
get_profile_cookie_stats,
copy_profile_cookies, copy_profile_cookies,
import_cookies_from_file, import_cookies_from_file,
export_profile_cookies, export_profile_cookies,
@@ -2074,12 +2089,9 @@ pub fn run() {
stop_mcp_server, stop_mcp_server,
get_mcp_server_status, get_mcp_server_status,
get_mcp_config, get_mcp_config,
is_mcp_in_claude_desktop, list_mcp_agents,
add_mcp_to_claude_desktop, add_mcp_to_agent,
remove_mcp_from_claude_desktop, remove_mcp_from_agent,
is_mcp_in_claude_code,
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
// VPN commands // VPN commands
import_vpn_config, import_vpn_config,
list_vpn_configs, list_vpn_configs,
@@ -2122,6 +2134,7 @@ pub fn run() {
set_profile_password, set_profile_password,
change_profile_password, change_profile_password,
remove_profile_password, remove_profile_password,
verify_profile_password,
unlock_profile, unlock_profile,
lock_profile, lock_profile,
is_profile_locked, is_profile_locked,
+574
View File
@@ -0,0 +1,574 @@
// MCP client integrations — installs/removes the donut-browser MCP server in
// 14 popular AI assistant clients. Ports the add-mcp registry to Rust.
//
// Claude Desktop is managed via Claude's local extensions bundle
// (manifest.json + node bridge), since the desktop app supports only stdio
// servers via its plain JSON config but exposes HTTP through the extension
// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other
// agents (including Claude Code) use the generic config-file installer here.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const SERVER_NAME: &str = "donut-browser";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum AgentCategory {
DesktopApp,
Cli,
Editor,
EditorExt,
}
#[derive(Debug, Clone, Copy)]
enum ConfigFormat {
Json,
Toml,
Yaml,
}
#[derive(Debug, Clone)]
struct AgentSpec {
id: &'static str,
display_name: &'static str,
category: AgentCategory,
/// Top-level key (supports dot notation) where the server is written.
config_key: &'static str,
format: ConfigFormat,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct McpAgentInfo {
pub id: String,
pub display_name: String,
pub category: AgentCategory,
pub connected: bool,
/// True when the underlying client appears to be installed on the system
/// (its config directory exists), regardless of whether we have installed
/// the donut-browser server into it.
pub detected: bool,
}
fn home() -> Option<PathBuf> {
dirs::home_dir()
}
#[cfg(target_os = "macos")]
fn vscode_user_dir() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Code")
.join("User")
})
}
#[cfg(target_os = "windows")]
fn vscode_user_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Code").join("User"))
}
#[cfg(target_os = "linux")]
fn vscode_user_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Code").join("User"))
}
#[cfg(target_os = "macos")]
fn zed_config_dir() -> Option<PathBuf> {
home().map(|h| h.join("Library").join("Application Support").join("Zed"))
}
#[cfg(target_os = "windows")]
fn zed_config_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Zed"))
}
#[cfg(target_os = "linux")]
fn zed_config_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("zed"))
}
#[cfg(target_os = "windows")]
fn goose_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Block")
.join("goose")
.join("config")
.join("config.yaml")
})
}
#[cfg(not(target_os = "windows"))]
fn goose_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("goose").join("config.yaml"))
}
/// Resolve the global config path for an agent. Returns `None` on unsupported
/// platforms (none currently — every supported agent has a defined path on
/// macOS/Linux/Windows).
fn config_path_for(agent_id: &str) -> Option<PathBuf> {
let h = home()?;
match agent_id {
"antigravity" => Some(
h.join(".gemini")
.join("antigravity")
.join("mcp_config.json"),
),
"cline" => vscode_user_dir().map(|d| {
d.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json")
}),
"cline-cli" => {
let base = std::env::var("CLINE_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".cline"));
Some(
base
.join("data")
.join("settings")
.join("cline_mcp_settings.json"),
)
}
"claude-code" => Some(h.join(".claude.json")),
"claude-desktop" => claude_desktop_config_path(),
"codex" => {
let base = std::env::var("CODEX_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".codex"));
Some(base.join("config.toml"))
}
"cursor" => Some(h.join(".cursor").join("mcp.json")),
"gemini-cli" => Some(h.join(".gemini").join("settings.json")),
"goose" => goose_config_path(),
"github-copilot-cli" => Some(
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".copilot"))
.join("mcp-config.json"),
),
"mcporter" => {
// add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back
// to mcporter.jsonc if it already exists, else default to mcporter.json.
let dir = h.join(".mcporter");
let json_path = dir.join("mcporter.json");
let jsonc_path = dir.join("mcporter.jsonc");
if json_path.exists() {
Some(json_path)
} else if jsonc_path.exists() {
Some(jsonc_path)
} else {
Some(json_path)
}
}
"opencode" => Some(h.join(".config").join("opencode").join("opencode.json")),
"vscode" => vscode_user_dir().map(|d| d.join("mcp.json")),
"zed" => zed_config_dir().map(|d| d.join("settings.json")),
_ => None,
}
}
#[cfg(target_os = "macos")]
fn claude_desktop_config_path() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "windows")]
fn claude_desktop_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "linux")]
fn claude_desktop_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Claude").join("claude_desktop_config.json"))
}
const AGENT_SPECS: &[AgentSpec] = &[
AgentSpec {
id: "claude-desktop",
display_name: "Claude Desktop",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "claude-code",
display_name: "Claude Code",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cursor",
display_name: "Cursor",
category: AgentCategory::Editor,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "vscode",
display_name: "VS Code",
category: AgentCategory::Editor,
config_key: "servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "zed",
display_name: "Zed",
category: AgentCategory::Editor,
config_key: "context_servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline-cli",
display_name: "Cline CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline",
display_name: "Cline VSCode",
category: AgentCategory::EditorExt,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "codex",
display_name: "Codex",
category: AgentCategory::Cli,
config_key: "mcp_servers",
format: ConfigFormat::Toml,
},
AgentSpec {
id: "gemini-cli",
display_name: "Gemini CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "github-copilot-cli",
display_name: "GitHub Copilot CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "goose",
display_name: "Goose",
category: AgentCategory::Cli,
config_key: "extensions",
format: ConfigFormat::Yaml,
},
AgentSpec {
id: "antigravity",
display_name: "Antigravity",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "opencode",
display_name: "OpenCode",
category: AgentCategory::Cli,
config_key: "mcp",
format: ConfigFormat::Json,
},
AgentSpec {
id: "mcporter",
display_name: "MCPorter",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
];
fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> {
AGENT_SPECS.iter().find(|s| s.id == agent_id)
}
fn detect_agent_directory(agent_id: &str) -> bool {
// Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate
// parent of the config file. Used only for UI annotation; install/uninstall
// always operates on the resolved config path.
let Some(h) = home() else {
return false;
};
match agent_id {
"antigravity" => h.join(".gemini").exists(),
"cline" => config_path_for("cline")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"cline-cli" => config_path_for("cline-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"claude-code" => h.join(".claude").exists(),
"claude-desktop" => claude_desktop_config_path()
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"codex" => h.join(".codex").exists(),
"cursor" => h.join(".cursor").exists(),
"gemini-cli" => h.join(".gemini").exists(),
"github-copilot-cli" => config_path_for("github-copilot-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"goose" => goose_config_path().is_some_and(|p| p.exists()),
"mcporter" => h.join(".mcporter").exists(),
"opencode" => h.join(".config").join("opencode").exists(),
"vscode" => vscode_user_dir().is_some_and(|d| d.exists()),
"zed" => zed_config_dir().is_some_and(|d| d.exists()),
_ => false,
}
}
/// Transform the donut-browser HTTP server config into the per-agent shape.
/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge
/// (handled by the extension installer in lib.rs).
fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value {
use serde_json::json;
match agent_id {
"zed" => json!({ "source": "custom", "type": "http", "url": url }),
"opencode" => json!({ "type": "remote", "url": url, "enabled": true }),
"antigravity" => json!({ "serverUrl": url }),
"cursor" => json!({ "url": url }),
"cline" | "cline-cli" => json!({
"url": url,
"type": "streamableHttp",
"disabled": false,
}),
"codex" => json!({ "type": "http", "url": url }),
"github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }),
"goose" => json!({
"name": SERVER_NAME,
"description": "",
"type": "streamable_http",
"uri": url,
"headers": {},
"enabled": true,
"timeout": 300,
}),
"vscode" => json!({ "type": "http", "url": url }),
// claude-code, claude-desktop, gemini-cli, mcporter — passthrough
_ => json!({ "type": "http", "url": url }),
}
}
/// Detect whether a server config object looks like our donut-browser HTTP
/// endpoint by URL prefix. Matches across the various per-agent key shapes
/// (`url`, `uri`, `serverUrl`).
fn config_matches_donut(value: &serde_json::Value) -> bool {
for key in ["url", "uri", "serverUrl"] {
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if s.contains("/mcp/")
&& (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost"))
{
return true;
}
}
}
false
}
fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value {
let Ok(content) = fs::read_to_string(path) else {
return serde_json::Value::Null;
};
match format {
ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null),
ConfigFormat::Toml => toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|t| serde_json::to_value(t).ok())
.unwrap_or(serde_json::Value::Null),
ConfigFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(&content)
.ok()
.and_then(|y| serde_json::to_value(y).ok())
.unwrap_or(serde_json::Value::Null),
}
}
fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = match format {
ConfigFormat::Json => {
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))?
}
ConfigFormat::Toml => {
let toml_val: toml::Value = serde_json::from_value(value.clone())
.map_err(|e| format!("Failed to convert to TOML: {e}"))?;
toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))?
}
ConfigFormat::Yaml => {
let yaml_val: serde_yaml::Value = serde_yaml::from_str(
&serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?,
)
.map_err(|e| format!("Failed to convert to YAML: {e}"))?;
serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))?
}
};
fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
/// Navigate `config_key` (dot notation), creating object literals at each
/// missing level. Returns a mutable reference to the bottom container so the
/// caller can set/remove server entries.
fn ensure_nested_object<'a>(
root: &'a mut serde_json::Value,
config_key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !root.is_object() {
*root = serde_json::Value::Object(serde_json::Map::new());
}
let mut current = root.as_object_mut().expect("just set to object");
let parts: Vec<&str> = config_key.split('.').collect();
for part in &parts {
let entry = current
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
current = entry.as_object_mut().expect("just ensured object");
}
current
}
fn nested_object<'a>(
root: &'a serde_json::Value,
config_key: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let mut current = root.as_object()?;
for part in config_key.split('.') {
current = current.get(part)?.as_object()?;
}
Some(current)
}
fn is_generic_agent_connected(agent_id: &str) -> bool {
let Some(spec) = spec_for(agent_id) else {
return false;
};
let Some(path) = config_path_for(agent_id) else {
return false;
};
if !path.exists() {
return false;
}
let root = read_value(&path, spec.format);
let Some(servers) = nested_object(&root, spec.config_key) else {
return false;
};
if let Some(entry) = servers.get(SERVER_NAME) {
return config_matches_donut(entry);
}
servers.values().any(config_matches_donut)
}
/// Install or remove the donut-browser entry from a generic agent. Returns
/// `true` if a write happened. Callers handle higher-level dispatch (Claude
/// Desktop extension setup, Claude Code CLI invocation).
pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let path = config_path_for(agent_id)
.ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?;
let mut root = if path.exists() {
read_value(&path, spec.format)
} else {
serde_json::Value::Object(serde_json::Map::new())
};
if !root.is_object() {
root = serde_json::Value::Object(serde_json::Map::new());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.insert(
SERVER_NAME.to_string(),
transform_remote_config(agent_id, url),
);
write_value(&path, &root, spec.format)
}
pub fn uninstall_generic(agent_id: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let Some(path) = config_path_for(agent_id) else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let mut root = read_value(&path, spec.format);
if !root.is_object() {
return Ok(());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.remove(SERVER_NAME);
write_value(&path, &root, spec.format)
}
pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec<McpAgentInfo> {
AGENT_SPECS
.iter()
.map(|spec| {
let connected = connected_overrides
.iter()
.find(|(id, _)| *id == spec.id)
.map(|(_, c)| *c)
.unwrap_or_else(|| is_generic_agent_connected(spec.id));
McpAgentInfo {
id: spec.id.to_string(),
display_name: spec.display_name.to_string(),
category: spec.category,
connected,
detected: detect_agent_directory(spec.id),
}
})
.collect()
}
pub fn agent_exists(agent_id: &str) -> bool {
spec_for(agent_id).is_some()
}
+567 -43
View File
@@ -33,6 +33,48 @@ pub struct McpTool {
pub input_schema: serde_json::Value, pub input_schema: serde_json::Value,
} }
/// JavaScript executed in the target page to enumerate visible interactive
/// elements. Returns a JSON string `{elements, count, truncated}` where
/// `elements` is the newline-joined labeled list. Live references are stashed
/// on `window.__donut_interactive` so subsequent `click_by_index` /
/// `type_by_index` calls can resolve `index → Element` without round-tripping
/// a selector. `__MAX_CHARS__` is substituted at call time.
const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => {
const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt'];
const MAX_CHARS = __MAX_CHARS__;
const interactive = [];
const lines = [];
let truncated = false;
let total = 0;
const nodes = document.querySelectorAll(SELECTORS);
for (const el of nodes) {
if (el.disabled) continue;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) continue;
const style = window.getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
const tag = el.tagName.toLowerCase();
const parts = [];
for (const a of ATTRS) {
const v = el.getAttribute(a);
if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"');
}
let text = '';
if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100);
}
const idx = interactive.length;
const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + '</' + tag + '>';
if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; }
total += line.length + 1;
interactive.push(el);
lines.push(line);
}
window.__donut_interactive = interactive;
return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated });
})()"#;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct McpRequest { pub struct McpRequest {
@@ -1103,6 +1145,25 @@ impl McpServer {
"required": ["profile_id"] "required": ["profile_id"]
}), }),
}, },
// Cookie management tools
McpTool {
name: "import_profile_cookies".to_string(),
description: "Import cookies into a Wayfern or Camoufox profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the target profile"
},
"content": {
"type": "string",
"description": "Raw cookie file content (JSON array or Netscape cookies.txt)"
}
},
"required": ["profile_id", "content"]
}),
},
// Team lock tools // Team lock tools
McpTool { McpTool {
name: "get_team_locks".to_string(), name: "get_team_locks".to_string(),
@@ -1354,6 +1415,76 @@ impl McpServer {
"required": ["profile_id"] "required": ["profile_id"]
}), }),
}, },
McpTool {
name: "get_interactive_elements".to_string(),
description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"max_chars": {
"type": "integer",
"description": "Cap on the serialized output length (default: 40000). The response carries a `truncated` flag if the list was cut off — narrow the viewport or scroll if you need elements past the cutoff."
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "click_by_index".to_string(),
description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
}
},
"required": ["profile_id", "index"]
}),
},
McpTool {
name: "type_by_index".to_string(),
description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
},
"text": {
"type": "string",
"description": "Text to type into the element"
},
"clear_first": {
"type": "boolean",
"description": "Clear the input before typing (default: true)"
},
"instant": {
"type": "boolean",
"description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection."
},
"wpm": {
"type": "number",
"description": "Target words per minute for human typing (default: 80)"
}
},
"required": ["profile_id", "index", "text"]
}),
},
] ]
} }
@@ -1479,103 +1610,142 @@ impl McpServer {
.unwrap_or("<none>"); .unwrap_or("<none>");
log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}"); 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 { match tool_name {
"list_profiles" => self.handle_list_profiles().await, "list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).await, "get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => { "run_profile" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_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, "kill_profile" => self.handle_kill_profile(arguments).await,
"create_profile" => self.handle_create_profile(&arguments).await, "create_profile" => self.handle_create_profile(arguments).await,
"update_profile" => self.handle_update_profile(&arguments).await, "update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_profile(&arguments).await, "delete_profile" => self.handle_delete_profile(arguments).await,
"list_tags" => self.handle_list_tags().await, "list_tags" => self.handle_list_tags().await,
"list_proxies" => self.handle_list_proxies().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 // Group management
"list_groups" => self.handle_list_groups().await, "list_groups" => self.handle_list_groups().await,
"get_group" => self.handle_get_group(&arguments).await, "get_group" => self.handle_get_group(arguments).await,
"create_group" => self.handle_create_group(&arguments).await, "create_group" => self.handle_create_group(arguments).await,
"update_group" => self.handle_update_group(&arguments).await, "update_group" => self.handle_update_group(arguments).await,
"delete_group" => self.handle_delete_group(&arguments).await, "delete_group" => self.handle_delete_group(arguments).await,
"assign_profiles_to_group" => self.handle_assign_profiles_to_group(&arguments).await, "assign_profiles_to_group" => self.handle_assign_profiles_to_group(arguments).await,
// Full proxy management // Full proxy management
"get_proxy" => self.handle_get_proxy(&arguments).await, "get_proxy" => self.handle_get_proxy(arguments).await,
"create_proxy" => self.handle_create_proxy(&arguments).await, "create_proxy" => self.handle_create_proxy(arguments).await,
"update_proxy" => self.handle_update_proxy(&arguments).await, "update_proxy" => self.handle_update_proxy(arguments).await,
"delete_proxy" => self.handle_delete_proxy(&arguments).await, "delete_proxy" => self.handle_delete_proxy(arguments).await,
// Proxy import/export // Proxy import/export
"export_proxies" => self.handle_export_proxies(&arguments).await, "export_proxies" => self.handle_export_proxies(arguments).await,
"import_proxies" => self.handle_import_proxies(&arguments).await, "import_proxies" => self.handle_import_proxies(arguments).await,
// VPN management // 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, "list_vpn_configs" => self.handle_list_vpn_configs().await,
"delete_vpn" => self.handle_delete_vpn(&arguments).await, "delete_vpn" => self.handle_delete_vpn(arguments).await,
"connect_vpn" => self.handle_connect_vpn(&arguments).await, "connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await, "disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(&arguments).await, "get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management // Fingerprint management
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await, "get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await, "update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
"update_profile_proxy_bypass_rules" => { "update_profile_proxy_bypass_rules" => {
self self
.handle_update_profile_proxy_bypass_rules(&arguments) .handle_update_profile_proxy_bypass_rules(arguments)
.await .await
} }
// DNS blocklist management // 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, "get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
// Extension management // Extension management
"list_extensions" => self.handle_list_extensions().await, "list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await, "list_extension_groups" => self.handle_list_extension_groups().await,
"create_extension_group" => self.handle_create_extension_group(&arguments).await, "create_extension_group" => self.handle_create_extension_group(arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(&arguments).await, "delete_extension" => self.handle_delete_extension_mcp(arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(&arguments).await, "delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
"assign_extension_group_to_profile" => { "assign_extension_group_to_profile" => {
self self
.handle_assign_extension_group_to_profile(&arguments) .handle_assign_extension_group_to_profile(arguments)
.await .await
} }
// Cookie management
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
// Team lock tools // Team lock tools
"get_team_locks" => self.handle_get_team_locks().await, "get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await, "get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
// Synchronizer tools // Synchronizer tools
"start_sync_session" => { "start_sync_session" => {
Self::require_paid_subscription("Synchronizer").await?; Self::require_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, "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) // Browser interaction tools (require paid subscription)
"navigate" => { "navigate" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_paid_subscription("Browser automation").await?;
self.handle_navigate(&arguments).await self.handle_navigate(arguments).await
} }
"screenshot" => { "screenshot" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_paid_subscription("Browser automation").await?;
self.handle_screenshot(&arguments).await self.handle_screenshot(arguments).await
} }
"evaluate_javascript" => { "evaluate_javascript" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_paid_subscription("Browser automation").await?;
self.handle_evaluate_javascript(&arguments).await self.handle_evaluate_javascript(arguments).await
} }
"click_element" => { "click_element" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_paid_subscription("Browser automation").await?;
self.handle_click_element(&arguments).await self.handle_click_element(arguments).await
} }
"type_text" => { "type_text" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_paid_subscription("Browser automation").await?;
self.handle_type_text(&arguments).await self.handle_type_text(arguments).await
} }
"get_page_content" => { "get_page_content" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_content(&arguments).await self.handle_get_page_content(arguments).await
} }
"get_page_info" => { "get_page_info" => {
Self::require_paid_subscription("Browser automation").await?; Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_info(&arguments).await self.handle_get_page_info(arguments).await
}
"get_interactive_elements" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_interactive_elements(arguments).await
}
"click_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_click_by_index(arguments).await
}
"type_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_type_by_index(arguments).await
} }
_ => Err(McpError { _ => Err(McpError {
code: -32602, code: -32602,
@@ -2706,6 +2876,74 @@ impl McpServer {
})) }))
} }
// Cookie management handlers
async fn handle_import_profile_cookies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
})?;
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?
.clone()
};
let result =
crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import cookies: {e}"),
})?;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = crate::profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.is_sync_enabled() {
let pid = profile_id.to_string();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} replaced, {} parse error(s)",
result.cookies_imported,
result.cookies_replaced,
result.errors.len()
)
}]
}))
}
// VPN management handlers // VPN management handlers
async fn handle_import_vpn( async fn handle_import_vpn(
&self, &self,
@@ -4238,6 +4476,11 @@ impl McpServer {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("text"); .unwrap_or("text");
let selector = arguments.get("selector").and_then(|v| v.as_str()); let selector = arguments.get("selector").and_then(|v| v.as_str());
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(40_000);
let profile = self.get_running_profile(profile_id)?; let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?; let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
@@ -4285,10 +4528,28 @@ impl McpServer {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
// Cap output so a 500 KB DOM dump doesn't blow out the agent's context.
// Slice on character boundaries (chars().take().collect()) rather than
// byte indices, since the latter would panic on multi-byte boundaries.
let total_chars = content.chars().count();
let (text, truncated) = if total_chars > max_chars {
(content.chars().take(max_chars).collect::<String>(), true)
} else {
(content.to_string(), false)
};
let payload = if truncated {
format!(
"{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]"
)
} else {
text
};
Ok(serde_json::json!({ Ok(serde_json::json!({
"content": [{ "content": [{
"type": "text", "type": "text",
"text": content "text": payload
}] }]
})) }))
} }
@@ -4336,6 +4597,267 @@ impl McpServer {
})) }))
} }
async fn handle_get_interactive_elements(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(40_000);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
// Walk the DOM for visible, non-disabled interactive elements, label them
// with a zero-based index, and cache the live references on
// `window.__donut_interactive` so click_by_index / type_by_index can
// resolve the index → Element without round-tripping a selector.
let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string());
let result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Enumeration failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
let payload_str = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{}");
let payload: serde_json::Value =
serde_json::from_str(payload_str).unwrap_or(serde_json::json!({}));
let elements = payload
.get("elements")
.and_then(|v| v.as_str())
.unwrap_or("");
let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
let truncated = payload
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let header = if truncated {
format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):")
} else {
format!("{count} interactive elements:")
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("{header}\n{elements}")
}]
}))
}
async fn handle_click_by_index(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let index = arguments
.get("index")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing index".to_string(),
})?;
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let js = format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.click();
return true;
}})()"#
);
let result = self
.send_cdp_and_wait_for_load(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
10,
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Click failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Clicked element at index {index}")
}]
}))
}
async fn handle_type_by_index(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let index = arguments
.get("index")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing index".to_string(),
})?;
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing text".to_string(),
})?;
let clear_first = arguments
.get("clear_first")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let instant = arguments
.get("instant")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let wpm = arguments.get("wpm").and_then(|v| v.as_f64());
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
// Mirrors handle_type_text's focus step but resolves the element via the
// cached index instead of a CSS selector.
let focus_js = if clear_first {
format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
el.value = '';
el.dispatchEvent(new Event('input', {{bubbles: true}}));
return true;
}})()"#
)
} else {
format!(
r#"(() => {{
const arr = window.__donut_interactive;
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
return true;
}})()"#
)
};
let focus_result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": focus_js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = focus_result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Focus failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
if instant {
self
.send_cdp(
&ws_url,
"Input.insertText",
serde_json::json!({ "text": text }),
)
.await?;
} else {
self.send_human_keystrokes(&ws_url, text, wpm).await?;
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Typed text into element at index {index}")
}]
}))
}
// --- Synchronizer handlers --- // --- Synchronizer handlers ---
async fn handle_start_sync_session( async fn handle_start_sync_session(
@@ -4535,6 +5057,8 @@ mod tests {
assert!(tool_names.contains(&"delete_extension")); assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group")); assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile")); assert!(tool_names.contains(&"assign_extension_group_to_profile"));
// Cookie tools
assert!(tool_names.contains(&"import_profile_cookies"));
// Team lock tools // Team lock tools
assert!(tool_names.contains(&"get_team_locks")); assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status")); assert!(tool_names.contains(&"get_team_lock_status"));
+208 -15
View File
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System}; use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url; use url::Url;
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
});
{
let mut f = fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)
}
pub struct ProfileManager { pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager, camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager, wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
@@ -185,6 +199,7 @@ impl ProfileManager {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
}; };
match self match self
@@ -287,6 +302,7 @@ impl ProfileManager {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
}; };
match self match self
@@ -343,6 +359,12 @@ impl ProfileManager {
created_by_email: None, created_by_email: None,
dns_blocklist, dns_blocklist,
password_protected: false, 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 // Save profile info
@@ -388,7 +410,7 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?; create_dir_all(&profile_uuid_dir)?;
let json = serde_json::to_string_pretty(profile)?; let json = serde_json::to_string_pretty(profile)?;
fs::write(profile_file, json)?; atomic_write(&profile_file, json.as_bytes())?;
// Update tag suggestions after any save // Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -413,8 +435,26 @@ impl ProfileManager {
if path.is_dir() { if path.is_dir() {
let metadata_file = path.join("metadata.json"); let metadata_file = path.join("metadata.json");
if metadata_file.exists() { if metadata_file.exists() {
let content = fs::read_to_string(&metadata_file)?; let content = match fs::read_to_string(&metadata_file) {
let mut profile: BrowserProfile = serde_json::from_str(&content)?; Ok(c) => c,
Err(e) => {
log::warn!(
"Skipping profile at {}: failed to read metadata.json: {e}",
path.display()
);
continue;
}
};
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!(
"Skipping profile at {}: invalid metadata.json: {e}",
path.display()
);
continue;
}
};
// Backfill host_os from browser config for profiles created before // Backfill host_os from browser config for profiles created before
// the field existed (or synced without it). // the field existed (or synced without it).
@@ -423,7 +463,7 @@ impl ProfileManager {
if let Some(os) = inferred_os { if let Some(os) = inferred_os {
profile.host_os = Some(os); profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) { if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json); let _ = atomic_write(&metadata_file, json.as_bytes());
} }
} }
} }
@@ -465,6 +505,8 @@ impl ProfileManager {
// Save profile with new name // Save profile with new name
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Keep tag suggestions up to date after name change (rebuild from all profiles) // Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -645,7 +687,7 @@ impl ProfileManager {
pub fn assign_profiles_to_group( pub fn assign_profiles_to_group(
&self, &self,
app_handle: &tauri::AppHandle, _app_handle: &tauri::AppHandle,
profile_ids: Vec<String>, profile_ids: Vec<String>,
group_id: Option<String>, group_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
@@ -670,14 +712,14 @@ impl ProfileManager {
profile.group_id = group_id.clone(); profile.group_id = group_id.clone();
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled // Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id { if let Some(ref new_group_id) = group_id {
let group_id_clone = new_group_id.clone(); let group_id_clone = new_group_id.clone();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let _ = let _ = crate::sync::enable_group_sync_if_needed(&group_id_clone).await;
crate::sync::enable_group_sync_if_needed(&group_id_clone, &app_handle_clone).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() { if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_group_sync(group_id_clone).await; scheduler.queue_group_sync(group_id_clone).await;
} }
@@ -726,6 +768,8 @@ impl ProfileManager {
// Save profile // Save profile
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles // Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -760,6 +804,8 @@ impl ProfileManager {
// Save profile // Save profile
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event // Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -786,6 +832,8 @@ impl ProfileManager {
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) { if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}"); log::warn!("Warning: Failed to emit profile update event: {e}");
} }
@@ -815,6 +863,8 @@ impl ProfileManager {
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); log::warn!("Warning: Failed to emit profiles-changed event: {e}");
} }
@@ -839,6 +889,8 @@ impl ProfileManager {
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); log::warn!("Warning: Failed to emit profiles-changed event: {e}");
} }
@@ -991,6 +1043,12 @@ impl ProfileManager {
created_by_email: None, created_by_email: None,
dns_blocklist: source.dns_blocklist, dns_blocklist: source.dns_blocklist,
password_protected: false, 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)?; self.save_profile(&new_profile)?;
@@ -1048,6 +1106,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!( log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).", "Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name, profile.name,
@@ -1108,6 +1168,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!( log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).", "Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name, profile.name,
@@ -1124,7 +1186,7 @@ impl ProfileManager {
pub async fn update_profile_proxy( pub async fn update_profile_proxy(
&self, &self,
app_handle: tauri::AppHandle, _app_handle: tauri::AppHandle,
profile_id: &str, profile_id: &str,
proxy_id: Option<String>, proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
@@ -1162,10 +1224,12 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new proxy if profile has sync enabled // Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id { if let Some(ref new_proxy_id) = proxy_id {
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() { if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_proxy_sync(new_proxy_id.clone()).await; scheduler.queue_proxy_sync(new_proxy_id.clone()).await;
} }
@@ -1242,7 +1306,7 @@ impl ProfileManager {
})?; })?;
// Update VPN and clear proxy (mutual exclusion) // Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id; profile.vpn_id = vpn_id.clone();
profile.proxy_id = None; profile.proxy_id = None;
self self
@@ -1251,6 +1315,18 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
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) { if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}"); log::warn!("Warning: Failed to emit profile update event: {e}");
} }
@@ -1275,9 +1351,25 @@ impl ProfileManager {
.find(|p| p.id == profile_uuid) .find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id; profile.extension_group_id = extension_group_id.clone();
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync
// 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) { if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Failed to emit profile update event: {e}"); log::warn!("Failed to emit profile update event: {e}");
} }
@@ -1417,13 +1509,18 @@ impl ProfileManager {
}; };
let mut merged = latest_profile.clone(); let mut merged = latest_profile.clone();
let mut detected_stop = false;
if let Some(pid) = found_pid { if let Some(pid) = found_pid {
if merged.process_id != Some(pid) { if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid); merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) { if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}"); log::warn!("Warning: Failed to update profile with new PID: {e}");
} }
if let Some(prev) = old_pid {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, pid);
}
} }
} else if merged.process_id.is_some() { } else if merged.process_id.is_some() {
// Clear the PID if no process found // Clear the PID if no process found
@@ -1431,6 +1528,15 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) { if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}"); log::warn!("Warning: Failed to clear profile PID: {e}");
} }
detected_stop = true;
}
if detected_stop {
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(&app_handle, &merged)
{
merged = updated;
}
} }
// Emit profile update event to frontend // Emit profile update event to frontend
@@ -1445,7 +1551,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager // Check Camoufox status using CamoufoxManager
async fn check_camoufox_status( async fn check_camoufox_status(
&self, &self,
_app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
profile: &BrowserProfile, profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager; let launcher = self.camoufox_manager;
@@ -1474,10 +1580,14 @@ impl ProfileManager {
}; };
if latest.process_id != camoufox_process.processId { if latest.process_id != camoufox_process.processId {
let old_pid = latest.process_id;
latest.process_id = camoufox_process.processId; latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) { if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}"); log::warn!("Warning: Failed to update Camoufox profile with process info: {e}");
} }
if let (Some(prev), Some(new)) = (old_pid, camoufox_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend // Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) { if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1519,6 +1629,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}"); log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
} }
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) { if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}"); log::warn!("Warning: Failed to emit profile update event: {e}");
} }
@@ -1555,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 // Emit profile update event to frontend
if let Err(e3) = events::emit("profile-updated", &latest) { if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}"); log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1569,7 +1691,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager // Check Wayfern status using WayfernManager
async fn check_wayfern_status( async fn check_wayfern_status(
&self, &self,
_app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
profile: &BrowserProfile, profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager; let manager = self.wayfern_manager;
@@ -1598,10 +1720,14 @@ impl ProfileManager {
}; };
if latest.process_id != wayfern_process.processId { if latest.process_id != wayfern_process.processId {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId; latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) { if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}"); log::warn!("Warning: Failed to update Wayfern profile with process info: {e}");
} }
if let (Some(prev), Some(new)) = (old_pid, wayfern_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend // Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) { if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1643,6 +1769,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}"); log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
} }
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) { if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}"); log::warn!("Warning: Failed to emit profile update event: {e}");
} }
@@ -1667,10 +1799,17 @@ impl ProfileManager {
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(), "user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(), "user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(), "user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
// Keep extension updates enabled and allow sideloaded extensions // Keep extension updates enabled and allow sideloaded extensions.
// - autoDisableScopes=0: profile-installed extensions are enabled by default.
// - startupScanScopes=1: rescan SCOPE_PROFILE on each launch so freshly
// dropped .xpi files in <profile>/extensions/ get registered.
// - signatures.required=false: accept unsigned/dev .xpi files. Camoufox
// is built without MOZ_REQUIRE_SIGNING so this is honored.
"user_pref(\"extensions.update.enabled\", true);".to_string(), "user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(), "user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(), "user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
"user_pref(\"extensions.startupScanScopes\", 1);".to_string(),
"user_pref(\"xpinstall.signatures.required\", false);".to_string(),
// Completely disable browser update checking // Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(), "user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(), "user_pref(\"app.update.auto\", false);".to_string(),
@@ -2060,6 +2199,38 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(err.to_string().contains("http or https")); 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)] #[allow(clippy::too_many_arguments)]
@@ -2158,12 +2329,34 @@ pub fn update_profile_note(
.map_err(|e| format!("Failed to update profile note: {e}")) .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] #[tauri::command]
pub fn update_profile_launch_hook( pub fn update_profile_launch_hook(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
profile_id: String, profile_id: String,
launch_hook: Option<String>, launch_hook: Option<String>,
) -> Result<BrowserProfile, String> { ) -> Result<BrowserProfile, String> {
validate_launch_hook(launch_hook.as_deref())?;
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
profile_manager profile_manager
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook) .update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
+65 -10
View File
@@ -233,6 +233,15 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
return Err(err_code("PROFILE_ALREADY_PROTECTED")); return Err(err_code("PROFILE_ALREADY_PROTECTED"));
} }
// Ephemeral profiles live in RAM-backed dirs that get wiped on quit, so
// there's no on-disk data to encrypt. The two features are mutually
// exclusive by design — fail loudly rather than silently producing a
// half-broken state where `password_protected` is true but the encrypted
// dir vanishes between launches.
if profile.ephemeral {
return Err(err_code("PROFILE_EPHEMERAL"));
}
if profile if profile
.process_id .process_id
.is_some_and(crate::proxy_storage::is_process_running) .is_some_and(crate::proxy_storage::is_process_running)
@@ -283,10 +292,45 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
.map_err(err_internal)?; .map_err(err_internal)?;
cache_key(id, key); cache_key(id, key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) Ok(())
} }
/// Verify a profile password without unlocking. Used by the Settings UI's
/// "Validate" button so users can confirm they remember the password without
/// performing a destructive change. Honors the same lockout schedule as
/// `unlock_profile` so a brute-force attacker can't bypass rate-limiting by
/// hammering this command.
#[tauri::command]
pub async fn verify_profile_password(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
let profile = load_profile(&id)?;
if !profile.password_protected {
return Err(err_code("PROFILE_NOT_PROTECTED"));
}
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
let salt = profile
.encryption_salt
.as_deref()
.ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?;
let key = derive_profile_key(&password, salt).map_err(err_internal)?;
let dir = profile_data_dir(&profile);
match verify_key_against_dir(&key, &dir) {
Ok(()) => {
clear_failed_attempts(&id);
Ok(())
}
Err(crate::profile::encryption::PasswordError::WrongPassword) => {
record_failed_attempt(id);
Err(err_code("INCORRECT_PASSWORD"))
}
Err(other) => Err(err_internal(other)),
}
}
#[tauri::command] #[tauri::command]
pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> { pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?; let id = parse_uuid(&profile_id)?;
@@ -387,6 +431,7 @@ pub async fn change_profile_password(
drop_cached_key(&id); drop_cached_key(&id);
cache_key(id, new_key); cache_key(id, new_key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) Ok(())
} }
@@ -455,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
.map_err(err_internal)?; .map_err(err_internal)?;
drop_cached_key(&id); drop_cached_key(&id);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) Ok(())
} }
@@ -628,22 +674,31 @@ pub fn complete_after_quit_blocking(
result result
} }
/// Async re-encrypt of a password-protected profile's ephemeral dir back to /// Re-encrypt a password-protected profile's ephemeral dir back to the
/// disk, called after the browser process exits. Optionally purges the /// on-disk encrypted dir after the browser process exits. Optionally purges
/// ephemeral dir + cached key based on the global setting. /// the ephemeral dir + cached key based on the global setting. Returns the
pub fn complete_after_quit(profile: &crate::profile::BrowserProfile) { /// number of files re-encrypted (`None` when nothing to do or the profile
/// isn't protected).
///
/// Callers that release a queued sync run after a browser quit MUST await
/// this future — releasing sync while re-encryption is still in-flight
/// uploads the stale on-disk snapshot and leaves the fresh ciphertext
/// orphaned until the next scheduler tick.
pub async fn complete_after_quit_and_wait(
profile: &crate::profile::BrowserProfile,
) -> Option<usize> {
if !profile.password_protected { if !profile.password_protected {
return; return None;
} }
let keep_decrypted = read_keep_decrypted_setting(); let keep_decrypted = read_keep_decrypted_setting();
let profile = profile.clone(); let profile = profile.clone();
tauri::async_runtime::spawn(async move { tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
let _ = tokio::task::spawn_blocking(move || { .await
complete_after_quit_blocking(&profile, keep_decrypted); .unwrap_or_else(|e| {
log::error!("complete_after_quit_and_wait join error: {e}");
None
}) })
.await;
});
} }
#[cfg(test)] #[cfg(test)]
+5
View File
@@ -73,6 +73,11 @@ pub struct BrowserProfile {
/// Decryption goes to a RAM-backed ephemeral dir, never to disk. /// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)] #[serde(default)]
pub password_protected: bool, 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 { pub fn default_release_type() -> String {
+8
View File
@@ -585,6 +585,7 @@ impl ProfileImporter {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
}; };
match self match self
@@ -666,6 +667,7 @@ impl ProfileImporter {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
created_at: None,
}; };
match self match self
@@ -718,6 +720,12 @@ impl ProfileImporter {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, 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)?; self.profile_manager.save_profile(&profile)?;
+36 -404
View File
@@ -830,6 +830,42 @@ impl ProxyManager {
Ok(updated_proxy) 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 // Delete a stored proxy
pub fn delete_stored_proxy( pub fn delete_stored_proxy(
&self, &self,
@@ -1079,149 +1115,6 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id) 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 // Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> { pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap(); let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2281,8 +2174,6 @@ mod tests {
use hyper::Response; use hyper::Response;
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Helper function to build donut-proxy binary for testing // Helper function to build donut-proxy binary for testing
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> { async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -3551,263 +3442,4 @@ mod tests {
delete_proxy_config(&id); 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"));
}
} }
+19 -47
View File
@@ -918,8 +918,8 @@ async fn handle_http(
return Ok(response); return Ok(response);
} }
log::error!( log::trace!(
"DEBUG: Handling HTTP request: {} {} (host: {:?})", "Handling HTTP request: {} {} (host: {:?})",
req.method(), req.method(),
req.uri(), req.uri(),
req.uri().host() req.uri().host()
@@ -1182,7 +1182,7 @@ pub async fn handle_proxy_connection(
} }
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> { 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: {}", "Proxy worker starting, looking for config id: {}",
config.id config.id
); );
@@ -1196,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={:?}", "Found config: id={}, port={:?}, upstream={}, profile_id={:?}",
config.id, config.id,
config.local_port, config.local_port,
@@ -1204,32 +1204,14 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
config.profile_id config.profile_id
); );
log::error!("Starting proxy server for config id: {}", config.id); // Initialize traffic tracker with profile ID if available.
// This can be called multiple times to update the tracker.
// Initialize traffic tracker with profile ID if available
// This can now be called multiple times to update the tracker
init_traffic_tracker(config.id.clone(), config.profile_id.clone()); 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 // Determine the bind address
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0))); 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. Use SO_REUSEADDR so that a freshly-restarted worker // 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 // can bind a port that the previous worker left in TIME_WAIT, and retry
@@ -1276,18 +1258,13 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}; };
let actual_port = listener.local_addr()?.port(); 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 // Update config with actual port and local_url
let mut updated_config = config.clone(); let mut updated_config = config.clone();
updated_config.local_port = Some(actual_port); updated_config.local_port = Some(actual_port);
updated_config.local_url = Some(format!("http://127.0.0.1:{}", 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) { if !crate::proxy_storage::update_proxy_config(&updated_config) {
log::error!("Failed to update proxy config"); log::error!("Failed to update proxy config");
return Err("Failed to update proxy config".into()); return Err("Failed to update proxy config".into());
@@ -1299,12 +1276,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
Some(updated_config.upstream_url.clone()) Some(updated_config.upstream_url.clone())
}; };
log::error!("Proxy server bound to 127.0.0.1:{}", actual_port); log::info!(
log::error!(
"Proxy server listening on 127.0.0.1:{} (ready to accept connections)", "Proxy server listening on 127.0.0.1:{} (ready to accept connections)",
actual_port 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 // 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) // These are much smaller than full stats and can be written frequently (~100 bytes every 2 seconds)
@@ -1623,7 +1599,7 @@ async fn handle_connect_from_buffer(
.await?; .await?;
client_stream.flush().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 // Now tunnel data bidirectionally with counting
// Wrap streams to count bytes transferred // Wrap streams to count bytes transferred
@@ -1640,17 +1616,17 @@ async fn handle_connect_from_buffer(
let (mut client_read, mut client_write) = tokio::io::split(counting_client); let (mut client_read, mut client_write) = tokio::io::split(counting_client);
let (mut target_read, mut target_write) = tokio::io::split(counting_target); 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 // Spawn two tasks to forward data in both directions
let client_to_target = tokio::spawn(async move { let client_to_target = tokio::spawn(async move {
let result = tokio::io::copy(&mut client_read, &mut target_write).await; let result = tokio::io::copy(&mut client_read, &mut target_write).await;
match result { match result {
Ok(bytes) => { Ok(bytes) => {
log::error!("DEBUG: Tunneled {} bytes from client->target", bytes); log::trace!("Tunneled {bytes} bytes from client->target");
} }
Err(e) => { Err(e) => {
log::error!("Error forwarding client->target: {:?}", e); log::debug!("Error forwarding client->target: {e:?}");
} }
} }
}); });
@@ -1659,10 +1635,10 @@ async fn handle_connect_from_buffer(
let result = tokio::io::copy(&mut target_read, &mut client_write).await; let result = tokio::io::copy(&mut target_read, &mut client_write).await;
match result { match result {
Ok(bytes) => { Ok(bytes) => {
log::error!("DEBUG: Tunneled {} bytes from target->client", bytes); log::trace!("Tunneled {bytes} bytes from target->client");
} }
Err(e) => { Err(e) => {
log::error!("Error forwarding target->client: {:?}", e); log::debug!("Error forwarding target->client: {e:?}");
} }
} }
}); });
@@ -1670,10 +1646,10 @@ async fn handle_connect_from_buffer(
// Wait for either direction to finish (connection closed) // Wait for either direction to finish (connection closed)
tokio::select! { tokio::select! {
_ = client_to_target => { _ = client_to_target => {
log::error!("DEBUG: Client->target tunnel closed"); log::trace!("Client->target tunnel closed");
} }
_ = target_to_client => { _ = target_to_client => {
log::error!("DEBUG: Target->client tunnel closed"); log::trace!("Target->client tunnel closed");
} }
} }
@@ -1682,11 +1658,7 @@ async fn handle_connect_from_buffer(
client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed); client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed);
let final_recv = let final_recv =
target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed); target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed);
log::error!( log::trace!("Tunnel closed - sent: {final_sent} bytes, received: {final_recv} bytes");
"DEBUG: Tunnel closed - sent: {} bytes, received: {} bytes",
final_sent,
final_recv
);
// Update domain-specific byte counts now that tunnel is complete // Update domain-specific byte counts now that tunnel is complete
if let Some(tracker) = get_traffic_tracker() { if let Some(tracker) = get_traffic_tracker() {
+111 -1
View File
@@ -52,7 +52,7 @@ pub struct AppSettings {
#[serde(default)] #[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)] #[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)] #[serde(default)]
pub window_resize_warning_dismissed: bool, pub window_resize_warning_dismissed: bool,
#[serde(default)] #[serde(default)]
@@ -820,6 +820,105 @@ pub async fn save_app_settings(
Ok(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] #[tauri::command]
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> { pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
let manager = SettingsManager::instance(); let manager = SettingsManager::instance();
@@ -892,6 +991,17 @@ pub async fn save_sync_settings(
sync_server_url: Option<String>, sync_server_url: Option<String>,
sync_token: Option<String>, sync_token: Option<String>,
) -> Result<SyncSettings, 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(); let manager = SettingsManager::instance();
manager manager
+135 -3
View File
@@ -4,10 +4,40 @@ use aes_gcm::{
}; };
use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 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_HEADER: &[u8] = b"DBE2E";
const E2E_FILE_VERSION: u8 = 1; 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 { fn get_e2e_password_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("e2e_password.dat") 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> { pub fn store_e2e_password(password: &str) -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path(); let file_path = get_e2e_password_path();
if let Some(parent) = file_path.parent() { if let Some(parent) = file_path.parent() {
@@ -149,6 +180,7 @@ pub fn has_e2e_password() -> bool {
} }
pub fn remove_e2e_password() -> Result<(), String> { pub fn remove_e2e_password() -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path(); let file_path = get_e2e_password_path();
if file_path.exists() { if file_path.exists() {
std::fs::remove_file(&file_path) std::fs::remove_file(&file_path)
@@ -157,8 +189,20 @@ pub fn remove_e2e_password() -> Result<(), String> {
Ok(()) 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> { 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 let salt_bytes = BASE64
.decode(profile_salt) .decode(profile_salt)
.map_err(|e| format!("Invalid salt encoding: {e}"))?; .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]; let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes[..32]); key.copy_from_slice(&hash_bytes[..32]);
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.insert(cache_key, key);
}
Ok(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}")) .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 commands
#[tauri::command] #[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 { if password.len() < 8 {
return Err("Password must be at least 8 characters".to_string()); return Err("Password must be at least 8 characters".to_string());
} }
enforce_team_owner_for_encryption_change().await?;
store_e2e_password(&password) store_e2e_password(&password)
} }
@@ -236,10 +347,31 @@ pub fn check_has_e2e_password() -> bool {
} }
#[tauri::command] #[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() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
File diff suppressed because it is too large Load Diff
+14
View File
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/startupCache/**", "**/startupCache/**",
"**/safebrowsing/**", "**/safebrowsing/**",
"**/storage/temporary/**", "**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/sessionstore-backups/**",
"**/sessions/**",
"**/serviceworker.txt",
"**/AlternateServices.bin",
"**/SiteSecurityServiceState.bin",
"**/favicons.sqlite",
"**/favicons.sqlite-*",
"**/crashes/**", "**/crashes/**",
"**/minidumps/**", "**/minidumps/**",
"*.tmp", "*.tmp",
@@ -52,6 +62,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*", "**/BrowserMetrics*",
"**/.DS_Store", "**/.DS_Store",
".donut-sync/**", ".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's
// 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 /// A single file entry in the manifest
+27 -6
View File
@@ -7,13 +7,16 @@ pub mod subscription;
pub mod types; pub mod types;
pub use client::SyncClient; pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password}; pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{ pub use engine::{
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities, cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile, enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile, get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_used_by_synced_profile, request_profile_sync, set_extension_group_sync_enabled, 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_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, 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 scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem}; pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult}; pub use types::{SyncError, SyncResult};
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
/// reassignment shows up on other devices without waiting for the next
/// scheduled tick. Spawns the async queue call so this helper is callable
/// from both sync and async contexts.
pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
let profile_id = profile.id.to_string();
tauri::async_runtime::spawn(async move {
if let Some(scheduler) = get_global_scheduler() {
scheduler.queue_profile_sync(profile_id).await;
}
});
}
+10 -3
View File
@@ -716,16 +716,18 @@ impl SyncScheduler {
match entity_type.as_str() { match entity_type.as_str() {
"profile" => { "profile" => {
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let has_profile = { let local_sync_enabled = {
if let Ok(profiles) = profile_manager.list_profiles() { if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok(); let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid)) profile_uuid
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
.is_some_and(|p| p.is_sync_enabled())
} else { } else {
false false
} }
}; };
if has_profile { if local_sync_enabled {
log::info!( log::info!(
"Profile {} was deleted remotely, deleting locally", "Profile {} was deleted remotely, deleting locally",
entity_id entity_id
@@ -733,6 +735,11 @@ impl SyncScheduler {
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) { if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e); log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
} }
} else {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
entity_id
);
} }
} }
"proxy" => { "proxy" => {
+2
View File
@@ -166,6 +166,7 @@ pub enum SyncError {
SerializationError(String), SerializationError(String),
ConflictError(String), ConflictError(String),
InvalidData(String), InvalidData(String),
Cancelled,
} }
impl std::fmt::Display for SyncError { impl std::fmt::Display for SyncError {
@@ -178,6 +179,7 @@ impl std::fmt::Display for SyncError {
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"), SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"), SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"), SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
} }
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Donut", "productName": "Donut",
"version": "0.23.0", "version": "0.24.3",
"identifier": "com.donutbrowser", "identifier": "com.donutbrowser",
"build": { "build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev", "beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+486 -110
View File
@@ -5,8 +5,10 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog"; import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CommandPalette } from "@/components/command-palette";
import { CommercialTrialModal } from "@/components/commercial-trial-modal"; import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog"; import { CookieManagementDialog } from "@/components/cookie-management-dialog";
@@ -16,7 +18,6 @@ import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog"; import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog"; import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog"; import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
import { GroupBadges } from "@/components/group-badges";
import { GroupManagementDialog } from "@/components/group-management-dialog"; import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header"; import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { ImportProfileDialog } from "@/components/import-profile-dialog";
@@ -32,7 +33,9 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProfileSyncDialog } from "@/components/profile-sync-dialog"; import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog"; import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog"; import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { type AppPage, RailNav } from "@/components/rail-nav";
import { SettingsDialog } from "@/components/settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog";
import { ShortcutsPage } from "@/components/shortcuts-page";
import { SyncAllDialog } from "@/components/sync-all-dialog"; import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog"; import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog"; import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
@@ -52,6 +55,12 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors"; import { translateBackendError } from "@/lib/backend-errors";
import {
matchesGroupDigit,
matchesShortcut,
SHORTCUTS,
type ShortcutId,
} from "@/lib/shortcuts";
import { import {
dismissToast, dismissToast,
showErrorToast, showErrorToast,
@@ -141,6 +150,18 @@ export default function Home() {
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured; 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 [extensionManagementInitialTab, setExtensionManagementInitialTab] =
useState<"extensions" | "groups">("extensions");
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
"api" | "mcp"
>("api");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false); const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -175,7 +196,7 @@ export default function Home() {
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState< const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
string[] string[]
>([]); >([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>("default"); const [selectedGroupId, setSelectedGroupId] = useState<string>("__all__");
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState< const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
string[] string[]
>([]); >([]);
@@ -213,6 +234,11 @@ export default function Home() {
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false); const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] = const [currentProfileForSync, setCurrentProfileForSync] =
useState<BrowserProfile | null>(null); useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
useState<BrowserProfile | null>(null);
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions(); usePermissions();
@@ -221,6 +247,178 @@ export default function Home() {
setSelectedProfiles([]); 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;
case "shortcuts":
// Plain page render — nothing else to open.
break;
}
}, []);
const runShortcut = useCallback(
(id: ShortcutId) => {
switch (id) {
case "openPalette":
setCommandPaletteOpen(true);
break;
case "openShortcuts":
handleRailNavigate("shortcuts");
break;
case "importProfile":
handleRailNavigate("import");
break;
case "goProfiles":
handleRailNavigate("profiles");
break;
case "goProxies": {
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
// initialTab, so we just pick the right destination.
if (currentPage === "proxies") {
handleRailNavigate("vpns");
} else if (currentPage === "vpns") {
handleRailNavigate("proxies");
} else {
handleRailNavigate(
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
);
}
break;
}
case "goExtensions": {
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
if (currentPage === "extensions") {
setExtensionManagementInitialTab((cur) =>
cur === "extensions" ? "groups" : "extensions",
);
} else {
handleRailNavigate("extensions");
}
break;
}
case "goGroups":
handleRailNavigate("groups");
break;
case "goIntegrations": {
// Mod+I: flip api↔mcp tab when already on integrations.
if (currentPage === "integrations") {
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
} else {
handleRailNavigate("integrations");
}
break;
}
case "goAccount":
handleRailNavigate("account");
break;
case "goSettings":
handleRailNavigate("settings");
break;
}
},
[handleRailNavigate, currentPage, proxyManagementInitialTab],
);
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
const orderedGroupTargets = useMemo(
() => [
{ id: "__all__", name: t("rail.profiles") },
...groupsData.map((g) => ({ id: g.id, name: g.name })),
],
[groupsData, t],
);
const selectGroupByDigit = useCallback(
(digit: number) => {
const target = orderedGroupTargets[digit - 1];
if (!target) return;
handleRailNavigate("profiles");
handleSelectGroup(target.id);
},
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
);
useEffect(() => {
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const isTyping =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target?.isContentEditable === true;
const digit = matchesGroupDigit(e);
if (digit !== null) {
if (isTyping) return;
if (digit - 1 >= orderedGroupTargets.length) return;
e.preventDefault();
selectGroupByDigit(digit);
return;
}
for (const s of SHORTCUTS) {
if (!matchesShortcut(s, e)) continue;
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
return;
}
e.preventDefault();
runShortcut(s.id);
return;
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
// Check for missing binaries and offer to download them // Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => { const checkMissingBinaries = useCallback(async () => {
try { try {
@@ -558,7 +756,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig, wayfernConfig: profileData.wayfernConfig,
groupId: groupId:
profileData.groupId ?? profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined), (selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral, ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist, dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook, launchHook: profileData.launchHook,
@@ -974,7 +1174,7 @@ export default function Home() {
failed_count: payload.failed_count ?? 0, failed_count: payload.failed_count ?? 0,
phase: payload.phase, phase: payload.phase,
}, },
{ id: toastId }, { id: toastId, profileId: payload.profile_id },
); );
} }
}); });
@@ -1042,6 +1242,84 @@ export default function Home() {
profiles.length, 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) // Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
useEffect(() => { useEffect(() => {
if (profiles.length === 0) return; if (profiles.length === 0) return;
@@ -1109,9 +1387,11 @@ export default function Home() {
const filteredProfiles = useMemo(() => { const filteredProfiles = useMemo(() => {
let filtered = profiles; let filtered = profiles;
// Filter by group // Filter by group. "__all__" is a virtual filter that shows every
if (!selectedGroupId || selectedGroupId === "default") { // profile (including ungrouped ones). Any other value is a real
filtered = profiles.filter((profile) => !profile.group_id); // group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles;
} else { } else {
filtered = profiles.filter( filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId, (profile) => profile.group_id === selectedGroupId,
@@ -1142,67 +1422,171 @@ export default function Home() {
// Update loading states // Update loading states
const isLoading = profilesLoading || groupsLoading || proxiesLoading; const isLoading = profilesLoading || groupsLoading || proxiesLoading;
const subPageTitle =
currentPage === "profiles"
? undefined
: currentPage === "import"
? t("pageTitle.import")
: t(`pageTitle.${currentPage}`);
return ( return (
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background"> <div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<main className="flex flex-col items-center w-full max-w-4xl px-3"> <HomeHeader
<div className="w-full"> onCreateProfileDialogOpen={setCreateProfileDialogOpen}
<HomeHeader searchQuery={searchQuery}
onCreateProfileDialogOpen={setCreateProfileDialogOpen} onSearchQueryChange={setSearchQuery}
onGroupManagementDialogOpen={setGroupManagementDialogOpen} groups={groupsData}
onImportProfileDialogOpen={setImportProfileDialogOpen} totalProfiles={profiles.length}
onProxyManagementDialogOpen={setProxyManagementDialogOpen} selectedGroupId={selectedGroupId}
onSettingsDialogOpen={setSettingsDialogOpen} onGroupSelect={handleSelectGroup}
onSyncConfigDialogOpen={setSyncConfigDialogOpen} pageTitle={subPageTitle}
onIntegrationsDialogOpen={setIntegrationsDialogOpen} />
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen} <div className="flex flex-1 min-h-0">
searchQuery={searchQuery} <RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
onSearchQueryChange={setSearchQuery} <main className="flex-1 min-w-0 flex flex-col overflow-hidden">
/> {currentPage === "profiles" && (
</div> <div className="px-3 pt-2.5 flex flex-col flex-1 min-h-0">
<div className="w-full mt-2.5"> {isLoading && groupsData.length === 0 ? null : null}
<GroupBadges <ProfilesDataTable
selectedGroupId={selectedGroupId} profiles={filteredProfiles}
onGroupSelect={handleSelectGroup} infoDialogProfile={profileInfoDialog}
groups={groupsData} onInfoDialogProfileChange={setProfileInfoDialog}
isLoading={isLoading} onLaunchProfile={launchProfile}
/> onKillProfile={handleKillProfile}
<ProfilesDataTable onCloneProfile={handleCloneProfile}
profiles={filteredProfiles} onSetPassword={handleSetPassword}
onLaunchProfile={launchProfile} onChangePassword={handleChangePassword}
onKillProfile={handleKillProfile} onRemovePassword={handleRemovePassword}
onCloneProfile={handleCloneProfile} onDeleteProfile={handleDeleteProfile}
onSetPassword={handleSetPassword} onRenameProfile={handleRenameProfile}
onChangePassword={handleChangePassword} onConfigureCamoufox={handleConfigureCamoufox}
onRemovePassword={handleRemovePassword} onCopyCookiesToProfile={handleCopyCookiesToProfile}
onDeleteProfile={handleDeleteProfile} onOpenCookieManagement={handleOpenCookieManagement}
onRenameProfile={handleRenameProfile} runningProfiles={runningProfiles}
onConfigureCamoufox={handleConfigureCamoufox} isUpdating={isUpdating}
onCopyCookiesToProfile={handleCopyCookiesToProfile} onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onOpenCookieManagement={handleOpenCookieManagement} onAssignProfilesToGroup={handleAssignProfilesToGroup}
runningProfiles={runningProfiles} selectedGroupId={selectedGroupId}
isUpdating={isUpdating} selectedProfiles={selectedProfiles}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles} onSelectedProfilesChange={setSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup} onBulkDelete={handleBulkDelete}
selectedGroupId={selectedGroupId} onBulkGroupAssignment={handleBulkGroupAssignment}
selectedProfiles={selectedProfiles} onBulkProxyAssignment={handleBulkProxyAssignment}
onSelectedProfilesChange={setSelectedProfiles} onBulkCopyCookies={handleBulkCopyCookies}
onBulkDelete={handleBulkDelete} onBulkExtensionGroupAssignment={
onBulkGroupAssignment={handleBulkGroupAssignment} handleBulkExtensionGroupAssignment
onBulkProxyAssignment={handleBulkProxyAssignment} }
onBulkCopyCookies={handleBulkCopyCookies} onAssignExtensionGroup={handleAssignExtensionGroup}
onBulkExtensionGroupAssignment={handleBulkExtensionGroupAssignment} onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onAssignExtensionGroup={handleAssignExtensionGroup} onToggleProfileSync={handleToggleProfileSync}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog} crossOsUnlocked={crossOsUnlocked}
onToggleProfileSync={handleToggleProfileSync} syncUnlocked={syncUnlocked}
crossOsUnlocked={crossOsUnlocked} getProfileSyncInfo={getProfileSyncInfo}
syncUnlocked={syncUnlocked} onLaunchWithSync={(profile) => {
getProfileSyncInfo={getProfileSyncInfo} setSyncLeaderProfile(profile);
onLaunchWithSync={(profile) => { }}
setSyncLeaderProfile(profile); />
}} </div>
/> )}
</div>
</main> {currentPage === "shortcuts" && (
<ShortcutsPage groupTargets={orderedGroupTargets} />
)}
{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"}
initialTab={integrationsInitialTab}
/>
)}
{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"}
initialTab={extensionManagementInitialTab}
/>
)}
{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 <CreateProfileDialog
isOpen={createProfileDialogOpen} isOpen={createProfileDialogOpen}
@@ -1214,36 +1598,26 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked} crossOsUnlocked={crossOsUnlocked}
/> />
<SettingsDialog <CommandPalette
isOpen={settingsDialogOpen} open={commandPaletteOpen}
onClose={() => { onOpenChange={setCommandPaletteOpen}
setSettingsDialogOpen(false); onAction={runShortcut}
groupTargets={orderedGroupTargets}
onSelectGroup={(id) => {
handleRailNavigate("profiles");
handleSelectGroup(id);
}} }}
onIntegrationsOpen={() => { profiles={profiles}
setSettingsDialogOpen(false); runningProfileIds={runningProfiles}
setIntegrationsDialogOpen(true); onLaunchProfile={(profile) => {
void launchProfile(profile);
}} }}
/> onKillProfile={(profile) => {
void handleKillProfile(profile);
<IntegrationsDialog
isOpen={integrationsDialogOpen}
onClose={() => {
setIntegrationsDialogOpen(false);
}} }}
/> onShowProfileInfo={(profile) => {
handleRailNavigate("profiles");
<ImportProfileDialog setProfileInfoDialog(profile);
isOpen={importProfileDialogOpen}
onClose={() => {
setImportProfileDialogOpen(false);
}}
crossOsUnlocked={crossOsUnlocked}
/>
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
}} }}
/> />
@@ -1288,6 +1662,7 @@ export default function Home() {
profile={passwordDialogProfile} profile={passwordDialogProfile}
mode={passwordDialogMode} mode={passwordDialogMode}
onSuccess={(p) => { onSuccess={(p) => {
// Resume pending launch after unlock.
if ( if (
passwordDialogMode === "unlock" && passwordDialogMode === "unlock" &&
pendingLaunchAfterUnlockRef.current?.id === p.id pendingLaunchAfterUnlockRef.current?.id === p.id
@@ -1296,6 +1671,23 @@ export default function Home() {
pendingLaunchAfterUnlockRef.current = null; pendingLaunchAfterUnlockRef.current = null;
void launchProfile(target); 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);
},
);
}
}} }}
/> />
@@ -1315,22 +1707,6 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked} crossOsUnlocked={crossOsUnlocked}
/> />
<GroupManagementDialog
isOpen={groupManagementDialogOpen}
onClose={() => {
setGroupManagementDialogOpen(false);
}}
onGroupManagementComplete={handleGroupManagementComplete}
/>
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => {
setExtensionManagementDialogOpen(false);
}}
limitedMode={!crossOsUnlocked}
/>
<GroupAssignmentDialog <GroupAssignmentDialog
isOpen={groupAssignmentDialogOpen} isOpen={groupAssignmentDialogOpen}
onClose={() => { 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 ( return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground"> <div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5"> <div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 w-5 h-5" /> <LuCheckCheck className="flex-shrink-0 size-5" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -59,9 +59,9 @@ export function AppUpdateToast({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onDismiss} onClick={onDismiss}
className="p-0 w-6 h-6 shrink-0" className="p-0 size-6 shrink-0"
> >
<FaTimes className="w-3 h-3" /> <FaTimes className="size-3" />
</Button> </Button>
</div> </div>
@@ -72,7 +72,7 @@ export function AppUpdateToast({
size="sm" size="sm"
className="flex gap-2 items-center text-xs" className="flex gap-2 items-center text-xs"
> >
<LuCheckCheck className="w-3 h-3" /> <LuCheckCheck className="size-3" />
{t("appUpdate.toast.restartNow")} {t("appUpdate.toast.restartNow")}
</RippleButton> </RippleButton>
) : ( ) : (
@@ -83,7 +83,7 @@ export function AppUpdateToast({
size="sm" size="sm"
className="flex gap-2 items-center text-xs" className="flex gap-2 items-center text-xs"
> >
<FaExternalLinkAlt className="w-3 h-3" /> <FaExternalLinkAlt className="size-3" />
{t("appUpdate.toast.viewRelease")} {t("appUpdate.toast.viewRelease")}
</RippleButton> </RippleButton>
) )
+10 -8
View File
@@ -36,16 +36,18 @@ export function CloneProfileDialog({
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (isOpen && profile) { if (!(isOpen && profile)) {
const defaultName = `${profile.name} (Copy)`;
setName(defaultName);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
} else {
setIsLoading(false); setIsLoading(false);
return;
} }
setName(`${profile.name} (Copy)`);
const handle = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen, profile]); }, [isOpen, profile]);
if (!profile) return null; if (!profile) return null;
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear } from "react-icons/go";
import {
LuCircleStop,
LuCloud,
LuInfo,
LuKeyboard,
LuPlay,
LuPlug,
LuPuzzle,
LuUser,
LuUsers,
} from "react-icons/lu";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
formatGroupShortcut,
formatShortcut,
SHORTCUTS,
type ShortcutDef,
type ShortcutId,
} from "@/lib/shortcuts";
import type { BrowserProfile } from "@/types";
interface GroupTarget {
id: string;
name: string;
}
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAction: (id: ShortcutId) => void;
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
groupTargets: GroupTarget[];
onSelectGroup: (id: string) => void;
/** All profiles for launch/stop/info entries. */
profiles: BrowserProfile[];
runningProfileIds: Set<string>;
onLaunchProfile: (profile: BrowserProfile) => void;
onKillProfile: (profile: BrowserProfile) => void;
onShowProfileInfo: (profile: BrowserProfile) => void;
}
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
openPalette: LuKeyboard,
openShortcuts: LuKeyboard,
importProfile: FaDownload,
goProfiles: LuUser,
goProxies: FiWifi,
goExtensions: LuPuzzle,
goGroups: LuUsers,
goIntegrations: LuPlug,
goAccount: LuCloud,
goSettings: GoGear,
};
function Tokens({ tokens }: { tokens: string[] }) {
return (
<CommandShortcut className="flex items-center gap-0.5">
{tokens.map((tok, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
>
{tok}
</kbd>
))}
</CommandShortcut>
);
}
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
return <Tokens tokens={formatShortcut(shortcut)} />;
}
/**
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
* to appear as a substring somewhere in the item's value or its keywords; the
* score is reduced when tokens appear later in the haystack so a closer match
* sorts higher. "ctest info" matches "Info — ctest" the default cmdk filter
* requires tokens in document order so it would otherwise return zero.
*/
function fuzzyFilter(
value: string,
search: string,
keywords?: string[],
): number {
if (!search.trim()) return 1;
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
let score = 0;
for (const tok of tokens) {
const idx = haystack.indexOf(tok);
if (idx === -1) return 0;
score += 1 / (1 + idx);
}
return score / tokens.length;
}
export function CommandPalette({
open,
onOpenChange,
onAction,
groupTargets,
onSelectGroup,
profiles,
runningProfileIds,
onLaunchProfile,
onKillProfile,
onShowProfileInfo,
}: CommandPaletteProps) {
const { t } = useTranslation();
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
// on the next tick so an action that opens another dialog doesn't race
// this one's close animation.
const dispatch = (fn: () => void) => {
onOpenChange(false);
setTimeout(fn, 0);
};
const byGroup = (group: ShortcutDef["group"]) =>
SHORTCUTS.filter((s) => s.group === group);
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
// We still display more in the palette (without a shortcut hint) so the
// user can search/jump to any of them.
const renderGroup = (target: GroupTarget, index: number) => (
<CommandItem
key={target.id}
onSelect={() => {
dispatch(() => {
onSelectGroup(target.id);
});
}}
>
<LuUsers />
<span>{target.name}</span>
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
</CommandItem>
);
return (
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
<CommandInput placeholder={t("commandPalette.placeholder")} />
<CommandList>
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
<CommandGroup heading={t("commandPalette.groups.navigation")}>
{byGroup("navigation").map((s) => {
const Icon = ICONS[s.id];
return (
<CommandItem
key={s.id}
onSelect={() => {
dispatch(() => {
onAction(s.id);
});
}}
>
<Icon />
<span>{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</CommandItem>
);
})}
</CommandGroup>
{groupTargets.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
{groupTargets.map((target, i) => renderGroup(target, i))}
</CommandGroup>
</>
) : null}
{profiles.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.profiles")}>
{profiles.map((p) => {
const running = runningProfileIds.has(p.id);
return running ? (
<CommandItem
key={`run-${p.id}`}
onSelect={() => {
dispatch(() => {
onKillProfile(p);
});
}}
>
<LuCircleStop />
<span>
{t("commandPalette.actions.stopProfile", {
name: p.name,
})}
</span>
</CommandItem>
) : (
<CommandItem
key={`run-${p.id}`}
onSelect={() => {
dispatch(() => {
onLaunchProfile(p);
});
}}
>
<LuPlay />
<span>
{t("commandPalette.actions.launchProfile", {
name: p.name,
})}
</span>
</CommandItem>
);
})}
{profiles.map((p) => (
<CommandItem
key={`info-${p.id}`}
onSelect={() => {
dispatch(() => {
onShowProfileInfo(p);
});
}}
>
<LuInfo />
<span>
{t("commandPalette.actions.profileInfo", { name: p.name })}
</span>
</CommandItem>
))}
</CommandGroup>
</>
) : null}
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.actions")}>
{byGroup("actions").map((s) => {
const Icon = ICONS[s.id];
return (
<CommandItem
key={s.id}
onSelect={() => {
dispatch(() => {
onAction(s.id);
});
}}
>
<Icon />
<span>{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
+6 -6
View File
@@ -335,7 +335,7 @@ export function CookieCopyDialog({
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col"> <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" /> <LuCookie className="size-5" />
{t("cookies.copy.title")} {t("cookies.copy.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
@@ -372,7 +372,7 @@ export function CookieCopyDialog({
disabled={isRunning} disabled={isRunning}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{IconComponent && <IconComponent className="w-4 h-4" />} {IconComponent && <IconComponent className="size-4" />}
<span>{profile.name}</span> <span>{profile.name}</span>
{isRunning && ( {isRunning && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
</div> </div>
<div className="relative"> <div className="relative">
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
placeholder={t("cookies.copy.searchPlaceholder")} placeholder={t("cookies.copy.searchPlaceholder")}
value={searchQuery} value={searchQuery}
@@ -450,7 +450,7 @@ export function CookieCopyDialog({
{isLoadingCookies ? ( {isLoadingCookies ? (
<div className="flex items-center justify-center h-40"> <div className="flex items-center justify-center h-40">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" /> <div className="animate-spin size-6 border-2 border-primary border-t-transparent rounded-full" />
</div> </div>
) : error ? ( ) : error ? (
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md"> <div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
@@ -565,9 +565,9 @@ function DomainRow({
}} }}
> >
{isExpanded ? ( {isExpanded ? (
<LuChevronDown className="w-4 h-4" /> <LuChevronDown className="size-4" />
) : ( ) : (
<LuChevronRight className="w-4 h-4" /> <LuChevronRight className="size-4" />
)} )}
<span className="font-medium">{domain.domain}</span> <span className="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
+7 -7
View File
@@ -15,9 +15,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple"; import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -429,7 +429,7 @@ export function CookieManagementDialog({
} }
}} }}
> >
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" /> <LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
{t("cookies.management.dropPrompt")} {t("cookies.management.dropPrompt")}
<br /> <br />
@@ -556,14 +556,14 @@ export function CookieManagementDialog({
{isLoadingExportCookies ? ( {isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24"> <div className="flex items-center justify-center h-24">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" /> <div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
</div> </div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? ( ) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md"> <div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
{t("cookies.management.noCookies")} {t("cookies.management.noCookies")}
</div> </div>
) : ( ) : (
<ScrollArea className="h-[200px] border rounded-md"> <FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => ( {exportCookieData.domains.map((domain) => (
<ExportDomainRow <ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/> />
))} ))}
</div> </div>
</ScrollArea> </FadingScrollArea>
)} )}
</div> </div>
@@ -645,9 +645,9 @@ function ExportDomainRow({
}} }}
> >
{isExpanded ? ( {isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" /> <LuChevronDown className="size-3.5" />
) : ( ) : (
<LuChevronRight className="w-3.5 h-3.5" /> <LuChevronRight className="size-3.5" />
)} )}
<span className="font-medium truncate">{domain.domain}</span> <span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0"> <span className="text-xs text-muted-foreground shrink-0">
+47 -27
View File
@@ -1,7 +1,14 @@
"use client"; "use client";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go"; import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu"; import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
@@ -116,6 +123,8 @@ export function CreateProfileDialog({
crossOsUnlocked = false, crossOsUnlocked = false,
}: CreateProfileDialogProps) { }: CreateProfileDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState(""); const [profileName, setProfileName] = useState("");
const [currentStep, setCurrentStep] = useState< const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config" "browser-selection" | "browser-config"
@@ -422,7 +431,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId, vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig, wayfernConfig: finalWayfernConfig,
groupId: groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined, selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId, extensionGroupId: selectedExtensionGroupId,
ephemeral, ephemeral,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
@@ -450,7 +461,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId, vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig, camoufoxConfig: finalCamoufoxConfig,
groupId: groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined, selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId, extensionGroupId: selectedExtensionGroupId,
ephemeral, ephemeral,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
@@ -478,7 +491,10 @@ export function CreateProfileDialog({
version: bestVersion.version, version: bestVersion.version,
releaseType: bestVersion.releaseType, releaseType: bestVersion.releaseType,
proxyId: selectedProxyId, proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined, launchHook: launchHook.trim() || undefined,
password: passwordToSet, password: passwordToSet,
@@ -568,7 +584,7 @@ export function CreateProfileDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col"> <DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle> <DialogTitle>
{currentStep === "browser-selection" {currentStep === "browser-selection"
@@ -605,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" className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline" variant="outline"
> >
<div className="flex justify-center items-center w-8 h-8"> <div className="flex justify-center items-center size-8">
{(() => { {(() => {
const IconComponent = getBrowserIcon("wayfern"); const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? ( return IconComponent ? (
<IconComponent className="w-6 h-6" /> <IconComponent className="size-6" />
) : null; ) : null;
})()} })()}
</div> </div>
@@ -631,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" className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline" variant="outline"
> >
<div className="flex justify-center items-center w-8 h-8"> <div className="flex justify-center items-center size-8">
{(() => { {(() => {
const IconComponent = getBrowserIcon("camoufox"); const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? ( return IconComponent ? (
<IconComponent className="w-6 h-6" /> <IconComponent className="size-6" />
) : null; ) : null;
})()} })()}
</div> </div>
@@ -676,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" className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline" variant="outline"
> >
<div className="flex justify-center items-center w-8 h-8"> <div className="flex justify-center items-center size-8">
{IconComponent && ( {IconComponent && (
<IconComponent className="w-6 h-6" /> <IconComponent className="size-6" />
)} )}
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -729,7 +745,7 @@ export function CreateProfileDialog({
{/* Ephemeral Option */} {/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30"> <div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2"> <div className="flex items-center gap-x-2">
<Checkbox <Checkbox
id="ephemeral" id="ephemeral"
checked={ephemeral} checked={ephemeral}
@@ -749,7 +765,7 @@ export function CreateProfileDialog({
{/* Password Option */} {/* Password Option */}
{!ephemeral && ( {!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30"> <div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2"> <div className="flex items-center gap-x-2">
<Checkbox <Checkbox
id="enable-password" id="enable-password"
checked={enablePassword} checked={enablePassword}
@@ -814,7 +830,7 @@ export function CreateProfileDialog({
{/* Wayfern Download Status */} {/* Wayfern Download Status */}
{isLoadingReleaseTypes && ( {isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border"> <div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" /> <div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")} {t("createProfile.version.fetching")}
</p> </p>
@@ -922,7 +938,7 @@ export function CreateProfileDialog({
{/* Camoufox Download Status */} {/* Camoufox Download Status */}
{isLoadingReleaseTypes && ( {isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border"> <div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" /> <div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")} {t("createProfile.version.fetching")}
</p> </p>
@@ -1041,7 +1057,7 @@ export function CreateProfileDialog({
<div className="space-y-3"> <div className="space-y-3">
{isLoadingReleaseTypes && ( {isLoadingReleaseTypes && (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" /> <div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")} {t("createProfile.version.fetching")}
</p> </p>
@@ -1154,7 +1170,7 @@ export function CreateProfileDialog({
}} }}
className="px-2 h-7 text-xs" className="px-2 h-7 text-xs"
> >
<GoPlus className="mr-1 w-3 h-3" />{" "} <GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")} {t("createProfile.proxy.addProxy")}
</RippleButton> </RippleButton>
</div> </div>
@@ -1168,6 +1184,7 @@ export function CreateProfileDialog({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={proxyPopoverOpen} aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdAntiDetect}
className="w-full justify-between font-normal" className="w-full justify-between font-normal"
> >
{(() => { {(() => {
@@ -1190,10 +1207,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy") t("createProfile.proxy.noProxy")
); );
})()} })()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
id={proxyListboxIdAntiDetect}
className="w-[240px] p-0" className="w-[240px] p-0"
sideOffset={8} sideOffset={8}
> >
@@ -1217,7 +1235,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
!selectedProxyId !selectedProxyId
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1236,7 +1254,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === proxy.id selectedProxyId === proxy.id
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1261,7 +1279,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === selectedProxyId ===
`vpn-${vpn.id}` `vpn-${vpn.id}`
? "opacity-100" ? "opacity-100"
@@ -1412,7 +1430,7 @@ export function CreateProfileDialog({
<div className="space-y-3"> <div className="space-y-3">
{isLoadingReleaseTypes && ( {isLoadingReleaseTypes && (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" /> <div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Fetching available versions... Fetching available versions...
</p> </p>
@@ -1520,7 +1538,7 @@ export function CreateProfileDialog({
}} }}
className="px-2 h-7 text-xs" className="px-2 h-7 text-xs"
> >
<GoPlus className="mr-1 w-3 h-3" />{" "} <GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")} {t("createProfile.proxy.addProxy")}
</RippleButton> </RippleButton>
</div> </div>
@@ -1534,6 +1552,7 @@ export function CreateProfileDialog({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={proxyPopoverOpen} aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdRegular}
className="w-full justify-between font-normal" className="w-full justify-between font-normal"
> >
{(() => { {(() => {
@@ -1556,10 +1575,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy") t("createProfile.proxy.noProxy")
); );
})()} })()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
id={proxyListboxIdRegular}
className="w-[240px] p-0" className="w-[240px] p-0"
sideOffset={8} sideOffset={8}
> >
@@ -1583,7 +1603,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
!selectedProxyId !selectedProxyId
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1602,7 +1622,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === proxy.id selectedProxyId === proxy.id
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -1627,7 +1647,7 @@ export function CreateProfileDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectedProxyId === selectedProxyId ===
`vpn-${vpn.id}` `vpn-${vpn.id}`
? "opacity-100" ? "opacity-100"
+14 -14
View File
@@ -174,42 +174,42 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) { function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) { switch (type) {
case "success": case "success":
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />; return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
case "error": case "error":
return ( return (
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" /> <LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
); );
case "download": case "download":
if (stage === "completed") { if (stage === "completed") {
return ( 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": case "version-update":
return ( 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": case "fetching":
return ( 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": case "twilight-update":
return ( 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": case "sync-progress":
return ( 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": case "loading":
return ( return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" /> <div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
); );
default: default:
return ( return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" /> <div className="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" 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")} aria-label={t("common.buttons.cancel")}
> >
<LuX className="w-3 h-3" /> <LuX className="size-3" />
</button> </button>
)} )}
</div> </div>
@@ -255,7 +255,7 @@ export function UnifiedToast(props: ToastProps) {
</div> </div>
<div className="w-full bg-muted rounded-full h-1.5"> <div className="w-full bg-muted rounded-full h-1.5">
<div <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}%` }} style={{ width: `${progress.percentage}%` }}
/> />
</div> </div>
@@ -272,10 +272,10 @@ export function UnifiedToast(props: ToastProps) {
<>Looking for updates for {progress.current_browser}</> <>Looking for updates for {progress.current_browser}</>
)} )}
</p> </p>
<div className="flex items-center space-x-2"> <div className="flex items-center gap-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0"> <div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div <div
className="bg-foreground h-1.5 rounded-full transition-all duration-300" className="bg-foreground h-1.5 rounded-full transition-all duration-150"
style={{ style={{
width: `${(progress.current / progress.total) * 100}%`, width: `${(progress.current / progress.total) * 100}%`,
}} }}
+1 -1
View File
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
{...props} {...props}
> >
{isPending ? ( {isPending ? (
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" /> <div className="size-3.5 rounded-full border border-current animate-spin border-t-transparent" />
) : ( ) : (
children children
)} )}
@@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
+2 -2
View File
@@ -155,13 +155,13 @@ export function DeleteGroupDialog({
setDeleteAction(value as "move" | "delete"); 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" /> <RadioGroupItem value="move" id="move" />
<Label htmlFor="move" className="text-sm"> <Label htmlFor="move" className="text-sm">
{t("groups.moveToDefault")} {t("groups.moveToDefault")}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center gap-x-2">
<RadioGroupItem value="delete" id="delete" /> <RadioGroupItem value="delete" id="delete" />
<Label <Label
htmlFor="delete" htmlFor="delete"
+28 -1
View File
@@ -3,7 +3,9 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -16,6 +18,8 @@ import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
interface DeviceCodeVerifyDialogProps { interface DeviceCodeVerifyDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: (loginOccurred?: boolean) => void; onClose: (loginOccurred?: boolean) => void;
@@ -36,6 +40,19 @@ export function DeviceCodeVerifyDialog({
const { exchangeDeviceCode } = useCloudAuth(); const { exchangeDeviceCode } = useCloudAuth();
const [linkCode, setLinkCode] = useState(""); const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false); 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 // Reset the field when the dialog reopens so a stale code from a
// previous attempt doesn't auto-populate. // previous attempt doesn't auto-populate.
@@ -75,12 +92,22 @@ export function DeviceCodeVerifyDialog({
> >
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("sync.cloud.verifyAndLogin")}</DialogTitle> <DialogTitle>{t("sync.cloud.signInTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("sync.cloud.deviceLinkInstructions")} {t("sync.cloud.deviceLinkInstructions")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <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"> <div className="space-y-2">
<Label htmlFor="device-link-code"> <Label htmlFor="device-link-code">
{t("sync.cloud.linkCodeLabel")} {t("sync.cloud.linkCodeLabel")}
+1 -1
View File
@@ -137,7 +137,7 @@ export function DnsBlocklistDialog({
className="w-full" className="w-full"
> >
<LuRefreshCw <LuRefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
/> />
{t("dnsBlocklist.refreshAll")} {t("dnsBlocklist.refreshAll")}
</Button> </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 const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name || ? groups.find((g) => g.id === selectedGroupId)?.name ||
t("groups.unknownGroup") t("groups.unknownGroup")
: t("groups.defaultGroup"); : t("groups.noGroup");
toast.success( toast.success(
t("groups.assignSuccess", { t("groups.assignSuccess", {
@@ -165,7 +165,7 @@ export function GroupAssignmentDialog({
setCreateDialogOpen(true); setCreateDialogOpen(true);
}} }}
> >
<GoPlus className="mr-1 w-3 h-3" />{" "} <GoPlus className="mr-1 size-3" />{" "}
{t("groupManagement.createGroup")} {t("groupManagement.createGroup")}
</RippleButton> </RippleButton>
</div> </div>
@@ -175,17 +175,17 @@ export function GroupAssignmentDialog({
</div> </div>
) : ( ) : (
<Select <Select
value={selectedGroupId ?? "default"} value={selectedGroupId ?? "__none__"}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedGroupId(value === "default" ? null : value); setSelectedGroupId(value === "__none__" ? null : value);
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t("groupAssignment.placeholder")} /> <SelectValue placeholder={t("groupAssignment.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="default"> <SelectItem value="__none__">
{t("groups.defaultGroupNoGroup")} {t("groups.noGroup")}
</SelectItem> </SelectItem>
{groups.map((group) => ( {groups.map((group) => (
<SelectItem key={group.id} value={group.id}> <SelectItem key={group.id} value={group.id}>
+1 -3
View File
@@ -183,9 +183,7 @@ export function GroupBadges({
} }
}} }}
> >
<span> <span>{group.name}</span>
{group.id === "default" ? t("groups.defaultGroup") : group.name}
</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm"> <span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count} {group.count}
</span> </span>
+432 -148
View File
@@ -1,14 +1,37 @@
"use client"; "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 { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; 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 { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go"; 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 { 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 { DeleteGroupDialog } from "@/components/delete-group-dialog";
import { EditGroupDialog } from "@/components/edit-group-dialog"; import { EditGroupDialog } from "@/components/edit-group-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -20,8 +43,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Table, Table,
TableBody, TableBody,
@@ -35,6 +57,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { GroupWithCount, ProfileGroup } from "@/types"; import type { GroupWithCount, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple"; import { RippleButton } from "./ui/ripple";
@@ -93,12 +116,14 @@ interface GroupManagementDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onGroupManagementComplete: () => void; onGroupManagementComplete: () => void;
subPage?: boolean;
} }
export function GroupManagementDialog({ export function GroupManagementDialog({
isOpen, isOpen,
onClose, onClose,
onGroupManagementComplete, onGroupManagementComplete,
subPage,
}: GroupManagementDialogProps) { }: GroupManagementDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [groups, setGroups] = useState<GroupWithCount[]>([]); const [groups, setGroups] = useState<GroupWithCount[]>([]);
@@ -109,6 +134,8 @@ export function GroupManagementDialog({
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>( const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>(
null, null,
); );
@@ -123,6 +150,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 // Listen for group sync status events
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
@@ -230,8 +263,8 @@ export function GroupManagementDialog({
} catch (error) { } catch (error) {
console.error("Failed to toggle sync:", error); console.error("Failed to toggle sync:", error);
showErrorToast( showErrorToast(
error instanceof Error parseBackendError(error)
? error.message ? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"), : t("proxies.management.updateSyncFailed"),
); );
} finally { } finally {
@@ -244,32 +277,309 @@ export function GroupManagementDialog({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
void loadGroups(); 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]); }, [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 firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
? t("proxies.management.syncEnabled")
: t("proxies.management.syncDisabled"),
);
}
await loadGroups();
}, [selectedGroupsForBulk, groupInUse, loadGroups, t]);
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col"> <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader> {!subPage && (
<DialogTitle>{t("groups.management")}</DialogTitle> <DialogHeader>
<DialogDescription> <DialogTitle>{t("groups.management")}</DialogTitle>
{t("groups.noGroupDescription")} <DialogDescription>
</DialogDescription> {t("groups.noGroupDescription")}
</DialogHeader> </DialogDescription>
</DialogHeader>
)}
<div className="space-y-4"> <div className="flex flex-col gap-4 flex-1 min-h-0">
{/* Create new group button */} <div className="flex items-start justify-between gap-3">
<div className="flex justify-between items-center"> <div className="flex flex-col gap-1">
<Label>{t("groupManagement.groupsLabel")}</Label> <h2 className="text-base font-semibold">
{t("groups.pageTitle")}
</h2>
<p className="text-xs text-muted-foreground">
{t("groups.pageDescription")}
</p>
</div>
<RippleButton <RippleButton
size="sm" size="sm"
onClick={() => { onClick={() => {
setCreateDialogOpen(true); 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")} {t("proxies.management.create")}
</RippleButton> </RippleButton>
</div> </div>
@@ -290,142 +600,116 @@ export function GroupManagementDialog({
{t("groups.noGroupsDescription")} {t("groups.noGroupsDescription")}
</div> </div>
) : ( ) : (
<div className="border rounded-md"> <FadingScrollArea
<ScrollArea className="h-[240px]"> className="flex-1 min-h-0"
<Table> style={
<TableHeader> {
<TableRow> "--scroll-fade-top-offset": "32px",
<TableHead>{t("common.labels.name")}</TableHead> } as React.CSSProperties
<TableHead className="w-20"> }
{t("groupManagement.profilesCol")} >
</TableHead> <Table>
<TableHead className="w-24"> <TableHeader className="sticky top-0 z-10 bg-background">
{t("proxies.management.syncCol")} {table.getHeaderGroups().map((headerGroup) => (
</TableHead> <TableRow key={headerGroup.id}>
<TableHead className="w-24"> {headerGroup.headers.map((header) => (
{t("common.labels.actions")} <TableHead
</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> </TableRow>
</TableHeader> ))}
<TableBody> </TableHeader>
{groups.map((group) => { <TableBody>
const syncDot = getSyncStatusDot( {table.getRowModel().rows.map((row) => (
group, <TableRow
groupSyncStatus[group.id], key={row.id}
t, data-state={row.getIsSelected() && "selected"}
groupSyncErrors[group.id], >
); {row.getVisibleCells().map((cell) => (
return ( <TableCell
<TableRow key={group.id}> key={cell.id}
<TableCell className="font-medium"> style={{
<div className="flex items-center gap-2"> width: cell.column.columnDef.size
<Tooltip> ? `${cell.column.getSize()}px`
<TooltipTrigger asChild> : undefined,
<div }}
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${ >
syncDot.animate ? "animate-pulse" : "" {flexRender(
}`} cell.column.columnDef.cell,
/> cell.getContext(),
</TooltipTrigger> )}
<TooltipContent> </TableCell>
<p>{syncDot.tooltip}</p> ))}
</TooltipContent> </TableRow>
</Tooltip> ))}
{group.name} </TableBody>
</div> </Table>
</TableCell> </FadingScrollArea>
<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>
)} )}
</div> </div>
<DialogFooter> {!subPage && (
<RippleButton variant="outline" onClick={onClose}> <DialogFooter>
{t("common.buttons.close")} <RippleButton variant="outline" onClick={onClose}>
</RippleButton> {t("common.buttons.close")}
</DialogFooter> </RippleButton>
</DialogFooter>
)}
</DialogContent> </DialogContent>
</Dialog> </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 <CreateGroupDialog
isOpen={createDialogOpen} isOpen={createDialogOpen}
onClose={() => { 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 { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa"; import { GoPlus } from "react-icons/go";
import { FiWifi } from "react-icons/fi"; import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go"; import { getCurrentOS } from "@/lib/browser-utils";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuSearch,
LuUsers,
LuX,
} from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo"; import type { GroupWithCount } from "@/types";
import { Button } from "./ui/button"; 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 { Input } from "./ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const CLICK_THRESHOLD = 5; const HOLD_MS = 150;
const CLICK_WINDOW_MS = 2000; const DRAG_THRESHOLD_PX = 3;
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() { const isTextInputTarget = (target: EventTarget | null): boolean => {
const clickTimestamps = useRef<number[]>([]); if (!(target instanceof Element)) return false;
const [isPressed, setIsPressed] = useState(false); const el = target.closest(
const [wobbleKey, setWobbleKey] = useState(0); "input, select, textarea, [contenteditable=''], [contenteditable='true']",
const [isFalling, setIsFalling] = useState(false); );
const [isHidden, setIsHidden] = useState(() => { return el !== null;
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 ALL_FILTER_ID = "__all__";
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,
};
}
interface Props { interface Props {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
onGroupManagementDialogOpen: (open: boolean) => void;
onImportProfileDialogOpen: (open: boolean) => void;
onCreateProfileDialogOpen: (open: boolean) => void; onCreateProfileDialogOpen: (open: boolean) => void;
onSyncConfigDialogOpen: (open: boolean) => void;
onIntegrationsDialogOpen: (open: boolean) => void;
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string; searchQuery: string;
onSearchQueryChange: (query: string) => void; onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
totalProfiles: number;
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
pageTitle?: string;
} }
const HomeHeader = ({ const HomeHeader = ({
onSettingsDialogOpen,
onProxyManagementDialogOpen,
onGroupManagementDialogOpen,
onImportProfileDialogOpen,
onCreateProfileDialogOpen, onCreateProfileDialogOpen,
onSyncConfigDialogOpen,
onIntegrationsDialogOpen,
onExtensionManagementDialogOpen,
searchQuery, searchQuery,
onSearchQueryChange, onSearchQueryChange,
groups,
totalProfiles,
selectedGroupId,
onGroupSelect,
pageTitle,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const [platform, setPlatform] = useState<string>("macos");
logoRef,
isPressed, useEffect(() => {
setIsPressed, setPlatform(getCurrentOS());
wobbleKey, }, []);
isFalling,
isHidden, const isMacOS = platform === "macos";
handleClick, const showProfileToolbar = !pageTitle;
} = useLogoEasterEgg();
// 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 ( return (
<div className="flex justify-between items-center mt-6"> <div
<div className="flex gap-3 items-center"> ref={dragRootRef}
{!isHidden ? ( onPointerDown={handlePointerDown}
<button onPointerMove={handlePointerMove}
ref={logoRef} onPointerUp={handlePointerEnd}
type="button" onPointerCancel={handlePointerEnd}
className="p-1 cursor-pointer select-none" className={cn(
onClick={handleClick} "flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
onPointerDown={() => { // Windows: WindowDragArea renders two 44px native-style controls
setIsPressed(true); // (minimize + close) fixed at top-right with z-50, total 88px wide.
}} // Reserve 100px on the right edge so the "+ New" button and search
onPointerUp={() => { // input clear them with a few pixels of breathing room — issues
setIsPressed(false); // #358, #361, #362 all reported the same overlap before this fix.
}} isWindows ? "pr-[100px]" : "pr-3",
onPointerLeave={() => { )}
setIsPressed(false); >
{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 {/* "All" filter — shows every profile regardless of group. */}
key={wobbleKey} {(() => {
className={cn( const active = selectedGroupId === ALL_FILTER_ID;
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110", return (
isPressed && "scale-90", <button
!isFalling && key="__all__"
!isPressed && type="button"
wobbleKey > 0 && onClick={() => {
"animate-[wiggle_0.3s_ease-in-out]", onGroupSelect(ALL_FILTER_ID);
)} }}
/> className={cn(
</button> "flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
) : ( active
<div className="p-1 w-10 h-10" /> ? "text-foreground font-medium"
)} : "text-muted-foreground hover:text-foreground",
<CardTitle>Donut</CardTitle> )}
</div> >
<div className="flex gap-2 items-center"> <span>{t("groups.all")}</span>
<div className="relative"> <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 <Input
type="text" type="text"
placeholder={t("header.searchPlaceholder")} placeholder={t("header.searchPlaceholder")}
@@ -247,122 +297,43 @@ const HomeHeader = ({
onChange={(e) => { onChange={(e) => {
onSearchQueryChange(e.target.value); 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" /> <LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
{searchQuery && ( {searchQuery ? (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
onSearchQueryChange(""); 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")} 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> </button>
)} ) : null}
</div> </div>
<DropdownMenu> )}
<DropdownMenuTrigger asChild>
<span> {showProfileToolbar && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center h-[36px] border-foreground/20 hover:text-foreground"
>
<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>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span className="shrink-0">
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => {
onCreateProfileDialogOpen(true); 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> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent>{t("header.createProfile")}</TooltipContent>
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
{t("header.createProfile")}
</TooltipContent>
</Tooltip> </Tooltip>
</div> )}
</div> </div>
); );
}; };
+48 -39
View File
@@ -9,11 +9,16 @@ import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form"; import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
@@ -30,6 +35,7 @@ import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support"; import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types"; import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple"; import { RippleButton } from "./ui/ripple";
@@ -43,12 +49,14 @@ interface ImportProfileDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
crossOsUnlocked?: boolean; crossOsUnlocked?: boolean;
subPage?: boolean;
} }
export function ImportProfileDialog({ export function ImportProfileDialog({
isOpen, isOpen,
onClose, onClose,
crossOsUnlocked, crossOsUnlocked,
subPage,
}: ImportProfileDialogProps) { }: ImportProfileDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>( const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
@@ -292,39 +300,33 @@ export function ImportProfileDialog({
}, [isOpen, loadDetectedProfiles]); }, [isOpen, loadDetectedProfiles]);
return ( 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"> <DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0"> {!subPage && (
<DialogTitle>{t("importProfile.title")}</DialogTitle> <DialogHeader className="flex-shrink-0">
</DialogHeader> <DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 space-y-6 min-h-0"> <div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && ( {currentStep === "select" && (
<> <AnimatedTabs
<div className="flex gap-2"> value={importMode}
<RippleButton onValueChange={(v) =>
variant={importMode === "auto-detect" ? "default" : "outline"} setImportMode(v as "auto-detect" | "manual")
onClick={() => { }
setImportMode("auto-detect"); className="flex flex-col gap-6"
}} >
className="flex-1" <AnimatedTabsList>
disabled={isLoading} <AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
>
{t("importProfile.autoDetect")} {t("importProfile.autoDetect")}
</RippleButton> </AnimatedTabsTrigger>
<RippleButton <AnimatedTabsTrigger value="manual" disabled={isLoading}>
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
{t("importProfile.manualImport")} {t("importProfile.manualImport")}
</RippleButton> </AnimatedTabsTrigger>
</div> </AnimatedTabsList>
{importMode === "auto-detect" && ( <AnimatedTabsContent value="auto-detect">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")} {t("importProfile.detectedProfilesTitle")}
@@ -379,7 +381,7 @@ export function ImportProfileDialog({
> >
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{IconComponent && ( {IconComponent && (
<IconComponent className="w-4 h-4" /> <IconComponent className="size-4" />
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium"> <span className="font-medium">
@@ -435,9 +437,9 @@ export function ImportProfileDialog({
</div> </div>
)} )}
</div> </div>
)} </AnimatedTabsContent>
{importMode === "manual" && ( <AnimatedTabsContent value="manual">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
{t("importProfile.manualTitle")} {t("importProfile.manualTitle")}
@@ -471,7 +473,7 @@ export function ImportProfileDialog({
<SelectItem key={browser} value={browser}> <SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{IconComponent && ( {IconComponent && (
<IconComponent className="w-4 h-4" /> <IconComponent className="size-4" />
)} )}
<span>{getBrowserDisplayName(browser)}</span> <span>{getBrowserDisplayName(browser)}</span>
</div> </div>
@@ -503,7 +505,7 @@ export function ImportProfileDialog({
onClick={() => void handleBrowseFolder()} onClick={() => void handleBrowseFolder()}
title={t("importProfile.browseFolderTitle")} title={t("importProfile.browseFolderTitle")}
> >
<FaFolder className="w-4 h-4" /> <FaFolder className="size-4" />
</Button> </Button>
</div> </div>
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
@@ -535,8 +537,8 @@ export function ImportProfileDialog({
</div> </div>
</div> </div>
</div> </div>
)} </AnimatedTabsContent>
</> </AnimatedTabs>
)} )}
{currentStep === "configure" && currentMappedBrowser && ( {currentStep === "configure" && currentMappedBrowser && (
@@ -600,12 +602,19 @@ export function ImportProfileDialog({
)} )}
</div> </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" ? ( {currentStep === "select" ? (
<> <>
<RippleButton variant="outline" onClick={handleClose}> {!subPage && (
{t("common.buttons.cancel")} <RippleButton variant="outline" onClick={handleClose}>
</RippleButton> {t("common.buttons.cancel")}
</RippleButton>
)}
<RippleButton <RippleButton
disabled={!canProceedToNext} disabled={!canProceedToNext}
onClick={() => { onClick={() => {
@@ -635,7 +644,7 @@ export function ImportProfileDialog({
</LoadingButton> </LoadingButton>
</> </>
)} )}
</DialogFooter> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+410 -290
View File
@@ -4,8 +4,23 @@ import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -14,8 +29,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard"; import { CopyToClipboard } from "./ui/copy-to-clipboard";
@@ -33,14 +48,59 @@ interface McpConfig {
token: string; 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 { interface IntegrationsDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
subPage?: boolean;
/** Which tab is displayed when the dialog mounts; defaults to "api". */
initialTab?: "api" | "mcp";
}
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({ export function IntegrationsDialog({
isOpen, isOpen,
onClose, onClose,
subPage,
initialTab = "api",
}: IntegrationsDialogProps) { }: IntegrationsDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({ const [settings, setSettings] = useState<AppSettings>({
@@ -55,11 +115,12 @@ export function IntegrationsDialog({
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null); const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
const [, setMcpRunning] = useState(false); const [, setMcpRunning] = useState(false);
const [showApiToken, setShowApiToken] = useState(false); const [showApiToken, setShowApiToken] = useState(false);
const [showMcpToken, setShowMcpToken] = useState(false); const [showMcpUrl, setShowMcpUrl] = useState(false);
const [isApiStarting, setIsApiStarting] = useState(false); const [isApiStarting, setIsApiStarting] = useState(false);
const [isMcpStarting, setIsMcpStarting] = useState(false); const [isMcpStarting, setIsMcpStarting] = useState(false);
const [mcpInClaudeDesktop, setMcpInClaudeDesktop] = useState(false); const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [mcpInClaudeCode, setMcpInClaudeCode] = useState(false); const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
const { termsAccepted } = useWayfernTerms(); const { termsAccepted } = useWayfernTerms();
@@ -67,6 +128,7 @@ export function IntegrationsDialog({
try { try {
const loaded = await invoke<AppSettings>("get_app_settings"); const loaded = await invoke<AppSettings>("get_app_settings");
setSettings(loaded); setSettings(loaded);
setApiPortDraft(String(loaded.api_port ?? ""));
} catch (e) { } catch (e) {
console.error("Failed to load settings:", e); console.error("Failed to load settings:", e);
} }
@@ -99,21 +161,12 @@ export function IntegrationsDialog({
} }
}, []); }, []);
const loadClaudeDesktopStatus = useCallback(async () => { const loadAgents = useCallback(async () => {
try { try {
const exists = await invoke<boolean>("is_mcp_in_claude_desktop"); const list = await invoke<McpAgentInfo[]>("list_mcp_agents");
setMcpInClaudeDesktop(exists); setAgents(list);
} catch { } catch (e) {
// Not critical console.error("Failed to list MCP agents:", e);
}
}, []);
const loadClaudeCodeStatus = useCallback(async () => {
try {
const exists = await invoke<boolean>("is_mcp_in_claude_code");
setMcpInClaudeCode(exists);
} catch {
// Claude CLI may not be installed
} }
}, []); }, []);
@@ -123,8 +176,7 @@ export function IntegrationsDialog({
void loadApiServerStatus(); void loadApiServerStatus();
void loadMcpConfig(); void loadMcpConfig();
void loadMcpServerStatus(); void loadMcpServerStatus();
void loadClaudeDesktopStatus(); void loadAgents();
void loadClaudeCodeStatus();
} }
}, [ }, [
isOpen, isOpen,
@@ -132,8 +184,7 @@ export function IntegrationsDialog({
loadApiServerStatus, loadApiServerStatus,
loadMcpConfig, loadMcpConfig,
loadMcpServerStatus, loadMcpServerStatus,
loadClaudeDesktopStatus, loadAgents,
loadClaudeCodeStatus,
]); ]);
const handleApiToggle = async (enabled: boolean) => { const handleApiToggle = async (enabled: boolean) => {
@@ -179,6 +230,7 @@ export function IntegrationsDialog({
}); });
setSettings(next); setSettings(next);
void loadMcpConfig(); void loadMcpConfig();
void loadAgents();
showSuccessToast(t("integrations.mcpStarted", { port })); showSuccessToast(t("integrations.mcpStarted", { port }));
} else { } else {
await invoke("stop_mcp_server"); await invoke("stop_mcp_server");
@@ -200,213 +252,309 @@ 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 ( return (
<Dialog <Dialog
open={isOpen} open={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) onClose(); if (!open) onClose();
}} }}
subPage={subPage}
> >
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col"> <DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
<DialogHeader className="shrink-0"> {!subPage && (
<DialogTitle>{t("integrations.title")}</DialogTitle> <DialogHeader className="shrink-0">
</DialogHeader> <DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 min-h-0"> <div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full"> <AnimatedTabs key={initialTab} defaultValue={initialTab}>
<TabsList className="grid w-full grid-cols-2"> <AnimatedTabsList>
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger> <AnimatedTabsTrigger value="api">
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger> {t("integrations.tabApi")}
</TabsList> </AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="api" className="space-y-4 mt-4"> <AnimatedTabsContent
<div className="flex items-center space-x-2"> value="api"
<Checkbox className="mt-4 flex flex-col gap-4"
id="api-enabled" >
checked={apiServerPort !== null} <div className="rounded-md border bg-card p-4 flex flex-col gap-4">
disabled={isApiStarting} <div className="flex items-start justify-between gap-3">
onCheckedChange={(checked) => void handleApiToggle(!!checked)} <div className="flex items-start gap-3">
/> <LuPlug className="size-5 mt-0.5 text-muted-foreground" />
<div className="grid gap-1.5 leading-none"> <div className="flex flex-col gap-1">
<Label <Label className="text-sm font-medium">
htmlFor="api-enabled" {t("integrations.apiEnableLabel")}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" </Label>
> <p className="text-xs text-muted-foreground">
{t("integrations.apiEnableLabel")} {t("integrations.apiEnableDescription")}
</Label> </p>
<p className="text-xs text-muted-foreground"> </div>
{t("integrations.apiEnableDescription")} </div>
</p> <AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(checked)}
/>
</div> </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> </div>
{settings.api_enabled && ( {settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40"> <>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-4">
<Label className="text-sm font-medium"> <div className="rounded-md border bg-card p-4 flex flex-col gap-2">
{t("integrations.apiPortLabel")} <Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
</Label> {t("integrations.apiPortLabel")}
<div className="flex items-center space-x-2"> </Label>
<Button <div className="flex items-center gap-2">
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">
<Input <Input
type={showApiToken ? "text" : "password"} type="number"
value={settings.api_token ?? ""} value={apiPortDraft}
readOnly onChange={(e) => {
className="font-mono pr-10" setApiPortDraft(e.target.value);
const val = Number.parseInt(e.target.value, 10);
if (
!Number.isNaN(val) &&
val >= 1 &&
val <= 65535
) {
setSettings({ ...settings, api_port: val });
}
}}
onBlur={() => {
const val = Number.parseInt(apiPortDraft, 10);
if (Number.isNaN(val) || val < 1 || val > 65535) {
setApiPortDraft(String(settings.api_port));
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/> />
<Button <Button
type="button"
variant="ghost"
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent" variant="outline"
onClick={() => { disabled={
setShowApiToken(!showApiToken); 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 ? ( {t("common.buttons.save")}
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button> </Button>
</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.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 <CopyToClipboard
text={settings.api_token ?? ""} text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("integrations.tokenCopied")} successMessage={t("common.buttons.copied")}
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
{t("integrations.apiTokenHint", { {`curl -H "Authorization: Bearer \${TOKEN}" \\
tokenSlot: "<token>", http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
})} </pre>
</p>
</div> </div>
</div> </>
)} )}
</TabsContent> </AnimatedTabsContent>
<TabsContent value="mcp" className="space-y-4 mt-4"> <AnimatedTabsContent
<div className="flex items-center space-x-2"> value="mcp"
<Checkbox className="mt-4 flex flex-col gap-5"
id="mcp-enabled" >
checked={settings.mcp_enabled && mcpConfig !== null} <div className="rounded-md border bg-card p-4 flex flex-col gap-4">
disabled={!termsAccepted || isMcpStarting} <div className="flex items-start justify-between gap-3">
onCheckedChange={(checked) => void handleMcpToggle(!!checked)} <div className="flex items-start gap-3">
/> <LuZap className="size-5 mt-0.5 text-muted-foreground" />
<div className="grid gap-1.5 leading-none"> <div className="flex flex-col gap-1">
<Label <Label className="text-sm font-medium">
htmlFor="mcp-enabled" {t("integrations.mcpEnableLabel")}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" </Label>
> <p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableLabel")} {t("integrations.mcpEnableDescription")}
</Label> {!termsAccepted && (
<p className="text-xs text-muted-foreground"> <span className="ml-1 text-warning">
{t("integrations.mcpEnableDescription")} {t("integrations.mcpAcceptTermsFirst")}
{!termsAccepted && ( </span>
<span className="ml-1 text-warning"> )}
{t("integrations.mcpAcceptTermsFirst")} </p>
</span> </div>
)} </div>
</p> <AnimatedSwitch
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(checked)}
/>
</div> </div>
</div> </div>
{mcpConfig && ( {mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40"> <>
<div className="space-y-2"> <div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-sm font-medium"> <Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.url")} {t("integrations.mcp.url")}
</Label> </Label>
<div className="flex items-center space-x-2"> <div className="flex items-center gap-x-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
type={showMcpToken ? "text" : "password"} type={showMcpUrl ? "text" : "password"}
value={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`} value={mcpUrl}
readOnly readOnly
className="font-mono text-xs pr-10" className="font-mono text-xs pr-10"
/> />
@@ -416,116 +564,88 @@ export function IntegrationsDialog({
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent" className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => { onClick={() => {
setShowMcpToken(!showMcpToken); setShowMcpUrl(!showMcpUrl);
}} }}
> >
{showMcpToken ? ( {showMcpUrl ? (
<EyeOff className="h-4 w-4" /> <EyeOff className="size-4" />
) : ( ) : (
<Eye className="h-4 w-4" /> <Eye className="size-4" />
)} )}
</Button> </Button>
</div> </div>
<CopyToClipboard <CopyToClipboard
text={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`} text={mcpUrl}
successMessage={t("integrations.mcp.urlCopied")} successMessage={t("integrations.mcp.urlCopied")}
/> />
</div> </div>
</div> </div>
<div className="space-y-2 pt-1 border-t"> <div className="flex flex-col gap-3">
<p className="text-xs font-medium text-muted-foreground"> <Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.claudeDesktopTitle")} {t("integrations.mcp.clientsLabel")}
</p> </Label>
{mcpInClaudeDesktop ? ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Button {agents.map((agent) => {
variant="outline" const busy = busyAgentIds.has(agent.id);
size="sm" return (
className="w-full" <div
onClick={async () => { key={agent.id}
try { className="rounded-md border bg-card px-3 py-2.5 flex items-center gap-3"
await invoke("remove_mcp_from_claude_desktop"); >
setMcpInClaudeDesktop(false); <div className="grid place-items-center size-8 rounded-md bg-muted shrink-0">
showSuccessToast( <AgentIcon category={agent.category} />
t("integrations.mcp.removedFromClaudeDesktop"), </div>
); <div className="min-w-0 flex-1">
} catch (e) { <p className="text-sm font-medium truncate">
showErrorToast(String(e)); {agent.display_name}
} </p>
}} <p className="text-[10px] uppercase tracking-wide text-muted-foreground">
> {categoryLabel(t, agent.category)}
{t("integrations.mcp.removeFromClaudeDesktop")} </p>
</Button> </div>
) : ( {agent.connected ? (
<Button <div className="flex items-center gap-1">
variant="outline" <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">
size="sm" <LuCheck className="size-3" />
className="w-full" {t("integrations.mcp.connected")}
onClick={async () => { </span>
try { <Button
await invoke("add_mcp_to_claude_desktop"); type="button"
setMcpInClaudeDesktop(true); variant="ghost"
showSuccessToast( size="icon"
t("integrations.mcp.addedToClaudeDesktop"), className="size-8 text-muted-foreground hover:text-destructive"
); disabled={busy}
} catch (e) { onClick={() => void handleRemoveAgent(agent)}
showErrorToast(String(e)); aria-label={t(
} "integrations.mcp.removeAriaLabel",
}} {
> name: agent.display_name,
{t("integrations.mcp.addToClaudeDesktop")} },
</Button> )}
)} >
<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>
</>
<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> </AnimatedTabsContent>
</Tabs> </AnimatedTabs>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+2 -2
View File
@@ -12,12 +12,12 @@ type Props = ButtonProps & {
export const LoadingButton = ({ isLoading, className, ...props }: Props) => { export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
return ( return (
<UIButton <UIButton
className={cn("grid place-items-center", className)} className={cn("inline-flex items-center justify-center", className)}
{...props} {...props}
disabled={props.disabled || isLoading} disabled={props.disabled || isLoading}
> >
{isLoading ? ( {isLoading ? (
<LuLoaderCircle className="h-4 w-4 animate-spin" /> <LuLoaderCircle className="size-4 animate-spin" />
) : ( ) : (
props.children props.children
)} )}
+4 -4
View File
@@ -26,6 +26,10 @@ interface LocationProxyDialogProps {
onClose: () => void; onClose: () => void;
} }
function LoadingSpinner() {
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
}
export function LocationProxyDialog({ export function LocationProxyDialog({
isOpen, isOpen,
onClose, onClose,
@@ -219,10 +223,6 @@ export function LocationProxyDialog({
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name })); const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
const ispOptions = isps.map((i) => ({ value: i.code, label: i.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 ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
+1 -1
View File
@@ -434,7 +434,7 @@ const MultipleSelector = React.forwardRef<
handleUnselect(option); 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> </button>
</Badge> </Badge>
); );
+3 -3
View File
@@ -131,9 +131,9 @@ export function PermissionDialog({
const getPermissionIcon = (type: PermissionType) => { const getPermissionIcon = (type: PermissionType) => {
switch (type) { switch (type) {
case "microphone": case "microphone":
return <BsMic className="w-8 h-8" />; return <BsMic className="size-8" />;
case "camera": case "camera":
return <BsCamera className="w-8 h-8" />; return <BsCamera className="size-8" />;
} }
}; };
@@ -195,7 +195,7 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader className="text-center"> <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)} {getPermissionIcon(permissionType)}
</div> </div>
<DialogTitle className="text-xl"> <DialogTitle className="text-xl">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -53,14 +53,16 @@ export function ProfilePasswordDialog({
const firstInputRef = React.useRef<HTMLInputElement>(null); const firstInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (isOpen) { if (!isOpen) return;
setOldPassword(""); setOldPassword("");
setPassword(""); setPassword("");
setConfirm(""); setConfirm("");
setIsSubmitting(false); setIsSubmitting(false);
setLockoutSecondsRemaining(null); setLockoutSecondsRemaining(null);
setTimeout(() => firstInputRef.current?.focus(), 0); const handle = window.setTimeout(() => firstInputRef.current?.focus(), 0);
} return () => {
window.clearTimeout(handle);
};
}, [isOpen]); }, [isOpen]);
// Tick down the lockout timer // Tick down the lockout timer
+1 -1
View File
@@ -237,7 +237,7 @@ export function ProfileSelectorDialog({
profile.browser, profile.browser,
); );
return IconComponent ? ( return IconComponent ? (
<IconComponent className="w-4 h-4" /> <IconComponent className="size-4" />
) : null; ) : null;
})()} })()}
</div> </div>
+4 -4
View File
@@ -188,7 +188,7 @@ export function ProfileSyncDialog({
{isCheckingConfig ? ( {isCheckingConfig ? (
<div className="flex justify-center py-8"> <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>
) : ( ) : (
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
@@ -216,7 +216,7 @@ export function ProfileSyncDialog({
disabled={isSaving} disabled={isSaving}
className="grid gap-3" 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" /> <RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer"> <Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium"> <span className="font-medium">
@@ -228,7 +228,7 @@ export function ProfileSyncDialog({
</Label> </Label>
</div> </div>
<div className="flex items-start space-x-3"> <div className="flex items-start gap-x-3">
<RadioGroupItem value="Regular" id="sync-regular" /> <RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer"> <Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium"> <span className="font-medium">
@@ -240,7 +240,7 @@ export function ProfileSyncDialog({
</Label> </Label>
</div> </div>
<div className="flex items-start space-x-3"> <div className="flex items-start gap-x-3">
<RadioGroupItem <RadioGroupItem
value="Encrypted" value="Encrypted"
id="sync-encrypted" id="sync-encrypted"
+12 -6
View File
@@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event"; 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 { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu"; import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -55,6 +55,7 @@ export function ProxyAssignmentDialog({
vpnConfigs = [], vpnConfigs = [],
}: ProxyAssignmentDialogProps) { }: ProxyAssignmentDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const proxyListboxId = useId();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">( const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
"none", "none",
@@ -183,6 +184,7 @@ export function ProxyAssignmentDialog({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={proxyPopoverOpen} aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxId}
className="w-full justify-between font-normal" className="w-full justify-between font-normal"
> >
{(() => { {(() => {
@@ -199,10 +201,14 @@ export function ProxyAssignmentDialog({
); );
return proxy ? proxy.name : t("proxyAssignment.noneOption"); 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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[240px] p-0" sideOffset={8}> <PopoverContent
id={proxyListboxId}
className="w-[240px] p-0"
sideOffset={8}
>
<Command> <Command>
<CommandInput <CommandInput
placeholder={t("proxyAssignment.searchPlaceholder")} placeholder={t("proxyAssignment.searchPlaceholder")}
@@ -219,7 +225,7 @@ export function ProxyAssignmentDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectionType === "none" selectionType === "none"
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
@@ -243,7 +249,7 @@ export function ProxyAssignmentDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectionType === "proxy" && selectionType === "proxy" &&
selectedId === proxy.id selectedId === proxy.id
? "opacity-100" ? "opacity-100"
@@ -269,7 +275,7 @@ export function ProxyAssignmentDialog({
> >
<LuCheck <LuCheck
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 size-4",
selectionType === "vpn" && selectedId === vpn.id selectionType === "vpn" && selectedId === vpn.id
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
+3 -3
View File
@@ -118,12 +118,12 @@ export function ProxyCheckButton({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0" className="size-7 p-0"
onClick={handleCheck} onClick={handleCheck}
disabled={isCurrentlyChecking || disabled} disabled={isCurrentlyChecking || disabled}
> >
{isCurrentlyChecking ? ( {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 ? ( ) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center"> <span className="relative inline-flex items-center justify-center">
<FlagIcon countryCode={result.country_code} className="h-2.5" /> <FlagIcon countryCode={result.country_code} className="h-2.5" />
@@ -132,7 +132,7 @@ export function ProxyCheckButton({
) : result && !result.is_valid ? ( ) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span> <span className="text-destructive text-sm"></span>
) : ( ) : (
<FiCheck className="w-3 h-3" /> <FiCheck className="size-3" />
)} )}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
+5 -5
View File
@@ -108,13 +108,13 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
}} }}
className="flex gap-4" 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" /> <RadioGroupItem value="json" id="format-json" />
<Label htmlFor="format-json" className="cursor-pointer"> <Label htmlFor="format-json" className="cursor-pointer">
{t("proxies.exportDialog.json")} {t("proxies.exportDialog.json")}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center gap-x-2">
<RadioGroupItem value="txt" id="format-txt" /> <RadioGroupItem value="txt" id="format-txt" />
<Label htmlFor="format-txt" className="cursor-pointer"> <Label htmlFor="format-txt" className="cursor-pointer">
{t("proxies.exportDialog.txt")} {t("proxies.exportDialog.txt")}
@@ -154,9 +154,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
{copied ? ( {copied ? (
<LuCheck className="w-4 h-4" /> <LuCheck className="size-4" />
) : ( ) : (
<LuCopy className="w-4 h-4" /> <LuCopy className="size-4" />
)} )}
{copied {copied
? t("proxies.exportDialog.copied") ? t("proxies.exportDialog.copied")
@@ -167,7 +167,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
disabled={!exportContent || isLoading} disabled={!exportContent || isLoading}
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
<LuDownload className="w-4 h-4" /> <LuDownload className="size-4" />
{t("common.buttons.download")} {t("common.buttons.download")}
</RippleButton> </RippleButton>
</DialogFooter> </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"> <p className="text-sm text-muted-foreground text-center">
{t("proxies.importDialog.dropzonePrompt")} {t("proxies.importDialog.dropzonePrompt")}
<br /> <br />
File diff suppressed because it is too large Load Diff

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