Compare commits

...

17 Commits

Author SHA1 Message Date
zhom 2e0ee1ddfe chore: version bump 2026-05-10 06:45:46 +04:00
zhom 8dc48ef526 chore: logging 2026-05-10 06:03:48 +04:00
zhom bc3c2c8cca chore: copy 2026-05-10 04:45:46 +04:00
zhom b4a8fd04d8 feat: password protected profiles 2026-05-10 04:32:59 +04:00
zhom 5bff4438f0 docs: remove fossa badge 2026-05-10 02:20:08 +04:00
zhom 0fe3e5bc50 feat: telegram notifications 2026-05-09 21:07:32 +04:00
zhom 90ccf77e3f chore: optimize issue validation 2026-05-09 18:30:52 +04:00
zhom 88e6d7e116 chore: linting 2026-05-09 18:26:27 +04:00
zhom dd613a4d59 refactor: reduce the number of s3 calls 2026-05-09 18:26:27 +04:00
dependabot[bot] cabb5a3e23 deps(rust)(deps): bump the rust-dependencies group (#349)
Bumps the rust-dependencies group in /src-tauri with 31 updates:

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 09:19:51 +00:00
zhom 53165e3cf0 chore: cleanup issue validation 2026-05-06 13:18:23 +04:00
github-actions[bot] 29e73bd2d8 chore: update flake.nix for v0.22.7 [skip ci] (#341)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-05 22:13:54 +00:00
github-actions[bot] 6441843d85 docs: update CHANGELOG.md and README.md for v0.22.7 [skip ci] (#340)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-05 22:13:36 +00:00
49 changed files with 4052 additions and 353 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+158 -30
View File
@@ -160,11 +160,73 @@ jobs:
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
- macOS: `~/Library/Logs/Donut Browser/`
- macOS: `~/Library/Logs/com.donutbrowser/`
- Linux: `~/.local/share/DonutBrowser/logs/`
- Windows: `%APPDATA%\DonutBrowser\logs\`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else)
- **`CDP not ready after N attempts on port X: HTTP 5xx ...`** —
an HTTP 5xx (503 / 502) response from a freshly-launched
browser's `/json/version` endpoint always means *something on
the loopback path is intercepting the connection*: a firewall,
an antivirus web-shield (Kaspersky, Bitdefender, ESET, Avast /
AVG, Yandex Protect on Windows; Little Snitch, LuLu on macOS),
a VPN client that hijacks 127.0.0.1, or a corporate MDM /
proxy (Zscaler, Cisco AnyConnect, Netskope). Chrome's
DevTools endpoint never returns 5xx itself — only synthetic
responses from interception layers do. **Do NOT speculate
about Gatekeeper, first-launch verification, code signing, or
quarantine** — none of those cause a 5xx response, and
Gatekeeper never delays a launch long enough to surface as
"120 attempts". Lead with: which AV / web-shield / firewall /
VPN / MDM is installed, and ask the user to try with the AV's
web-shield component temporarily disabled (not the whole AV).
EOF
- name: Build triage system prompt
run: |
# The static system prompt has apostrophes ("doesn't", "official docs"
# etc.) that collide with shell single-quoting if embedded directly in
# the jq filter. Build the full prompt to a file instead, then load it
# via --rawfile in the next step.
{
cat <<'TRIAGE_HEAD'
You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.
TRIAGE_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'TRIAGE_TAIL'
# OUTPUT
Return ONLY valid JSON. No preamble, no code fences. Schema:
{
"language": "en" or ISO 639-1 code,
"classification": one of ["bug-in-scope", "bug-upstream-camoufox", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
"operating_system": "macos" | "windows" | "linux" | "unknown",
"is_paid_feature": true | false,
"user_followed_template": true | false,
"regression_signal": quoted user snippet or null,
"user_cited_external_docs": URL string or null,
"files_to_read": array of at most 20 file paths from the list,
"notes": one short sentence describing what you observed
}
Classification guidance:
- "bug-upstream-camoufox": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.
- "bug-template-violation": missing or filled-in nonsense for required template fields.
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
- "regression": user names a prior version that worked.
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
TRIAGE_TAIL
} > /tmp/triage-system.txt
wc -c /tmp/triage-system.txt
- name: Stage 1 — Triage and file selection
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
@@ -173,23 +235,17 @@ jobs:
# short list of source files for the composer to read.
PAYLOAD=$(jq -n \
--arg model "$TRIAGE_MODEL" \
--rawfile system_prompt /tmp/triage-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile files /tmp/all-source-files.txt \
--rawfile scope /tmp/scope-and-pricing.md \
--rawfile guidelines /tmp/repo-context.txt \
'{
model: $model,
messages: [
{
role: "system",
content: ("You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.\n\n" + $scope + "\n\n# REPO GUIDELINES\n" + $guidelines + "\n\n# OUTPUT\nReturn ONLY valid JSON. No preamble, no code fences. Schema:\n{\n \"language\": \"en\" or ISO 639-1 code,\n \"classification\": one of [\"bug-in-scope\", \"bug-upstream-camoufox\", \"bug-template-violation\", \"feature-request\", \"fork-request\", \"regression\", \"ai-generated-junk\", \"question\", \"other\"],\n \"operating_system\": \"macos\" | \"windows\" | \"linux\" | \"unknown\",\n \"is_paid_feature\": true | false,\n \"user_followed_template\": true | false,\n \"regression_signal\": quoted user snippet or null,\n \"user_cited_external_docs\": URL string or null,\n \"files_to_read\": array of at most 20 file paths from the list,\n \"notes\": one short sentence describing what you observed\n}\n\nClassification guidance:\n- \"bug-upstream-camoufox\": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.\n- \"bug-template-violation\": missing or filled-in nonsense for required template fields.\n- \"ai-generated-junk\": cites fabricated 'official docs' (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).\n- \"fork-request\": asks for support of CloverLabsAI/VulpineOS/etc. forks.\n- \"regression\": user names a prior version that worked.\n\nFile selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.")
},
{
role: "user",
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files)
}
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
]
}')
@@ -246,6 +302,81 @@ jobs:
mv /tmp/file-context.capped.txt /tmp/file-context.txt
wc -c /tmp/file-context.txt
- name: Build composer system prompt
run: |
# Same reason as the triage prompt: lots of apostrophes, no shell-quoting
# gymnastics. Build it to a file, load via --rawfile.
{
cat <<'COMPOSER_HEAD'
You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.
COMPOSER_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'COMPOSER_TAIL'
# RULES — STRICT
## Output shape
- One sentence acknowledging the report.
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.
- Maximum 15 lines.
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
- No closing pleasantries ("please let me know", "happy to help", etc.).
## Forbidden — never do these
- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
- NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
- NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
- NEVER list more than one OS log path. Use ONLY the path matching the user's reported OS. If OS is unknown, ask for it instead of listing all three.
- NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
- NEVER call a report "a known and expected behavior" or "a false positive" if the user mentions a regression. The triage tells you when this applies.
## Classification handling
The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.
## Paid-feature awareness
If `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.
## Language
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
Use ONLY the one matching `triage.operating_system`:
- macos: `~/Library/Logs/com.donutbrowser/`
- linux: `~/.local/share/DonutBrowser/logs/`
- windows: `%APPDATA%\DonutBrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\DonutBrowser\logs`)
- unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions)
If the issue body contains any of these, lead with the matching
response — do NOT speculate about other causes:
- `CDP not ready after N attempts on port X: HTTP 5xx ...` —
this is loopback interception by a firewall / antivirus
web-shield / VPN / MDM. Lead with that question (specifically:
Kaspersky, Bitdefender, ESET, Avast/AVG, Yandex Protect on
Windows; Little Snitch, LuLu, corporate MDM on macOS; any
VPN). Suggest temporarily disabling the AV's web-shield
component (NOT the whole AV) and retrying. Do NOT mention
Gatekeeper, first-launch verification, code signing, or
quarantine — none of those cause an HTTP 5xx response, and
Gatekeeper never delays a launch long enough to produce a
"120 attempts" failure.
COMPOSER_TAIL
} > /tmp/composer-system.txt
wc -c /tmp/composer-system.txt
- name: Stage 2 — Compose response
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
@@ -254,13 +385,17 @@ jobs:
run: |
GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING='This is the user'\''s first issue — start the comment with "Thanks for opening your first issue!" on its own line.'
# Use printf with %s so the apostrophe inside the string never has to
# cross a shell single-quote boundary.
printf '%s' 'This is the first issue from this user — start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
else
: > /tmp/greeting.txt
fi
printf '%s' "$GREETING" > /tmp/greeting.txt
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile system_prompt /tmp/composer-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile author /tmp/issue-author.txt \
@@ -268,25 +403,18 @@ jobs:
--rawfile triage /tmp/triage.json \
--rawfile greeting /tmp/greeting.txt \
--rawfile files /tmp/file-context.txt \
--rawfile scope /tmp/scope-and-pricing.md \
--rawfile guidelines /tmp/repo-context.txt \
'{
model: $model,
messages: [
{
role: "system",
content: ("You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.\n\n" + $scope + "\n\n# REPO GUIDELINES\n" + $guidelines + "\n\n# RULES — STRICT\n\n## Output shape\n- One sentence acknowledging the report.\n- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.\n- Maximum 15 lines.\n- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.\n- No closing pleasantries (\"please let me know\", \"happy to help\", etc.).\n\n## Forbidden — never do these\n- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You don't have enough information; speculation is always wrong here.\n- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.\n- NEVER reference how subscription / paid-plan checks work internally. You don't know whether the user'\''s claim is correct.\n- NEVER call a report \"well-documented\", \"well-structured\", \"clear\", \"thorough\", \"reasonable\", \"well-thought-out\", or any similar evaluation. You are triage, not peer review.\n- NEVER list more than one OS log path. Use ONLY the path matching the user'\''s reported OS. If OS is unknown, ask for it instead of listing all three.\n- NEVER validate a feature request as \"a clear enhancement\" / \"a reasonable request\" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).\n- NEVER call a report \"a known and expected behavior\" or \"a false positive\" if the user mentions a regression. The triage tells you when this applies.\n\n## Classification handling\nThe triage classification (`triage.classification`) determines the response shape:\n\n- `bug-in-scope`: ask for what'\''s missing using the user'\''s reported OS log path. Be concrete about how to obtain logs.\n- `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.\n- `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.\n- `feature-request`: one neutral sentence acknowledging, then ask only what'\''s genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.\n- `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.\n- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user'\''s environment between then and now, and the specific delta in symptoms.\n- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.\n\n## Paid-feature awareness\nIf `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.\n\n## Language\nIf 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.\n\n## OS-specific log paths\nUse ONLY the one matching `triage.operating_system`:\n - macos: `~/Library/Logs/Donut Browser/`\n - linux: `~/.local/share/DonutBrowser/logs/`\n - windows: `%APPDATA%\\DonutBrowser\\logs\\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\\DonutBrowser\\logs`)\n - unknown: ask the user to share their OS first.")
},
{
role: "user",
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
"Title: " + $title +
"\nAuthor: " + $author +
"\n\n## Triage result\n" + $triage +
"\n\n## Parsed template fields\n" + $fields +
"\n\n## Raw issue body\n" + $body +
"\n\n## Source files (selected by triage)\n" + $files)
}
{ role: "system", content: $system_prompt },
{ role: "user",
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
+ "Title: " + $title
+ "\nAuthor: " + $author
+ "\n\n## Triage result\n" + $triage
+ "\n\n## Parsed template fields\n" + $fields
+ "\n\n## Raw issue body\n" + $body
+ "\n\n## Source files (selected by triage)\n" + $files) }
]
}')
@@ -479,7 +607,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@557734bd130a68188454bc691e153f9f3731830e #v1.14.31
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+114
View File
@@ -0,0 +1,114 @@
name: Notify Telegram
on:
release:
types: [published]
permissions:
contents: read
jobs:
notify:
# Only post for stable releases on the canonical repo. Pre-releases
# (rolling builds, RCs) are skipped so the channel stays low-noise.
if: github.repository == 'zhom/donutbrowser' && !github.event.release.prerelease
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 0
- name: Post release announcement to Telegram
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# Resolve the previous stable tag the same way notify-discord does
# so the changelog ranges line up.
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
# Build a plain bullet list from feat / fix / refactor commits.
# Other commit types (chore, docs, ci, test, deps) are intentionally
# filtered out — same convention as the Discord embed.
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
;;
esac
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
if [ -z "$CHANGES" ]; then
CHANGES="• See release notes."$'\n'
fi
# HTML-escape the changelog before injecting into Telegram HTML
# mode — commit messages can legitimately contain `<`, `>`, `&`.
# The static markup around it (we control it) is left as-is.
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
VERSION="${TAG}"
VERSION_NUM="${TAG#v}"
RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
DL="https://github.com/${REPO}/releases/download/${VERSION}"
# Build the API payload in one jq pass — keeps every literal
# newline, every angle bracket, and every quote correctly escaped
# for both shell and JSON.
PAYLOAD=$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg version "$VERSION" \
--arg changes "$ESCAPED_CHANGES" \
--arg dl "$DL" \
--arg vnum "$VERSION_NUM" \
--arg release_url "$RELEASE_URL" \
'{
chat_id: $chat_id,
parse_mode: "HTML",
disable_web_page_preview: true,
text: (
"<b>Donut Browser " + $version + " released</b>\n\n" +
$changes + "\n" +
"<b>Download</b>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_aarch64.dmg\">macOS (Apple Silicon)</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64.dmg\">macOS (Intel)</a>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64-setup.exe\">Windows x64</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_amd64.AppImage\">Linux x64</a>\n\n" +
"<a href=\"" + $release_url + "\">Full release notes</a>"
)
}')
# Use --fail-with-body so we surface Telegram's error JSON on 4xx/5xx
# instead of just a curl exit code.
RESPONSE=$(curl -sSL --fail-with-body \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage") \
|| { echo "::error::Telegram API call failed"; echo "$RESPONSE"; exit 1; }
if [ "$(jq -r .ok <<< "$RESPONSE")" != "true" ]; then
echo "::error::Telegram API rejected the message:"
jq . <<< "$RESPONSE"
exit 1
fi
echo "Posted to Telegram (message_id $(jq -r .result.message_id <<< "$RESPONSE"))"
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo
uses: crate-ci/typos@bbaefadf97b0ec5fdc942684b647f1a6ab250274 #v1.46.0
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with:
run_install: false
+13
View File
@@ -1,6 +1,19 @@
# Changelog
## v0.22.7 (2026-05-05)
### Refactoring
- cleanup
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.6 [skip ci] (#337)
## v0.22.6 (2026-05-03)
### Features
+5 -8
View File
@@ -16,9 +16,6 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a>
@@ -51,7 +48,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_x64.dmg) |
| **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) |
Or install via Homebrew:
@@ -61,15 +58,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_x64-portable.zip)
[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)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut-0.22.6-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut-0.22.6-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_aarch64.AppImage) |
| **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) |
| **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) |
| **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) |
<!-- install-links-end -->
Or install via package manager:
+1 -1
View File
@@ -117,7 +117,7 @@ export class SyncController {
@Get("subscribe")
@Sse()
subscribe(@Req() req: Request): Observable<MessageEvent> {
return this.syncService.subscribe(this.getUserContext(req), 2000).pipe(
return this.syncService.subscribe(this.getUserContext(req), 5000).pipe(
map((event) => ({
data: event,
})),
+239 -47
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
CreateBucketCommand,
DeleteObjectCommand,
@@ -41,6 +42,18 @@ import type {
SubscribeEventDto,
} from "./dto/sync.dto.js";
/**
* Marker object written under each scope (user / team / self-hosted root).
* Subscribers HEAD this object on each poll and only LIST when its ETag has
* changed, which keeps the steady-state polling cost down to one Class-B
* HeadObject per scope per poll instead of N Class-A ListObjectsV2 calls.
*
* Filename starts with a dot so it sorts first and is unmistakably internal
* to donut-sync; client `list()` calls strip it from results so it never
* leaks into application data.
*/
const MANIFEST_KEY = ".donut-sync-manifest";
@Injectable()
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
@@ -149,6 +162,71 @@ export class SyncService implements OnModuleInit {
return `${ctx.prefix}${key}`;
}
/**
* Return every scope prefix the given user can write to. For self-hosted
* that's the bucket root (`""`); for cloud that's the user prefix plus an
* optional team prefix.
*/
private scopesFor(ctx: UserContext): string[] {
if (ctx.mode === "self-hosted") return [""];
const out = [ctx.prefix];
if (ctx.teamPrefix) out.push(ctx.teamPrefix);
return out;
}
/**
* Bump the manifest object for the scope that owns `scopedKey`. Writers call
* this fire-and-forget after any successful mutation so subscribers'
* cheap HEAD polls observe an ETag change and pull a fresh listing.
*
* Slightly over-eager by design: we bump on presign-issue (rather than on
* the actual S3 PUT), so a never-completed upload causes one wasted refresh
* on other devices. That's strictly cheaper than verifying every upload.
*/
private async bumpManifest(
ctx: UserContext,
scopedKey: string,
): Promise<void> {
const scope = this.scopeForKey(ctx, scopedKey);
if (scope === null) return;
const key = `${scope}${MANIFEST_KEY}`;
// Body just needs to be unique so the ETag changes; clients never read it.
const body = JSON.stringify({
updatedAt: new Date().toISOString(),
nonce: randomUUID(),
});
try {
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: "application/json",
}),
);
} catch (err) {
// Manifest bump failures must NEVER fail the user's request.
// Subscribers fall back to detecting changes on their next listing.
this.logger.warn(
`Manifest bump failed for ${key}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Resolve which scope owns a fully-scoped key. Returns null if the key
* doesn't belong to a known scope (which shouldn't happen in practice
* because validateKeyAccess gates the write paths).
*/
private scopeForKey(ctx: UserContext, scopedKey: string): string | null {
if (ctx.mode === "self-hosted") return "";
if (ctx.teamPrefix && scopedKey.startsWith(ctx.teamPrefix)) {
return ctx.teamPrefix;
}
if (scopedKey.startsWith(ctx.prefix)) return ctx.prefix;
return null;
}
/**
* Validate that a key is accessible by the user.
* For cloud mode, key must start with user's prefix or team prefix.
@@ -220,6 +298,11 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
// Notify subscribers via the per-scope manifest. Fire-and-forget; a
// failure here just means other devices pick up the change on their
// next full listing instead of immediately.
void this.bumpManifest(ctx, key);
return {
url,
expiresAt: expiresAt.toISOString(),
@@ -294,6 +377,10 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
if (deleted || tombstoneCreated) {
void this.bumpManifest(ctx, key);
}
return { deleted, tombstoneCreated };
}
@@ -311,19 +398,22 @@ export class SyncService implements OnModuleInit {
const userPrefix = ctx?.prefix || "";
const teamPrefix = ctx?.teamPrefix || "";
const objects = (response.Contents || []).map((obj) => {
let key = obj.Key || "";
if (teamPrefix && key.startsWith(teamPrefix)) {
key = key.substring(teamPrefix.length);
} else if (userPrefix && key.startsWith(userPrefix)) {
key = key.substring(userPrefix.length);
}
return {
key,
lastModified: obj.LastModified?.toISOString() || "",
size: obj.Size || 0,
};
});
const objects = (response.Contents || [])
// Don't leak donut-sync's internal manifest object to clients.
.filter((obj) => !(obj.Key || "").endsWith(MANIFEST_KEY))
.map((obj) => {
let key = obj.Key || "";
if (teamPrefix && key.startsWith(teamPrefix)) {
key = key.substring(teamPrefix.length);
} else if (userPrefix && key.startsWith(userPrefix)) {
key = key.substring(userPrefix.length);
}
return {
key,
lastModified: obj.LastModified?.toISOString() || "",
size: obj.Size || 0,
};
});
return {
objects,
@@ -373,6 +463,20 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
// One bump per scope touched by this batch (usually one).
if (items.length > 0) {
const scopesSeen = new Set<string>();
for (const item of dto.items) {
const key = this.scopeKey(ctx, item.key);
const scope = this.scopeForKey(ctx, key);
if (scope !== null && !scopesSeen.has(scope)) {
scopesSeen.add(scope);
// Use any key from the scope; bumpManifest only inspects scope.
void this.bumpManifest(ctx, key);
}
}
}
return { items };
}
@@ -475,66 +579,154 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx);
}
if (deletedCount > 0 || tombstoneCreated) {
void this.bumpManifest(ctx, prefix);
}
return { deletedCount, tombstoneCreated };
}
/**
* Long-lived per-client poll loop.
*
* Steady-state cost is one HEAD per scope per poll (Class B on R2). A LIST
* (Class A) is only issued when:
* 1. it's the client's first poll (need to seed the state map), or
* 2. a write touched the scope and bumped its manifest ETag.
*
* This is *eventual* cross-device sync, gated by the poll interval.
* Real-time push is intentionally not provided here — that lives in the
* paid backend.
*/
subscribe(
ctx: UserContext,
pollIntervalMs = 2000,
pollIntervalMs = 5000,
): Observable<SubscribeEventDto> {
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
const scopes = this.scopesFor(ctx);
let prefixes: string[];
if (ctx.mode === "self-hosted") {
prefixes = basePrefixes;
} else {
prefixes = basePrefixes.map((p) => `${ctx.prefix}${p}`);
if (ctx.teamPrefix) {
prefixes.push(...basePrefixes.map((p) => `${ctx.teamPrefix}${p}`));
}
}
// Per-connection state (not shared across subscribers)
// Per-connection state (not shared across subscribers).
const lastManifestEtag = new Map<string, string | undefined>();
let lastKnownState = new Map<string, string>();
let initialized = false;
const pollChanges$ = interval(pollIntervalMs).pipe(
startWith(0),
switchMap(async () => {
const events: SubscribeEventDto[] = [];
const currentState = new Map<string, string>();
for (const prefix of prefixes) {
// Phase 1 — cheap HEAD on each scope's manifest. This is the
// steady-state cost (Class B). If no manifest changed since the
// last poll, we don't touch S3 again this tick.
let anyScopeChanged = false;
for (const scope of scopes) {
const manifestKey = `${scope}${MANIFEST_KEY}`;
let currentEtag: string | undefined;
try {
const result = await this.list({ prefix, maxKeys: 1000 });
for (const obj of result.objects) {
const stateKey = `${obj.key}:${obj.lastModified}`;
currentState.set(obj.key, stateKey);
const previousStateKey = lastKnownState.get(obj.key);
if (previousStateKey !== stateKey) {
events.push({
type: "change",
key: obj.key,
lastModified: obj.lastModified,
size: obj.size,
});
}
const head = await this.s3Client.send(
new HeadObjectCommand({
Bucket: this.bucket,
Key: manifestKey,
}),
);
currentEtag = head.ETag;
} catch (err: unknown) {
const status =
err && typeof err === "object" && "$metadata" in err
? (err as { $metadata?: { httpStatusCode?: number } }).$metadata
?.httpStatusCode
: undefined;
const name =
err && typeof err === "object" && "name" in err
? (err as { name?: string }).name
: undefined;
if (name === "NotFound" || name === "NoSuchKey" || status === 404) {
// No manifest yet — treat as "no changes" (undefined ETag).
currentEtag = undefined;
} else {
this.logger.error(
`Manifest HEAD failed for ${manifestKey}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
}
} catch (error) {
console.error(`Failed to list prefix ${prefix}:`, error);
}
const previousEtag = lastManifestEtag.get(scope);
if (previousEtag !== currentEtag) {
anyScopeChanged = true;
}
lastManifestEtag.set(scope, currentEtag);
}
// After the first poll, only run the LIST when something actually
// changed in at least one scope.
if (initialized && !anyScopeChanged) {
return [];
}
// Phase 2 — one LIST per scope (not per base prefix). Filter to the
// four base prefixes client-side. This is the cost we pay only when
// a manifest told us there's something new to look at.
const currentState = new Map<string, string>();
for (const scope of scopes) {
let continuationToken: string | undefined;
do {
try {
const result = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: scope,
MaxKeys: 1000,
ContinuationToken: continuationToken,
}),
);
for (const obj of result.Contents || []) {
const fullKey = obj.Key;
if (!fullKey) continue;
const relativeKey = fullKey.startsWith(scope)
? fullKey.substring(scope.length)
: fullKey;
// Skip the manifest object itself + anything outside the
// four data prefixes.
if (relativeKey === MANIFEST_KEY) continue;
if (!basePrefixes.some((bp) => relativeKey.startsWith(bp))) {
continue;
}
const lastModified = obj.LastModified?.toISOString() || "";
const stateKey = `${relativeKey}:${lastModified}`;
currentState.set(relativeKey, stateKey);
const previousStateKey = lastKnownState.get(relativeKey);
if (previousStateKey !== stateKey) {
events.push({
type: "change",
key: relativeKey,
lastModified,
size: obj.Size || 0,
});
}
}
continuationToken = result.NextContinuationToken;
} catch (err) {
this.logger.error(
`List failed for scope '${scope}': ${err instanceof Error ? err.message : String(err)}`,
);
continuationToken = undefined;
}
} while (continuationToken);
}
// Detect deletes by comparing key sets.
for (const [key] of lastKnownState) {
if (!currentState.has(key)) {
events.push({
type: "delete",
key,
});
events.push({ type: "delete", key });
}
}
lastKnownState = currentState;
initialized = true;
return events;
}),
switchMap((events) => of(...events)),
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.22.6";
releaseVersion = "0.22.7";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_amd64.AppImage";
hash = "sha256-sbYM8YKfQznGDl7kCJFDH2Ak+q//vYuHM6loXHckOAs=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.AppImage";
hash = "sha256-pnIiyXxCY/WxczM5IAjzCq+6C96oXOesmz27y78tJSI=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_aarch64.AppImage";
hash = "sha256-piMZR+ZxOyaxz6lom6aRZDyuU5fsu3kJFbOSsS5YuKI=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.AppImage";
hash = "sha256-CyrujVE925Fr2G1U18PaklXCjKCDi+kOAkak7tZ8CW4=";
}
else
null;
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.22.7",
"version": "0.23.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -93,7 +93,9 @@
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12",
"fast-xml-parser@<5.7.0": ">=5.7.2"
"fast-xml-parser@<5.7.0": ">=5.7.2",
"fast-uri@<3.1.2": ">=3.1.2",
"fast-xml-builder@<1.2.0": ">=1.2.0"
}
},
"packageManager": "pnpm@10.33.2",
+17 -8
View File
@@ -9,6 +9,8 @@ overrides:
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'
importers:
@@ -3784,11 +3786,11 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-xml-builder@1.1.5:
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
fast-xml-builder@1.2.0:
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
fast-xml-parser@5.7.2:
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
@@ -5525,6 +5527,10 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
xml-naming@0.1.0:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -8807,7 +8813,7 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
fast-uri: 3.1.2
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -9422,16 +9428,17 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-uri@3.1.0: {}
fast-uri@3.1.2: {}
fast-xml-builder@1.1.5:
fast-xml-builder@1.2.0:
dependencies:
path-expression-matcher: 1.5.0
xml-naming: 0.1.0
fast-xml-parser@5.7.2:
dependencies:
'@nodable/entities': 2.1.0
fast-xml-builder: 1.1.5
fast-xml-builder: 1.2.0
path-expression-matcher: 1.5.0
strnum: 2.2.3
@@ -11385,6 +11392,8 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
xml-naming@0.1.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
+12 -1
View File
@@ -171,10 +171,21 @@ async function startMinio(minioBin) {
async function buildDonutSync() {
log("Building donut-sync...");
// `nest build` runs incremental tsc, which silently skips emit when
// tsconfig.build.tsbuildinfo says nothing changed — even if dist/ was
// wiped. Drop the cache so we always produce a fresh dist.
const syncDir = path.join(ROOT_DIR, "donut-sync");
await rm(path.join(syncDir, "tsconfig.build.tsbuildinfo"), {
force: true,
});
await rm(path.join(syncDir, "dist"), { recursive: true, force: true });
execSync("pnpm build", {
cwd: path.join(ROOT_DIR, "donut-sync"),
cwd: syncDir,
stdio: process.env.VERBOSE ? "inherit" : "ignore",
});
if (!existsSync(path.join(syncDir, "dist", "main.js"))) {
throw new Error("donut-sync build did not produce dist/main.js");
}
log("donut-sync built");
}
+128 -167
View File
@@ -464,9 +464,9 @@ dependencies = [
[[package]]
name = "avif-serialize"
version = "0.8.8"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d"
checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
dependencies = [
"arrayvec",
]
@@ -941,9 +941,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.61"
version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1745,7 +1745,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.22.7"
version = "0.23.0"
dependencies = [
"aes 0.9.0",
"aes-gcm",
@@ -1820,7 +1820,7 @@ dependencies = [
"tokio-util",
"tower",
"tower-http",
"tray-icon",
"tray-icon 0.24.0",
"url",
"urlencoding",
"utoipa",
@@ -2052,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -2160,13 +2160,12 @@ dependencies = [
[[package]]
name = "filetime"
version = "0.2.27"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
@@ -2721,9 +2720,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.13"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [
"atomic-waker",
"bytes",
@@ -3013,7 +3012,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -3292,16 +3291,6 @@ version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
[[package]]
name = "iri-string"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -3588,10 +3577,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
]
[[package]]
@@ -3934,9 +3920,9 @@ dependencies = [
[[package]]
name = "no_std_io2"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550"
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
"memchr",
]
@@ -4233,6 +4219,17 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-open-directory"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
dependencies = [
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -4322,15 +4319,14 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.78"
version = "0.10.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -4354,9 +4350,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.114"
version = "0.9.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
dependencies = [
"cc",
"libc",
@@ -4397,7 +4393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -4449,7 +4445,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link 0.2.1",
]
@@ -4499,16 +4495,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
@@ -4524,7 +4510,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_macros",
"phf_shared 0.13.1",
"serde",
]
@@ -4535,20 +4521,10 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_generator",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
@@ -4559,41 +4535,19 @@ dependencies = [
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_generator",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
@@ -4620,18 +4574,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.11"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
dependencies = [
"proc-macro2",
"quote",
@@ -4671,12 +4625,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "playwright"
version = "0.0.23"
@@ -4917,18 +4865,18 @@ dependencies = [
[[package]]
name = "profiling"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
dependencies = [
"profiling-procmacros",
]
[[package]]
name = "profiling-procmacros"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
dependencies = [
"quote",
"syn 2.0.117",
@@ -4977,9 +4925,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.2"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
"serde",
@@ -5173,15 +5121,6 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -5499,9 +5438,9 @@ dependencies = [
[[package]]
name = "rust_decimal"
version = "1.41.0"
version = "1.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
dependencies = [
"arrayvec",
"borsh",
@@ -5539,7 +5478,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -5894,9 +5833,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.18.0"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -5913,9 +5852,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.18.0"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334"
dependencies = [
"darling",
"proc-macro2",
@@ -6161,9 +6100,9 @@ dependencies = [
[[package]]
name = "siphasher"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
@@ -6225,7 +6164,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall 0.5.18",
"redox_syscall",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -6322,7 +6261,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_generator",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
@@ -6423,15 +6362,16 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.38.4"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
checksum = "cd9f9fe3d2b7b75cf4f2805e5b9926e8ac47146667b16b86298c4a8bf08cc469"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"objc2-open-directory",
"windows 0.62.2",
]
@@ -6471,9 +6411,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.35.0"
version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
dependencies = [
"bitflags 2.11.0",
"block2",
@@ -6545,9 +6485,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66"
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
dependencies = [
"anyhow",
"bytes",
@@ -6586,7 +6526,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon",
"tray-icon 0.23.1",
"url",
"webkit2gtk",
"webview2-com",
@@ -6596,9 +6536,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988"
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
dependencies = [
"anyhow",
"cargo_toml",
@@ -6617,9 +6557,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e"
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -6644,9 +6584,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc"
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -6658,9 +6598,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
dependencies = [
"anyhow",
"glob",
@@ -6674,9 +6614,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.8"
version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95"
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [
"dunce",
"plist",
@@ -6695,9 +6635,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
@@ -6713,9 +6653,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
@@ -6731,7 +6671,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"url",
]
@@ -6774,9 +6714,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
dependencies = [
"dunce",
"glob",
@@ -6817,9 +6757,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.1"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
@@ -6832,9 +6772,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95"
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
dependencies = [
"cookie",
"dpi",
@@ -6857,9 +6797,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117"
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
dependencies = [
"gtk",
"http",
@@ -6883,9 +6823,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7"
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
dependencies = [
"anyhow",
"brotli",
@@ -6899,7 +6839,7 @@ dependencies = [
"json-patch",
"log",
"memchr",
"phf 0.11.3",
"phf 0.13.1",
"plist",
"proc-macro2",
"quote",
@@ -6912,7 +6852,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -6940,7 +6880,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -7102,9 +7042,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -7342,9 +7282,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.8"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.11.0",
"bytes",
@@ -7355,7 +7295,6 @@ dependencies = [
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
@@ -7366,6 +7305,7 @@ dependencies = [
"tower-layer",
"tower-service",
"tracing",
"url",
]
[[package]]
@@ -7445,6 +7385,27 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tray-icon"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674"
dependencies = [
"crossbeam-channel",
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
"windows-sys 0.60.2",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -7714,9 +7675,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.4.0"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap 2.13.0",
"serde",
@@ -7739,9 +7700,9 @@ dependencies = [
[[package]]
name = "utoipa-gen"
version = "5.4.0"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
dependencies = [
"proc-macro2",
"quote",
@@ -8091,7 +8052,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -8734,9 +8695,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "wry"
version = "0.55.0"
version = "0.55.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429"
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
dependencies = [
"base64 0.22.1",
"block2",
@@ -9142,9 +9103,9 @@ dependencies = [
[[package]]
name = "zvariant"
version = "5.10.1"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
dependencies = [
"endi",
"enumflags2",
@@ -9156,9 +9117,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "5.10.1"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.22.7"
version = "0.23.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -51,7 +51,7 @@ directories = "6"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7"
sysinfo = "0.38"
sysinfo = "0.39"
lazy_static = "1.5"
base64 = "0.22"
libc = "0.2"
@@ -110,7 +110,7 @@ boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.23"
tray-icon = "0.24"
tao = "0.35"
image = "0.25"
dirs = "6"
+53 -3
View File
@@ -401,6 +401,10 @@ impl ApiServer {
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
// Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers.
.layer(middleware::from_fn(request_logging_middleware))
.layer(CorsLayer::permissive())
.with_state(state);
@@ -454,6 +458,8 @@ async fn auth_middleware(
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
let path = request.uri().path().to_string();
// Get the Authorization header
let auth_header = headers
.get("Authorization")
@@ -462,19 +468,31 @@ async fn auth_middleware(
let token = match auth_header {
Some(token) => token,
None => return Err(StatusCode::UNAUTHORIZED),
None => {
log::warn!("[api] Rejected {path}: missing Authorization header");
return Err(StatusCode::UNAUTHORIZED);
}
};
// Get the stored token
let settings_manager = crate::settings_manager::SettingsManager::instance();
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
Ok(None) => {
log::warn!(
"[api] Rejected {path}: API server has no stored token (was the API toggled off?)"
);
return Err(StatusCode::UNAUTHORIZED);
}
Err(e) => {
log::error!("[api] Failed to read stored API token: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
// Compare tokens
if token != stored_token {
log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED);
}
@@ -482,6 +500,38 @@ async fn auth_middleware(
Ok(next.run(request).await)
}
/// Logs every request: method, path, query, response status, duration.
/// Skips Authorization header and request bodies entirely.
async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let query = request.uri().query().map(|q| q.to_string());
let started = std::time::Instant::now();
let response = next.run(request).await;
let status = response.status();
let elapsed_ms = started.elapsed().as_millis();
let level = if status.is_server_error() {
log::Level::Error
} else if status.is_client_error() {
log::Level::Warn
} else {
log::Level::Info
};
match query {
Some(q) => log::log!(
level,
"[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)"
),
None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"),
}
response
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
+1
View File
@@ -701,6 +701,7 @@ mod tests {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
}
}
+1
View File
@@ -1218,6 +1218,7 @@ mod tests {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
};
let path = profile.get_profile_data_path(&profiles_dir);
+17 -6
View File
@@ -291,8 +291,12 @@ impl BrowserRunner {
);
}
// Create ephemeral dir for ephemeral profiles
let override_profile_path = if profile.ephemeral {
// Create ephemeral dir for ephemeral or password-protected profiles
let override_profile_path = if profile.password_protected {
let dir = crate::profile::password::prepare_for_launch(profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir)
} else if profile.ephemeral {
let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir)
@@ -542,8 +546,11 @@ impl BrowserRunner {
);
}
// Create ephemeral dir for ephemeral profiles
if profile.ephemeral {
// Create ephemeral dir for ephemeral or password-protected profiles
if profile.password_protected {
crate::profile::password::prepare_for_launch(profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
} else if profile.ephemeral {
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
}
@@ -1431,7 +1438,9 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -1771,7 +1780,9 @@ impl BrowserRunner {
);
}
if profile.ephemeral {
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
+2 -1
View File
@@ -240,7 +240,7 @@ fn cleanup_legacy_dirs() {
}
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
if profile.ephemeral {
if profile.ephemeral || profile.password_protected {
if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
return dir;
}
@@ -279,6 +279,7 @@ mod tests {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
}
}
+43 -9
View File
@@ -72,6 +72,11 @@ use profile::manager::{
update_wayfern_config,
};
use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile,
};
use browser_version_manager::{
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_supported_browsers,
@@ -1133,6 +1138,7 @@ async fn generate_sample_fingerprint(
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
};
if browser == "camoufox" {
@@ -1344,18 +1350,31 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await;
});
// Auto-start MCP server if it was previously enabled
// Auto-start MCP server if it was previously enabled. Always log the
// decision so customer logs reveal whether MCP is actually running —
// "automation features don't work" is otherwise indistinguishable from
// "MCP server isn't enabled" without this line.
{
let mcp_handle = app.handle().clone();
let settings_mgr = settings_manager::SettingsManager::instance();
if let Ok(settings) = settings_mgr.load_settings() {
if settings.mcp_enabled {
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
});
match settings_mgr.load_settings() {
Ok(settings) => {
if settings.mcp_enabled {
log::info!("MCP server is enabled in settings, attempting auto-start");
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
});
} else {
log::info!(
"MCP server is DISABLED in settings (mcp_enabled=false). Browser automation tools will not be available until it's enabled in Settings → Integrations."
);
}
}
Err(e) => {
log::warn!("Could not read settings to determine MCP state: {e}");
}
}
}
@@ -1769,6 +1788,13 @@ 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);
} else {
// Update the state even if unchanged to ensure we have it tracked
@@ -2092,6 +2118,13 @@ pub fn run() {
// DNS blocklist commands
dns_blocklist::get_dns_blocklist_cache_status,
dns_blocklist::refresh_dns_blocklists,
// Profile password commands
set_profile_password,
change_profile_password,
remove_profile_password,
unlock_profile,
lock_profile,
is_profile_locked,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -2138,6 +2171,7 @@ mod tests {
"generate_sample_fingerprint",
"cloud_get_wayfern_token",
"cloud_refresh_wayfern_token",
"lock_profile",
];
// Extract command names from the generate_handler! macro in this file
+21
View File
@@ -112,6 +112,17 @@ impl McpServer {
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
// Log the failed gate so customer logs explain why an MCP tool returned
// an error. Include enough state (logged-in vs not, plan, status) for
// support to diagnose without leaking secrets.
let summary = match CLOUD_AUTH.get_user().await {
Some(state) => format!(
"logged_in=true plan={} status={} period={:?}",
state.user.plan, state.user.subscription_status, state.user.plan_period,
),
None => "logged_in=false".to_string(),
};
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
return Err(McpError {
code: -32000,
message: format!("{feature} requires an active paid subscription"),
@@ -1458,6 +1469,16 @@ impl McpServer {
.cloned()
.unwrap_or(serde_json::json!({}));
// Surface the call in logs so customer reports show which tools the MCP
// client is actually invoking (and therefore which gate any subsequent
// error came from). Log only the tool name and the profile_id arg —
// arbitrary URLs / JS / selectors can be sensitive.
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}");
match tool_name {
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).await,
+702
View File
@@ -0,0 +1,702 @@
//! Per-file encryption for password-protected profiles.
//!
//! Each on-disk file in `profiles/{uuid}/profile/` has:
//! - **Filename**: `urlsafe_no_pad(HMAC-SHA256(profile_key, plaintext_relpath))[..32]`.
//! Deterministic so cross-machine sync sees stable filenames; same plaintext
//! path with same key always produces the same on-disk name.
//! - **Content**: `nonce(12B) || AES-256-GCM(profile_key, path_len(2B-LE) || plaintext_path || file_bytes)`.
//! The plaintext relpath is encoded inside the ciphertext so a launch can
//! reconstruct the directory tree without a separate manifest.
//!
//! Wrong password fails the AES-GCM auth tag on the first decrypt, which
//! doubles as password verification.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ring::hmac;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use crate::sync::encryption::{decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt};
/// Length of the on-disk HMAC filename in chars.
const HMAC_FILENAME_LEN: usize = 32;
/// Marker file written into encrypted profile dirs so launch code can verify
/// the password before attempting to decrypt actual user data files.
const VERIFY_FILE_NAME: &str = ".donut-pw-verify";
const VERIFY_FILE_PATH: &str = "__donut_pw_verify__";
lazy_static::lazy_static! {
/// In-memory cache of derived per-profile encryption keys, keyed by profile UUID.
/// Only populated while a profile is unlocked / running. Never persisted.
static ref KEY_CACHE: Mutex<HashMap<uuid::Uuid, [u8; 32]>> = Mutex::new(HashMap::new());
}
#[derive(Debug, thiserror::Error)]
pub enum PasswordError {
#[error("io error: {0}")]
Io(String),
#[error("encryption error: {0}")]
Encryption(String),
#[error("invalid password")]
WrongPassword,
#[error("invalid file format")]
InvalidFormat,
}
pub type PasswordResult<T> = Result<T, PasswordError>;
impl From<std::io::Error> for PasswordError {
fn from(e: std::io::Error) -> Self {
PasswordError::Io(e.to_string())
}
}
/// Compute the HMAC-SHA256 derived on-disk filename for a plaintext relative path.
pub fn hmac_filename(key: &[u8; 32], plaintext_relpath: &str) -> String {
let signing_key = hmac::Key::new(hmac::HMAC_SHA256, key);
let tag = hmac::sign(&signing_key, plaintext_relpath.as_bytes());
let encoded = URL_SAFE_NO_PAD.encode(tag.as_ref());
encoded.chars().take(HMAC_FILENAME_LEN).collect()
}
/// Encrypt a single file's contents with its plaintext relative path embedded.
pub fn encrypt_profile_file(
key: &[u8; 32],
plaintext_relpath: &str,
file_bytes: &[u8],
) -> PasswordResult<Vec<u8>> {
let path_bytes = plaintext_relpath.as_bytes();
if path_bytes.len() > u16::MAX as usize {
return Err(PasswordError::Encryption("relpath too long".into()));
}
let mut plaintext = Vec::with_capacity(2 + path_bytes.len() + file_bytes.len());
plaintext.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
plaintext.extend_from_slice(path_bytes);
plaintext.extend_from_slice(file_bytes);
encrypt_bytes(key, &plaintext).map_err(PasswordError::Encryption)
}
/// Decrypt one file's bytes back into `(plaintext_relpath, file_bytes)`.
pub fn decrypt_profile_file(
key: &[u8; 32],
encrypted_bytes: &[u8],
) -> PasswordResult<(String, Vec<u8>)> {
let plaintext = decrypt_bytes(key, encrypted_bytes).map_err(|_| PasswordError::WrongPassword)?;
if plaintext.len() < 2 {
return Err(PasswordError::InvalidFormat);
}
let path_len = u16::from_le_bytes([plaintext[0], plaintext[1]]) as usize;
if plaintext.len() < 2 + path_len {
return Err(PasswordError::InvalidFormat);
}
let path = std::str::from_utf8(&plaintext[2..2 + path_len])
.map_err(|_| PasswordError::InvalidFormat)?
.to_string();
let content = plaintext[2 + path_len..].to_vec();
Ok((path, content))
}
fn build_excludes(patterns: &[&str]) -> GlobSet {
let mut builder = GlobSetBuilder::new();
for p in patterns {
if let Ok(g) = Glob::new(p) {
builder.add(g);
}
}
builder.build().unwrap_or_else(|_| GlobSet::empty())
}
fn walk_files(
base: &Path,
current: &Path,
excludes: &GlobSet,
out: &mut Vec<(String, PathBuf)>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
let relative = path
.strip_prefix(base)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
if excludes.is_match(&relative) {
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if metadata.is_dir() {
walk_files(base, &path, excludes, out)?;
} else if metadata.is_file() {
out.push((relative, path));
}
}
Ok(())
}
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("donut-tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)
}
fn write_verifier(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
let encrypted = encrypt_profile_file(key, VERIFY_FILE_PATH, b"donut-verify")?;
let path = encrypted_dir.join(VERIFY_FILE_NAME);
atomic_write(&path, &encrypted)?;
Ok(())
}
/// Verify a derived key against an encrypted profile dir. Returns Ok(()) on
/// success, `Err(WrongPassword)` if the password is wrong, or another error
/// for I/O / format problems.
pub fn verify_key_against_dir(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
let path = encrypted_dir.join(VERIFY_FILE_NAME);
if !path.exists() {
return Err(PasswordError::InvalidFormat);
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
if relpath != VERIFY_FILE_PATH || content != b"donut-verify" {
return Err(PasswordError::InvalidFormat);
}
Ok(())
}
/// Encrypt every file under `plaintext_dir` into `encrypted_dir`, replacing
/// it. Files matching `exclude_patterns` are dropped.
pub fn encrypt_profile_dir(
key: &[u8; 32],
plaintext_dir: &Path,
encrypted_dir: &Path,
exclude_patterns: &[&str],
) -> PasswordResult<()> {
if encrypted_dir.exists() {
std::fs::remove_dir_all(encrypted_dir)?;
}
std::fs::create_dir_all(encrypted_dir)?;
let excludes = build_excludes(exclude_patterns);
let mut files = Vec::new();
if plaintext_dir.exists() {
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut files)?;
}
for (relpath, abs) in files {
let bytes = std::fs::read(&abs)?;
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
atomic_write(&on_disk, &encrypted)?;
}
write_verifier(key, encrypted_dir)?;
Ok(())
}
/// Decrypt every file in `encrypted_dir` back into `plaintext_dir` (which is
/// created if missing). Returns the per-file mtimes captured after writing,
/// keyed by plaintext relpath. Caller can use them as the "before-launch"
/// snapshot to skip unchanged files on re-encrypt.
pub fn decrypt_profile_dir(
key: &[u8; 32],
encrypted_dir: &Path,
plaintext_dir: &Path,
) -> PasswordResult<HashMap<String, SystemTime>> {
std::fs::create_dir_all(plaintext_dir)?;
let mut mtimes = HashMap::new();
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
for entry in entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
let dest = plaintext_dir.join(&relpath);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, &content)?;
if let Ok(m) = dest.metadata().and_then(|m| m.modified()) {
mtimes.insert(relpath, m);
}
}
Ok(mtimes)
}
/// Re-encrypt the contents of `plaintext_dir` back into `encrypted_dir`,
/// preserving on-disk filenames for files whose plaintext content didn't
/// change. Returns the number of files re-encrypted.
///
/// `before_launch_mtimes` is the snapshot captured by `decrypt_profile_dir`.
/// Files whose mtime hasn't moved are left untouched on disk.
pub fn reencrypt_changed_files(
key: &[u8; 32],
plaintext_dir: &Path,
encrypted_dir: &Path,
exclude_patterns: &[&str],
before_launch_mtimes: &HashMap<String, SystemTime>,
) -> PasswordResult<usize> {
std::fs::create_dir_all(encrypted_dir)?;
let excludes = build_excludes(exclude_patterns);
let mut current_files = Vec::new();
if plaintext_dir.exists() {
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut current_files)?;
}
let mut current_paths: HashSet<String> = HashSet::new();
let mut rewrote = 0usize;
for (relpath, abs) in current_files {
current_paths.insert(relpath.clone());
let cur_mtime = abs.metadata().and_then(|m| m.modified()).ok();
let unchanged = match (cur_mtime, before_launch_mtimes.get(&relpath)) {
(Some(now), Some(before)) => now == *before,
_ => false,
};
if unchanged {
continue;
}
let bytes = std::fs::read(&abs)?;
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
atomic_write(&on_disk, &encrypted)?;
rewrote += 1;
}
// Delete on-disk files for plaintext paths that no longer exist
let valid_names: HashSet<String> = current_paths
.iter()
.map(|p| hmac_filename(key, p))
.collect();
for entry in std::fs::read_dir(encrypted_dir)?.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
if !valid_names.contains(&name) {
let _ = std::fs::remove_file(&path);
}
}
write_verifier(key, encrypted_dir)?;
Ok(rewrote)
}
/// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in
/// place. Used when changing a profile password without launching it.
pub fn rekey_profile_dir(
old_key: &[u8; 32],
new_key: &[u8; 32],
encrypted_dir: &Path,
) -> PasswordResult<()> {
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
let mut decrypted: Vec<(String, Vec<u8>)> = Vec::new();
for entry in &entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(old_key, &bytes)?;
decrypted.push((relpath, content));
}
// Decryption succeeded for every file; safe to rewrite the directory.
for entry in entries {
let path = entry.path();
if path.is_file() {
let _ = std::fs::remove_file(&path);
}
}
for (relpath, content) in decrypted {
let encrypted = encrypt_profile_file(new_key, &relpath, &content)?;
let on_disk = encrypted_dir.join(hmac_filename(new_key, &relpath));
atomic_write(&on_disk, &encrypted)?;
}
write_verifier(new_key, encrypted_dir)?;
Ok(())
}
// ---------- key cache ----------
pub fn cache_key(profile_id: uuid::Uuid, key: [u8; 32]) {
if let Ok(mut guard) = KEY_CACHE.lock() {
guard.insert(profile_id, key);
}
}
pub fn get_cached_key(profile_id: &uuid::Uuid) -> Option<[u8; 32]> {
KEY_CACHE.lock().ok()?.get(profile_id).copied()
}
pub fn drop_cached_key(profile_id: &uuid::Uuid) {
if let Ok(mut guard) = KEY_CACHE.lock() {
guard.remove(profile_id);
}
}
pub fn has_cached_key(profile_id: &uuid::Uuid) -> bool {
KEY_CACHE
.lock()
.map(|g| g.contains_key(profile_id))
.unwrap_or(false)
}
/// Convenience: derive + verify against the encrypted dir + cache the key on success.
pub fn unlock(
profile_id: uuid::Uuid,
password: &str,
salt: &str,
encrypted_dir: &Path,
) -> PasswordResult<()> {
let key = derive_profile_key(password, salt).map_err(PasswordError::Encryption)?;
verify_key_against_dir(&key, encrypted_dir)?;
cache_key(profile_id, key);
Ok(())
}
pub fn fresh_salt() -> String {
generate_salt()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_key() -> [u8; 32] {
derive_profile_key("hunter2", &generate_salt()).unwrap()
}
#[test]
fn test_hmac_filename_deterministic() {
let key = [7u8; 32];
let a = hmac_filename(&key, "Default/Cookies");
let b = hmac_filename(&key, "Default/Cookies");
assert_eq!(a, b);
assert_eq!(a.len(), HMAC_FILENAME_LEN);
}
#[test]
fn test_hmac_filename_different_keys() {
let a = hmac_filename(&[1u8; 32], "Default/Cookies");
let b = hmac_filename(&[2u8; 32], "Default/Cookies");
assert_ne!(a, b);
}
#[test]
fn test_hmac_filename_different_paths() {
let key = [1u8; 32];
let a = hmac_filename(&key, "Default/Cookies");
let b = hmac_filename(&key, "Default/Login Data");
assert_ne!(a, b);
}
#[test]
fn test_file_roundtrip() {
let key = make_key();
let original = b"hello world".to_vec();
let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap();
let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap();
assert_eq!(path, "Default/Cookies");
assert_eq!(content, original);
}
#[test]
fn test_file_wrong_key_fails() {
let key1 = make_key();
let key2 = make_key();
let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap();
assert!(matches!(
decrypt_profile_file(&key2, &encrypted),
Err(PasswordError::WrongPassword)
));
}
#[test]
fn test_file_truncated_ciphertext() {
let key = make_key();
let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap();
// Drop the auth tag
let truncated = &encrypted[..encrypted.len() - 1];
assert!(decrypt_profile_file(&key, truncated).is_err());
}
#[test]
fn test_dir_roundtrip() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(plain.join("Default")).unwrap();
std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap();
std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
std::fs::write(plain.join("Local State"), b"state").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
// No plaintext filenames on disk
let names: Vec<String> = std::fs::read_dir(&enc)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
for n in &names {
assert!(!n.contains("Cookies"), "plaintext leaked: {n}");
assert!(!n.contains("Bookmarks"));
assert!(!n.contains("Local State"));
}
// Verify file present
assert!(enc.join(VERIFY_FILE_NAME).exists());
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
assert_eq!(mtimes.len(), 3);
assert_eq!(
std::fs::read(restored.join("Default/Cookies")).unwrap(),
b"sqlite-data"
);
assert_eq!(
std::fs::read(restored.join("Default/Bookmarks")).unwrap(),
b"{\"x\":1}"
);
assert_eq!(
std::fs::read(restored.join("Local State")).unwrap(),
b"state"
);
}
#[test]
fn test_dir_excludes() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(plain.join("Default/Cache")).unwrap();
std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap();
std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap();
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
// Only Cookies (1 file) should be present, not Cache contents
assert_eq!(mtimes.len(), 1);
assert!(mtimes.contains_key("Default/Cookies"));
assert!(restored.join("Default/Cookies").exists());
assert!(!restored.join("Default/Cache/data").exists());
}
#[test]
fn test_verify_against_wrong_key() {
let key1 = make_key();
let key2 = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("file"), b"data").unwrap();
encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap();
assert!(verify_key_against_dir(&key1, &enc).is_ok());
assert!(matches!(
verify_key_against_dir(&key2, &enc),
Err(PasswordError::WrongPassword)
));
}
#[test]
fn test_reencrypt_skips_unchanged() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("a"), b"AAA").unwrap();
std::fs::write(plain.join("b"), b"BBB").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
// Capture pre-rewrite ciphertext bytes
let name_a = hmac_filename(&key, "a");
let name_b = hmac_filename(&key, "b");
let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap();
let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap();
// Modify only "a" in the restored tree
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap();
let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
assert_eq!(rewrote, 1);
let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap();
let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap();
assert_ne!(
cipher_a_before, cipher_a_after,
"changed file should have new ciphertext"
);
assert_eq!(
cipher_b_before, cipher_b_after,
"unchanged file should have stable ciphertext"
);
}
#[test]
fn test_reencrypt_handles_added_and_removed() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("keep"), b"k").unwrap();
std::fs::write(plain.join("delete"), b"d").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
std::fs::remove_file(restored.join("delete")).unwrap();
std::fs::write(restored.join("new"), b"n").unwrap();
reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
let names: HashSet<String> = std::fs::read_dir(&enc)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&hmac_filename(&key, "keep")));
assert!(names.contains(&hmac_filename(&key, "new")));
assert!(!names.contains(&hmac_filename(&key, "delete")));
assert!(names.contains(VERIFY_FILE_NAME));
}
#[test]
fn test_rekey_changes_filenames_and_content() {
let old = make_key();
let new = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("x"), b"data").unwrap();
encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap();
let old_name = hmac_filename(&old, "x");
let new_name = hmac_filename(&new, "x");
assert_ne!(old_name, new_name);
rekey_profile_dir(&old, &new, &enc).unwrap();
assert!(!enc.join(&old_name).exists());
assert!(enc.join(&new_name).exists());
verify_key_against_dir(&new, &enc).unwrap();
assert!(matches!(
verify_key_against_dir(&old, &enc),
Err(PasswordError::WrongPassword)
));
let restored = work.path().join("restored");
decrypt_profile_dir(&new, &enc, &restored).unwrap();
assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data");
}
#[test]
fn test_atomic_write_leaves_original_intact_if_tmp_lingers() {
let work = TempDir::new().unwrap();
let target = work.path().join("file");
std::fs::write(&target, b"original").unwrap();
// Simulate a stale tmp from a crashed write
std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap();
// A successful write should overwrite the original even when stale tmp exists
atomic_write(&target, b"new").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"new");
}
#[test]
fn test_key_cache_lifecycle() {
let id = uuid::Uuid::new_v4();
assert!(!has_cached_key(&id));
cache_key(id, [9u8; 32]);
assert!(has_cached_key(&id));
assert_eq!(get_cached_key(&id), Some([9u8; 32]));
drop_cached_key(&id);
assert!(!has_cached_key(&id));
}
#[test]
fn test_unlock_helper() {
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("x"), b"data").unwrap();
let salt = generate_salt();
let key = derive_profile_key("correct horse", &salt).unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let id = uuid::Uuid::new_v4();
drop_cached_key(&id);
assert!(unlock(id, "wrong", &salt, &enc).is_err());
assert!(!has_cached_key(&id));
assert!(unlock(id, "correct horse", &salt, &enc).is_ok());
assert!(has_cached_key(&id));
drop_cached_key(&id);
}
}
+4
View File
@@ -184,6 +184,7 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
};
match self
@@ -285,6 +286,7 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
};
match self
@@ -340,6 +342,7 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist,
password_protected: false,
};
// Save profile info
@@ -987,6 +990,7 @@ impl ProfileManager {
created_by_id: None,
created_by_email: None,
dns_blocklist: source.dns_blocklist,
password_protected: false,
};
self.save_profile(&new_profile)?;
+2
View File
@@ -1,4 +1,6 @@
pub mod encryption;
pub mod manager;
pub mod password;
pub mod types;
pub use manager::ProfileManager;
File diff suppressed because it is too large Load Diff
+4
View File
@@ -69,6 +69,10 @@ pub struct BrowserProfile {
pub created_by_email: Option<String>,
#[serde(default)]
pub dns_blocklist: Option<String>,
/// True when the on-disk profile dir is encrypted with a per-profile password.
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)]
pub password_protected: bool,
}
pub fn default_release_type() -> String {
+3
View File
@@ -584,6 +584,7 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
};
match self
@@ -664,6 +665,7 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
};
match self
@@ -715,6 +717,7 @@ impl ProfileImporter {
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
};
self.profile_manager.save_profile(&profile)?;
+7
View File
@@ -57,6 +57,11 @@ pub struct AppSettings {
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub disable_auto_updates: bool,
/// When true, the decrypted in-RAM copy of a password-protected profile is
/// preserved between launches for faster subsequent startups. The on-disk
/// copy is always re-encrypted regardless of this flag.
#[serde(default)]
pub keep_decrypted_profiles_in_ram: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -92,6 +97,7 @@ impl Default for AppSettings {
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
}
}
}
@@ -1070,6 +1076,7 @@ mod tests {
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
};
let save_result = manager.save_settings(&test_settings);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.22.7",
"version": "0.23.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+84
View File
@@ -24,6 +24,10 @@ import { IntegrationsDialog } from "@/components/integrations-dialog";
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
type PasswordDialogMode,
ProfilePasswordDialog,
} from "@/components/profile-password-dialog";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
@@ -47,6 +51,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import {
dismissToast,
showErrorToast,
@@ -183,6 +188,11 @@ export default function Home() {
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
useState<BrowserProfile | null>(null);
const [cloneProfile, setCloneProfile] = useState<BrowserProfile | null>(null);
const [passwordDialogProfile, setPasswordDialogProfile] =
useState<BrowserProfile | null>(null);
const [passwordDialogMode, setPasswordDialogMode] =
useState<PasswordDialogMode>("set");
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
@@ -532,6 +542,7 @@ export default function Home() {
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
password?: string;
}) => {
try {
const profile = await invoke<BrowserProfile>(
@@ -565,6 +576,21 @@ export default function Home() {
}
}
if (profileData.password && !profileData.ephemeral) {
try {
await invoke("set_profile_password", {
profileId: profile.id,
password: profileData.password,
});
} catch (err) {
showErrorToast(
t("errors.setProfilePasswordFailed", {
error: translateBackendError(t, err),
}),
);
}
}
// No need to manually reload - useProfileEvents will handle the update
} catch (error) {
showErrorToast(
@@ -581,6 +607,23 @@ export default function Home() {
async (profile: BrowserProfile) => {
console.log("Starting launch for profile:", profile.name);
// Password-protected: must be unlocked before launch
if (profile.password_protected) {
try {
const isLocked = await invoke<boolean>("is_profile_locked", {
profileId: profile.id,
});
if (isLocked) {
pendingLaunchAfterUnlockRef.current = profile;
setPasswordDialogMode("unlock");
setPasswordDialogProfile(profile);
return;
}
} catch (err) {
console.error("Failed to check profile lock state:", err);
}
}
// Show one-time warning about window resizing for fingerprinted browsers
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
try {
@@ -623,6 +666,24 @@ export default function Home() {
setCloneProfile(profile);
}, []);
const handleSetPassword = useCallback((profile: BrowserProfile) => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogMode("set");
setPasswordDialogProfile(profile);
}, []);
const handleChangePassword = useCallback((profile: BrowserProfile) => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogMode("change");
setPasswordDialogProfile(profile);
}, []);
const handleRemovePassword = useCallback((profile: BrowserProfile) => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogMode("remove");
setPasswordDialogProfile(profile);
}, []);
const handleDeleteProfile = useCallback(
async (profile: BrowserProfile) => {
console.log("Attempting to delete profile:", profile.name);
@@ -1110,6 +1171,9 @@ export default function Home() {
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
onSetPassword={handleSetPassword}
onChangePassword={handleChangePassword}
onRemovePassword={handleRemovePassword}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
@@ -1215,6 +1279,26 @@ export default function Home() {
profile={cloneProfile}
/>
<ProfilePasswordDialog
isOpen={!!passwordDialogProfile}
onClose={() => {
pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogProfile(null);
}}
profile={passwordDialogProfile}
mode={passwordDialogMode}
onSuccess={(p) => {
if (
passwordDialogMode === "unlock" &&
pendingLaunchAfterUnlockRef.current?.id === p.id
) {
const target = pendingLaunchAfterUnlockRef.current;
pendingLaunchAfterUnlockRef.current = null;
void launchProfile(target);
}
}}
/>
<CamoufoxConfigDialog
isOpen={camoufoxConfigDialogOpen}
onClose={() => {
+93 -3
View File
@@ -86,6 +86,7 @@ interface CreateProfileDialogProps {
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
password?: string;
}) => Promise<void>;
selectedGroupId?: string;
crossOsUnlocked?: boolean;
@@ -170,6 +171,11 @@ export function CreateProfileDialog({
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [enablePassword, setEnablePassword] = useState(false);
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [passwordError, setPasswordError] = useState<string | null>(null);
const PASSWORD_MIN_LEN = 8;
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
useState<string>();
const [extensionGroups, setExtensionGroups] = useState<
@@ -370,12 +376,30 @@ export function CreateProfileDialog({
const handleCreate = async () => {
if (!profileName.trim()) return;
if (enablePassword && !ephemeral) {
if (password.length < PASSWORD_MIN_LEN) {
setPasswordError(
t("profilePassword.errors.tooShort", { min: PASSWORD_MIN_LEN }),
);
return;
}
if (password !== passwordConfirm) {
setPasswordError(t("profilePassword.errors.mismatch"));
return;
}
}
setPasswordError(null);
setIsCreating(true);
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
: undefined;
try {
if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected
@@ -403,6 +427,7 @@ export function CreateProfileDialog({
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
} else {
// Default to Camoufox
@@ -430,6 +455,7 @@ export function CreateProfileDialog({
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
}
} else {
@@ -455,6 +481,7 @@ export function CreateProfileDialog({
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
}
@@ -488,6 +515,10 @@ export function CreateProfileDialog({
os: getCurrentOS() as WayfernOS, // Reset to current OS
});
setEphemeral(false);
setEnablePassword(false);
setPassword("");
setPasswordConfirm("");
setPasswordError(null);
onClose();
};
@@ -709,15 +740,74 @@ export function CreateProfileDialog({
<Label htmlFor="ephemeral" className="font-medium">
{t("profiles.ephemeral")}
</Label>
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
{t("profiles.ephemeralAlpha")}
</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("profiles.ephemeralDescription")}
</p>
</div>
{/* Password Option */}
{!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-password"
checked={enablePassword}
onCheckedChange={(checked) => {
setEnablePassword(checked === true);
if (checked !== true) {
setPassword("");
setPasswordConfirm("");
setPasswordError(null);
}
}}
/>
<Label
htmlFor="enable-password"
className="font-medium"
>
{t("createProfile.passwordProtect.label")}
</Label>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("createProfile.passwordProtect.description")}
</p>
{enablePassword && (
<div className="ml-6 space-y-2">
<Input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setPasswordError(null);
}}
placeholder={t(
"profilePassword.fields.newPassword",
)}
autoComplete="new-password"
/>
<Input
type="password"
value={passwordConfirm}
onChange={(e) => {
setPasswordConfirm(e.target.value);
setPasswordError(null);
}}
placeholder={t(
"profilePassword.fields.confirm",
)}
autoComplete="new-password"
/>
{passwordError && (
<p className="text-sm text-destructive">
{passwordError}
</p>
)}
</div>
)}
</div>
)}
{selectedBrowser === "wayfern" ? (
// Wayfern Configuration
<div className="space-y-6">
+12 -1
View File
@@ -854,6 +854,9 @@ interface ProfilesDataTableProps {
}
| undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void;
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
}
export function ProfilesDataTable({
@@ -883,6 +886,9 @@ export function ProfilesDataTable({
syncUnlocked = false,
getProfileSyncInfo,
onLaunchWithSync,
onSetPassword,
onChangePassword,
onRemovePassword,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -1695,7 +1701,9 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const browser = profile.browser;
const IconComponent = getProfileIcon(profile);
const IconComponent = profile.password_protected
? LuLock
: getProfileIcon(profile);
const isCrossOs = isCrossOsProfile(profile);
const isSelected = meta.isProfileSelected(profile.id);
@@ -2732,6 +2740,9 @@ export function ProfilesDataTable({
}}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onSetPassword={onSetPassword}
onChangePassword={onChangePassword}
onRemovePassword={onRemovePassword}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
setProfileToDelete(profile);
+49
View File
@@ -13,7 +13,10 @@ import {
LuFingerprint,
LuGlobe,
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
LuRefreshCw,
@@ -71,6 +74,9 @@ interface ProfileInfoDialogProps {
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
isRunning?: boolean;
isDisabled?: boolean;
@@ -119,6 +125,9 @@ export function ProfileInfoDialog({
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
onSetPassword,
onChangePassword,
onRemovePassword,
crossOsUnlocked = false,
isRunning = false,
isDisabled = false,
@@ -354,6 +363,40 @@ export function ProfileInfoDialog({
},
hidden: !onOpenLaunchHook,
},
{
icon: <LuKey className="w-4 h-4" />,
label: t("profiles.actions.setPassword"),
onClick: () => {
handleAction(() => onSetPassword?.(profile));
},
disabled: isDisabled || isRunning,
runningBadge: isRunning,
hidden:
profile.password_protected === true ||
profile.ephemeral === true ||
!onSetPassword,
},
{
icon: <LuKey className="w-4 h-4" />,
label: t("profiles.actions.changePassword"),
onClick: () => {
handleAction(() => onChangePassword?.(profile));
},
disabled: isDisabled || isRunning,
runningBadge: isRunning,
hidden: profile.password_protected !== true || !onChangePassword,
},
{
icon: <LuLockOpen className="w-4 h-4" />,
label: t("profiles.actions.removePassword"),
onClick: () => {
handleAction(() => onRemovePassword?.(profile));
},
disabled: isDisabled || isRunning,
runningBadge: isRunning,
hidden: profile.password_protected !== true || !onRemovePassword,
destructive: true,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
@@ -417,6 +460,12 @@ export function ProfileInfoDialog({
{t("profiles.ephemeralBadge")}
</Badge>
)}
{profile.password_protected && (
<Badge variant="outline" className="text-xs gap-1">
<LuLock className="w-3 h-3" />
{t("profiles.passwordProtectedBadge")}
</Badge>
)}
{showCrossOs && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
extractLockoutSeconds,
formatLockoutDuration,
translateBackendError,
} from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { BrowserProfile } from "@/types";
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
export type PasswordDialogMode = "set" | "unlock" | "change" | "remove";
interface ProfilePasswordDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
mode: PasswordDialogMode;
onSuccess?: (profile: BrowserProfile) => void;
}
const MIN_LEN = 8;
export function ProfilePasswordDialog({
isOpen,
onClose,
profile,
mode,
onSuccess,
}: ProfilePasswordDialogProps) {
const { t } = useTranslation();
const [oldPassword, setOldPassword] = React.useState("");
const [password, setPassword] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [lockoutSecondsRemaining, setLockoutSecondsRemaining] = React.useState<
number | null
>(null);
const firstInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen) {
setOldPassword("");
setPassword("");
setConfirm("");
setIsSubmitting(false);
setLockoutSecondsRemaining(null);
setTimeout(() => firstInputRef.current?.focus(), 0);
}
}, [isOpen]);
// Tick down the lockout timer
React.useEffect(() => {
if (lockoutSecondsRemaining == null) return;
if (lockoutSecondsRemaining <= 0) {
setLockoutSecondsRemaining(null);
return;
}
const handle = window.setTimeout(() => {
setLockoutSecondsRemaining((prev) => (prev == null ? null : prev - 1));
}, 1000);
return () => {
window.clearTimeout(handle);
};
}, [lockoutSecondsRemaining]);
if (!profile) return null;
const needsConfirm = mode === "set" || mode === "change";
const needsOldPassword = mode === "change" || mode === "remove";
const validate = (): string | null => {
if (needsOldPassword && !oldPassword) {
return t("profilePassword.errors.oldPasswordRequired");
}
if (mode === "set" || mode === "change") {
if (password.length < MIN_LEN) {
return t("profilePassword.errors.tooShort", { min: MIN_LEN });
}
if (password !== confirm) {
return t("profilePassword.errors.mismatch");
}
}
if (mode === "unlock" && !password) {
return t("profilePassword.errors.passwordRequired");
}
if (mode === "remove" && !oldPassword) {
return t("profilePassword.errors.passwordRequired");
}
return null;
};
const handleSubmit = async () => {
if (isSubmitting || lockoutSecondsRemaining != null) return;
const error = validate();
if (error) {
showErrorToast(error);
return;
}
setIsSubmitting(true);
try {
switch (mode) {
case "set":
await invoke("set_profile_password", {
profileId: profile.id,
password,
});
showSuccessToast(t("profilePassword.toasts.set"));
break;
case "unlock":
await invoke("unlock_profile", {
profileId: profile.id,
password,
});
break;
case "change":
await invoke("change_profile_password", {
profileId: profile.id,
oldPassword,
newPassword: password,
});
showSuccessToast(t("profilePassword.toasts.changed"));
break;
case "remove":
await invoke("remove_profile_password", {
profileId: profile.id,
password: oldPassword,
});
showSuccessToast(t("profilePassword.toasts.removed"));
break;
}
onSuccess?.(profile);
onClose();
} catch (err: unknown) {
const lockoutSeconds = extractLockoutSeconds(err);
if (lockoutSeconds != null) {
setLockoutSecondsRemaining(lockoutSeconds);
} else {
showErrorToast(translateBackendError(t, err));
}
} finally {
setIsSubmitting(false);
}
};
const titleKey =
mode === "set"
? "profilePassword.set.title"
: mode === "unlock"
? "profilePassword.unlock.title"
: mode === "change"
? "profilePassword.change.title"
: "profilePassword.remove.title";
const descriptionKey =
mode === "set"
? "profilePassword.set.description"
: mode === "unlock"
? "profilePassword.unlock.description"
: mode === "change"
? "profilePassword.change.description"
: "profilePassword.remove.description";
const submitLabelKey =
mode === "set"
? "profilePassword.set.button"
: mode === "unlock"
? "profilePassword.unlock.button"
: mode === "change"
? "profilePassword.change.button"
: "profilePassword.remove.button";
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t(titleKey)}</DialogTitle>
<DialogDescription>
{t(descriptionKey, { name: profile.name })}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{(mode === "set" || mode === "change") && (
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
<p className="font-medium text-warning-foreground">
{t("profilePassword.warnings.forgetWarningTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("profilePassword.warnings.forgetWarningBody")}
</p>
</div>
)}
{lockoutSecondsRemaining != null && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{t("backendErrors.lockedOut", {
duration: formatLockoutDuration(t, lockoutSecondsRemaining),
})}
</div>
)}
{needsOldPassword && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="profile-pw-old">
{mode === "remove"
? t("profilePassword.fields.password")
: t("profilePassword.fields.currentPassword")}
</Label>
<Input
ref={firstInputRef}
id="profile-pw-old"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSubmit();
}}
disabled={isSubmitting}
autoComplete="current-password"
/>
</div>
)}
{(mode === "set" || mode === "change" || mode === "unlock") && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="profile-pw-new">
{mode === "unlock"
? t("profilePassword.fields.password")
: t("profilePassword.fields.newPassword")}
</Label>
<Input
ref={!needsOldPassword ? firstInputRef : undefined}
id="profile-pw-new"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSubmit();
}}
disabled={isSubmitting}
autoComplete={
mode === "unlock" ? "current-password" : "new-password"
}
/>
</div>
)}
{needsConfirm && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="profile-pw-confirm">
{t("profilePassword.fields.confirm")}
</Label>
<Input
id="profile-pw-confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSubmit();
}}
disabled={isSubmitting}
autoComplete="new-password"
/>
</div>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
onClick={() => void handleSubmit()}
isLoading={isSubmitting}
disabled={lockoutSecondsRemaining != null}
variant={mode === "remove" ? "destructive" : "default"}
>
{t(submitLabelKey)}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+25
View File
@@ -63,6 +63,7 @@ interface AppSettings {
api_port: number;
api_token?: string;
disable_auto_updates?: boolean;
keep_decrypted_profiles_in_ram?: boolean;
}
interface CustomThemeState {
@@ -1129,6 +1130,30 @@ export function SettingsDialog({
</div>
)}
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<Checkbox
id="keep-decrypted-profiles-in-ram"
checked={settings.keep_decrypted_profiles_in_ram ?? false}
onCheckedChange={(checked) => {
updateSetting(
"keep_decrypted_profiles_in_ram",
checked as boolean,
);
}}
/>
<div className="space-y-1">
<Label
htmlFor="keep-decrypted-profiles-in-ram"
className="text-sm font-medium"
>
{t("settings.keepDecryptedProfilesInRam")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings.keepDecryptedProfilesInRamDescription")}
</p>
</div>
</div>
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
+78 -7
View File
@@ -172,7 +172,9 @@
"clearCacheFailed": "Failed to clear cache"
},
"disableAutoUpdates": "Disable App Auto Updates",
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected."
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless."
},
"header": {
"searchPlaceholder": "Search profiles...",
@@ -221,7 +223,10 @@
"assignToGroup": "Assign to Group",
"changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile",
"launchHook": "Launch Hook URL"
"launchHook": "Launch Hook URL",
"setPassword": "Set Password",
"changePassword": "Change Password",
"removePassword": "Remove Password"
},
"synchronizer": {
"launchWithSync": "Launch with Synchronizer",
@@ -240,9 +245,8 @@
"flakyTooltip": "This profile has a different screen resolution than the leader. Page layouts may differ, causing clicks and interactions to hit the wrong elements."
},
"ephemeral": "Ephemeral",
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Note that your operating system can swap parts of memory to disk under load, so traces of the session may still be recoverable.",
"ephemeralBadge": "Ephemeral",
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Delete Selected Profiles",
"description": "This action cannot be undone. This will permanently delete {{count}} profile(s) and all associated data.",
@@ -265,7 +269,8 @@
"assignProxy": "Assign Proxy",
"assignExtensionGroup": "Assign Extension Group",
"copyCookies": "Copy Cookies"
}
},
"passwordProtectedBadge": "Password Protected"
},
"createProfile": {
"title": "Create New Profile",
@@ -312,7 +317,11 @@
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Powered by Camoufox",
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium.",
"platformUnavailable": "{{browser}} is not available on your platform yet."
"platformUnavailable": "{{browser}} is not available on your platform yet.",
"passwordProtect": {
"label": "Password protect this profile",
"description": "Encrypts the on-disk profile data. Required to launch."
}
},
"deleteDialog": {
"title": "Delete Profile",
@@ -892,7 +901,8 @@
"setupProxyListenersFailed": "Failed to setup proxy event listeners: {{error}}",
"loadVpnConfigsFailed": "Failed to load VPN configs: {{error}}",
"setupVpnListenersFailed": "Failed to setup VPN event listeners: {{error}}",
"themeNotFound": "Tokyo Night theme not found"
"themeNotFound": "Tokyo Night theme not found",
"setProfilePasswordFailed": "Failed to set profile password: {{error}}"
},
"browser": {
"camoufox": "Camoufox",
@@ -1589,5 +1599,66 @@
"upToDateDescription": "All browser versions are up to date",
"updateAllFailed": "Failed to update browser versions"
}
},
"profilePassword": {
"set": {
"title": "Set Profile Password",
"description": "Encrypt the on-disk data for {{name}}. You will need this password every time you launch the profile.",
"button": "Encrypt Profile"
},
"unlock": {
"title": "Unlock Profile",
"description": "Enter the password to unlock {{name}}.",
"button": "Unlock"
},
"change": {
"title": "Change Profile Password",
"description": "Re-encrypt {{name}} with a new password.",
"button": "Change Password"
},
"remove": {
"title": "Remove Profile Password",
"description": "Decrypt the on-disk data for {{name}}. The profile will no longer be password protected.",
"button": "Remove Password"
},
"fields": {
"password": "Password",
"currentPassword": "Current password",
"newPassword": "New password",
"confirm": "Confirm password"
},
"errors": {
"oldPasswordRequired": "Current password is required",
"passwordRequired": "Password is required",
"tooShort": "Password must be at least {{min}} characters",
"mismatch": "Passwords do not match"
},
"toasts": {
"set": "Profile is now password protected",
"changed": "Profile password changed",
"removed": "Profile password removed"
},
"warnings": {
"forgetWarningTitle": "Important: this password is not recoverable",
"forgetWarningBody": "Donut Browser cannot reset, recover, or bypass this password. If you forget it, you will permanently lose access to this profile's data."
}
},
"backendErrors": {
"incorrectPassword": "Incorrect password",
"lockedOut": "Too many incorrect attempts. Try again in {{duration}}.",
"lockedOutDuration": {
"seconds": "{{seconds}}s",
"minutes": "{{minutes}} min",
"hours": "{{hours}} h"
},
"profileNotFound": "Profile not found",
"profileNotProtected": "Profile is not password protected",
"profileAlreadyProtected": "Profile is already password protected",
"profileRunning": "Cannot perform this action while the profile is running",
"profileMissingSalt": "Profile is missing its encryption salt",
"profileLocked": "Profile is locked. Enter the password first.",
"invalidProfileId": "Invalid profile id",
"passwordTooShort": "Password must be at least {{min}} characters",
"internal": "Something went wrong: {{detail}}"
}
}
+78 -7
View File
@@ -172,7 +172,9 @@
"clearCacheFailed": "Error al limpiar la caché"
},
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas."
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.",
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso."
},
"header": {
"searchPlaceholder": "Buscar perfiles...",
@@ -221,7 +223,10 @@
"assignToGroup": "Asignar a Grupo",
"changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil",
"launchHook": "URL del hook de inicio"
"launchHook": "URL del hook de inicio",
"setPassword": "Establecer Contraseña",
"changePassword": "Cambiar Contraseña",
"removePassword": "Quitar Contraseña"
},
"synchronizer": {
"launchWithSync": "Lanzar con Sincronizador",
@@ -240,9 +245,8 @@
"flakyTooltip": "Este perfil tiene una resolución de pantalla diferente a la del líder. El diseño de las páginas puede variar, lo que puede causar que los clics e interacciones fallen."
},
"ephemeral": "Efímero",
"ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.",
"ephemeralDescription": "El navegador se ve obligado a escribir los datos del perfil en memoria en lugar de en el disco. Ten en cuenta que tu sistema operativo puede pasar partes de la memoria al disco cuando hay poca RAM, por lo que aún podrían quedar rastros de la sesión recuperables.",
"ephemeralBadge": "Efímero",
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Eliminar perfiles seleccionados",
"description": "Esta acción no se puede deshacer. Eliminará permanentemente {{count}} perfil(es) y todos los datos asociados.",
@@ -265,7 +269,8 @@
"assignProxy": "Asignar proxy",
"assignExtensionGroup": "Asignar grupo de extensiones",
"copyCookies": "Copiar cookies"
}
},
"passwordProtectedBadge": "Protegido por Contraseña"
},
"createProfile": {
"title": "Crear Nuevo Perfil",
@@ -312,7 +317,11 @@
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Impulsado por Camoufox",
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium.",
"platformUnavailable": "{{browser}} aún no está disponible en tu plataforma."
"platformUnavailable": "{{browser}} aún no está disponible en tu plataforma.",
"passwordProtect": {
"label": "Proteger este perfil con contraseña",
"description": "Cifra los datos del perfil en disco. Necesario para abrirlo."
}
},
"deleteDialog": {
"title": "Eliminar Perfil",
@@ -892,7 +901,8 @@
"setupProxyListenersFailed": "Error al configurar los listeners de eventos de proxies: {{error}}",
"loadVpnConfigsFailed": "Error al cargar las configuraciones de VPN: {{error}}",
"setupVpnListenersFailed": "Error al configurar los listeners de eventos de VPN: {{error}}",
"themeNotFound": "Tema Tokyo Night no encontrado"
"themeNotFound": "Tema Tokyo Night no encontrado",
"setProfilePasswordFailed": "Error al establecer la contraseña del perfil: {{error}}"
},
"browser": {
"camoufox": "Camoufox",
@@ -1589,5 +1599,66 @@
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
"updateAllFailed": "Error al actualizar las versiones del navegador"
}
},
"profilePassword": {
"set": {
"title": "Establecer Contraseña del Perfil",
"description": "Cifra los datos en disco de {{name}}. Necesitarás esta contraseña cada vez que abras el perfil.",
"button": "Cifrar Perfil"
},
"unlock": {
"title": "Desbloquear Perfil",
"description": "Introduce la contraseña para desbloquear {{name}}.",
"button": "Desbloquear"
},
"change": {
"title": "Cambiar Contraseña del Perfil",
"description": "Vuelve a cifrar {{name}} con una nueva contraseña.",
"button": "Cambiar Contraseña"
},
"remove": {
"title": "Quitar Contraseña del Perfil",
"description": "Descifra los datos en disco de {{name}}. El perfil dejará de estar protegido por contraseña.",
"button": "Quitar Contraseña"
},
"fields": {
"password": "Contraseña",
"currentPassword": "Contraseña actual",
"newPassword": "Nueva contraseña",
"confirm": "Confirmar contraseña"
},
"errors": {
"oldPasswordRequired": "Se requiere la contraseña actual",
"passwordRequired": "Se requiere la contraseña",
"tooShort": "La contraseña debe tener al menos {{min}} caracteres",
"mismatch": "Las contraseñas no coinciden"
},
"toasts": {
"set": "El perfil ahora está protegido por contraseña",
"changed": "Contraseña del perfil cambiada",
"removed": "Contraseña del perfil eliminada"
},
"warnings": {
"forgetWarningTitle": "Importante: esta contraseña no se puede recuperar",
"forgetWarningBody": "Donut Browser no puede restablecer, recuperar ni omitir esta contraseña. Si la olvidas, perderás permanentemente el acceso a los datos de este perfil."
}
},
"backendErrors": {
"incorrectPassword": "Contraseña incorrecta",
"lockedOut": "Demasiados intentos incorrectos. Vuelve a intentar en {{duration}}.",
"lockedOutDuration": {
"seconds": "{{seconds}}s",
"minutes": "{{minutes}} min",
"hours": "{{hours}} h"
},
"profileNotFound": "Perfil no encontrado",
"profileNotProtected": "El perfil no está protegido por contraseña",
"profileAlreadyProtected": "El perfil ya está protegido por contraseña",
"profileRunning": "No se puede realizar esta acción mientras el perfil está en ejecución",
"profileMissingSalt": "Al perfil le falta su sal de cifrado",
"profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
"invalidProfileId": "ID de perfil no válido",
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
"internal": "Algo salió mal: {{detail}}"
}
}
+78 -7
View File
@@ -172,7 +172,9 @@
"clearCacheFailed": "Échec de la suppression du cache"
},
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées."
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.",
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas."
},
"header": {
"searchPlaceholder": "Rechercher des profils...",
@@ -221,7 +223,10 @@
"assignToGroup": "Assigner au Groupe",
"changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil",
"launchHook": "URL du hook de lancement"
"launchHook": "URL du hook de lancement",
"setPassword": "Définir un mot de passe",
"changePassword": "Changer le mot de passe",
"removePassword": "Supprimer le mot de passe"
},
"synchronizer": {
"launchWithSync": "Lancer avec le synchroniseur",
@@ -240,9 +245,8 @@
"flakyTooltip": "Ce profil a une résolution d'écran différente de celle du leader. La mise en page des pages peut différer, ce qui peut causer des clics et interactions erronés."
},
"ephemeral": "Éphémère",
"ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.",
"ephemeralDescription": "Le navigateur est contraint d'écrire les données du profil en mémoire plutôt que sur le disque. Notez que votre système d'exploitation peut écrire une partie de la mémoire sur le disque (swap) en cas de charge, donc des traces de la session pourraient rester récupérables.",
"ephemeralBadge": "Éphémère",
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Supprimer les profils sélectionnés",
"description": "Cette action est irréversible. Elle supprimera définitivement {{count}} profil(s) et toutes les données associées.",
@@ -265,7 +269,8 @@
"assignProxy": "Assigner un proxy",
"assignExtensionGroup": "Assigner un groupe dextensions",
"copyCookies": "Copier les cookies"
}
},
"passwordProtectedBadge": "Protégé par mot de passe"
},
"createProfile": {
"title": "Créer un nouveau profil",
@@ -312,7 +317,11 @@
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Propulsé par Camoufox",
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium.",
"platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme."
"platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme.",
"passwordProtect": {
"label": "Protéger ce profil par mot de passe",
"description": "Chiffre les données du profil sur disque. Requis au lancement."
}
},
"deleteDialog": {
"title": "Supprimer le profil",
@@ -892,7 +901,8 @@
"setupProxyListenersFailed": "Échec de la configuration des écouteurs d’événements de proxies : {{error}}",
"loadVpnConfigsFailed": "Échec du chargement des configurations VPN : {{error}}",
"setupVpnListenersFailed": "Échec de la configuration des écouteurs d’événements VPN : {{error}}",
"themeNotFound": "Thème Tokyo Night introuvable"
"themeNotFound": "Thème Tokyo Night introuvable",
"setProfilePasswordFailed": "Échec de la définition du mot de passe du profil : {{error}}"
},
"browser": {
"camoufox": "Camoufox",
@@ -1589,5 +1599,66 @@
"upToDateDescription": "Toutes les versions des navigateurs sont à jour",
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs"
}
},
"profilePassword": {
"set": {
"title": "Définir un mot de passe de profil",
"description": "Chiffre les données sur disque de {{name}}. Vous devrez saisir ce mot de passe à chaque lancement du profil.",
"button": "Chiffrer le profil"
},
"unlock": {
"title": "Déverrouiller le profil",
"description": "Saisissez le mot de passe pour déverrouiller {{name}}.",
"button": "Déverrouiller"
},
"change": {
"title": "Changer le mot de passe du profil",
"description": "Re-chiffre {{name}} avec un nouveau mot de passe.",
"button": "Changer le mot de passe"
},
"remove": {
"title": "Supprimer le mot de passe du profil",
"description": "Déchiffre les données sur disque de {{name}}. Le profil ne sera plus protégé par mot de passe.",
"button": "Supprimer le mot de passe"
},
"fields": {
"password": "Mot de passe",
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"confirm": "Confirmer le mot de passe"
},
"errors": {
"oldPasswordRequired": "Le mot de passe actuel est requis",
"passwordRequired": "Le mot de passe est requis",
"tooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
"mismatch": "Les mots de passe ne correspondent pas"
},
"toasts": {
"set": "Le profil est maintenant protégé par mot de passe",
"changed": "Mot de passe du profil modifié",
"removed": "Mot de passe du profil supprimé"
},
"warnings": {
"forgetWarningTitle": "Important : ce mot de passe ne peut pas être récupéré",
"forgetWarningBody": "Donut Browser ne peut ni réinitialiser, ni récupérer, ni contourner ce mot de passe. Si vous l'oubliez, vous perdrez définitivement l'accès aux données de ce profil."
}
},
"backendErrors": {
"incorrectPassword": "Mot de passe incorrect",
"lockedOut": "Trop de tentatives incorrectes. Réessayez dans {{duration}}.",
"lockedOutDuration": {
"seconds": "{{seconds}}s",
"minutes": "{{minutes}} min",
"hours": "{{hours}} h"
},
"profileNotFound": "Profil introuvable",
"profileNotProtected": "Le profil n'est pas protégé par mot de passe",
"profileAlreadyProtected": "Le profil est déjà protégé par mot de passe",
"profileRunning": "Impossible d'effectuer cette action pendant que le profil est en cours d'exécution",
"profileMissingSalt": "Le sel de chiffrement du profil est manquant",
"profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
"invalidProfileId": "Identifiant de profil non valide",
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
"internal": "Une erreur s'est produite : {{detail}}"
}
}
+78 -7
View File
@@ -172,7 +172,9 @@
"clearCacheFailed": "キャッシュのクリアに失敗しました"
},
"disableAutoUpdates": "アプリの自動更新を無効にする",
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。"
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。"
},
"header": {
"searchPlaceholder": "プロファイルを検索...",
@@ -221,7 +223,10 @@
"assignToGroup": "グループに割り当て",
"changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー",
"launchHook": "起動フックURL"
"launchHook": "起動フックURL",
"setPassword": "パスワードを設定",
"changePassword": "パスワードを変更",
"removePassword": "パスワードを削除"
},
"synchronizer": {
"launchWithSync": "シンクロナイザーで起動",
@@ -240,9 +245,8 @@
"flakyTooltip": "このプロフィールはリーダーと画面解像度が異なります。ページレイアウトが異なる可能性があり、クリックや操作が正しく動作しない場合があります。"
},
"ephemeral": "一時的",
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。",
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ただし、OSはメモリ不足時にメモリの一部をディスクにスワップすることがあるため、セッションの痕跡が復元可能な状態で残る場合があります。",
"ephemeralBadge": "一時的",
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "選択したプロファイルを削除",
"description": "この操作は取り消せません。{{count}} 個のプロファイルと関連するすべてのデータが永久に削除されます。",
@@ -265,7 +269,8 @@
"assignProxy": "プロキシを割り当て",
"assignExtensionGroup": "拡張機能グループを割り当て",
"copyCookies": "Cookieをコピー"
}
},
"passwordProtectedBadge": "パスワード保護"
},
"createProfile": {
"title": "新しいプロファイルを作成",
@@ -312,7 +317,11 @@
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Camoufox搭載",
"camoufoxWarning": "FirefoxCamoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。",
"platformUnavailable": "{{browser}} はまだお使いのプラットフォームで利用できません。"
"platformUnavailable": "{{browser}} はまだお使いのプラットフォームで利用できません。",
"passwordProtect": {
"label": "このプロファイルをパスワードで保護",
"description": "ディスク上のプロファイルデータを暗号化します。起動に必要です。"
}
},
"deleteDialog": {
"title": "プロファイルを削除",
@@ -892,7 +901,8 @@
"setupProxyListenersFailed": "プロキシイベントリスナーの設定に失敗しました: {{error}}",
"loadVpnConfigsFailed": "VPN設定の読み込みに失敗しました: {{error}}",
"setupVpnListenersFailed": "VPNイベントリスナーの設定に失敗しました: {{error}}",
"themeNotFound": "Tokyo Night テーマが見つかりません"
"themeNotFound": "Tokyo Night テーマが見つかりません",
"setProfilePasswordFailed": "プロファイルのパスワード設定に失敗しました: {{error}}"
},
"browser": {
"camoufox": "Camoufox",
@@ -1589,5 +1599,66 @@
"upToDateDescription": "すべてのブラウザバージョンは最新です",
"updateAllFailed": "ブラウザバージョンの更新に失敗しました"
}
},
"profilePassword": {
"set": {
"title": "プロファイルにパスワードを設定",
"description": "{{name}} のディスク上のデータを暗号化します。プロファイルを起動するたびにこのパスワードが必要になります。",
"button": "プロファイルを暗号化"
},
"unlock": {
"title": "プロファイルを解除",
"description": "{{name}} を解除するためのパスワードを入力してください。",
"button": "解除"
},
"change": {
"title": "プロファイルのパスワードを変更",
"description": "新しいパスワードで {{name}} を再暗号化します。",
"button": "パスワードを変更"
},
"remove": {
"title": "プロファイルのパスワードを削除",
"description": "{{name}} のディスク上のデータを復号します。プロファイルはパスワード保護されなくなります。",
"button": "パスワードを削除"
},
"fields": {
"password": "パスワード",
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"confirm": "パスワードの確認"
},
"errors": {
"oldPasswordRequired": "現在のパスワードが必要です",
"passwordRequired": "パスワードが必要です",
"tooShort": "パスワードは {{min}} 文字以上必要です",
"mismatch": "パスワードが一致しません"
},
"toasts": {
"set": "プロファイルがパスワードで保護されました",
"changed": "プロファイルのパスワードを変更しました",
"removed": "プロファイルのパスワードを削除しました"
},
"warnings": {
"forgetWarningTitle": "重要: このパスワードは復元できません",
"forgetWarningBody": "Donut Browserはこのパスワードをリセット、復元、回避することはできません。忘れた場合、このプロファイルのデータへのアクセスは永続的に失われます。"
}
},
"backendErrors": {
"incorrectPassword": "パスワードが正しくありません",
"lockedOut": "失敗回数が多すぎます。{{duration}}後に再試行してください。",
"lockedOutDuration": {
"seconds": "{{seconds}}秒",
"minutes": "{{minutes}}分",
"hours": "{{hours}}時間"
},
"profileNotFound": "プロファイルが見つかりません",
"profileNotProtected": "プロファイルはパスワード保護されていません",
"profileAlreadyProtected": "プロファイルはすでにパスワード保護されています",
"profileRunning": "プロファイルの実行中はこの操作を実行できません",
"profileMissingSalt": "プロファイルに暗号化ソルトがありません",
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
"invalidProfileId": "無効なプロファイルIDです",
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
"internal": "問題が発生しました: {{detail}}"
}
}
+78 -7
View File
@@ -172,7 +172,9 @@
"clearCacheFailed": "Falha ao limpar o cache"
},
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas."
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.",
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso."
},
"header": {
"searchPlaceholder": "Pesquisar perfis...",
@@ -221,7 +223,10 @@
"assignToGroup": "Atribuir ao Grupo",
"changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil",
"launchHook": "URL do hook de inicialização"
"launchHook": "URL do hook de inicialização",
"setPassword": "Definir Senha",
"changePassword": "Alterar Senha",
"removePassword": "Remover Senha"
},
"synchronizer": {
"launchWithSync": "Iniciar com Sincronizador",
@@ -240,9 +245,8 @@
"flakyTooltip": "Este perfil tem uma resolução de tela diferente do líder. O layout das páginas pode variar, fazendo com que cliques e interações atinjam elementos errados."
},
"ephemeral": "Efêmero",
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Os dados são excluídos ao fechar o navegador.",
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Lembre-se de que o sistema operacional pode passar partes da memória para o disco (swap) sob carga, então rastros da sessão ainda podem ser recuperáveis.",
"ephemeralBadge": "Efêmero",
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Excluir perfis selecionados",
"description": "Esta ação não pode ser desfeita. Excluirá permanentemente {{count}} perfil(is) e todos os dados associados.",
@@ -265,7 +269,8 @@
"assignProxy": "Atribuir proxy",
"assignExtensionGroup": "Atribuir grupo de extensões",
"copyCookies": "Copiar cookies"
}
},
"passwordProtectedBadge": "Protegido por Senha"
},
"createProfile": {
"title": "Criar Novo Perfil",
@@ -312,7 +317,11 @@
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Desenvolvido com Camoufox",
"camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium.",
"platformUnavailable": "{{browser}} ainda não está disponível para sua plataforma."
"platformUnavailable": "{{browser}} ainda não está disponível para sua plataforma.",
"passwordProtect": {
"label": "Proteger este perfil com senha",
"description": "Criptografa os dados do perfil em disco. Necessário para iniciar."
}
},
"deleteDialog": {
"title": "Excluir Perfil",
@@ -892,7 +901,8 @@
"setupProxyListenersFailed": "Falha ao configurar os listeners de eventos de proxies: {{error}}",
"loadVpnConfigsFailed": "Falha ao carregar as configurações de VPN: {{error}}",
"setupVpnListenersFailed": "Falha ao configurar os listeners de eventos de VPN: {{error}}",
"themeNotFound": "Tema Tokyo Night não encontrado"
"themeNotFound": "Tema Tokyo Night não encontrado",
"setProfilePasswordFailed": "Falha ao definir a senha do perfil: {{error}}"
},
"browser": {
"camoufox": "Camoufox",
@@ -1589,5 +1599,66 @@
"upToDateDescription": "Todas as versões dos navegadores estão atualizadas",
"updateAllFailed": "Falha ao atualizar as versões dos navegadores"
}
},
"profilePassword": {
"set": {
"title": "Definir Senha do Perfil",
"description": "Criptografa os dados em disco de {{name}}. Você precisará desta senha sempre que abrir o perfil.",
"button": "Criptografar Perfil"
},
"unlock": {
"title": "Desbloquear Perfil",
"description": "Digite a senha para desbloquear {{name}}.",
"button": "Desbloquear"
},
"change": {
"title": "Alterar Senha do Perfil",
"description": "Recriptografe {{name}} com uma nova senha.",
"button": "Alterar Senha"
},
"remove": {
"title": "Remover Senha do Perfil",
"description": "Descriptografa os dados em disco de {{name}}. O perfil deixará de estar protegido por senha.",
"button": "Remover Senha"
},
"fields": {
"password": "Senha",
"currentPassword": "Senha atual",
"newPassword": "Nova senha",
"confirm": "Confirmar senha"
},
"errors": {
"oldPasswordRequired": "A senha atual é obrigatória",
"passwordRequired": "A senha é obrigatória",
"tooShort": "A senha deve ter pelo menos {{min}} caracteres",
"mismatch": "As senhas não coincidem"
},
"toasts": {
"set": "O perfil agora está protegido por senha",
"changed": "Senha do perfil alterada",
"removed": "Senha do perfil removida"
},
"warnings": {
"forgetWarningTitle": "Importante: esta senha não pode ser recuperada",
"forgetWarningBody": "O Donut Browser não pode redefinir, recuperar ou contornar esta senha. Se você esquecê-la, perderá permanentemente o acesso aos dados deste perfil."
}
},
"backendErrors": {
"incorrectPassword": "Senha incorreta",
"lockedOut": "Tentativas incorretas demais. Tente novamente em {{duration}}.",
"lockedOutDuration": {
"seconds": "{{seconds}}s",
"minutes": "{{minutes}} min",
"hours": "{{hours}} h"
},
"profileNotFound": "Perfil não encontrado",
"profileNotProtected": "O perfil não está protegido por senha",
"profileAlreadyProtected": "O perfil já está protegido por senha",
"profileRunning": "Não é possível realizar esta ação enquanto o perfil está em execução",
"profileMissingSalt": "O perfil está sem o sal de criptografia",
"profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
"invalidProfileId": "ID de perfil inválido",
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
"internal": "Algo deu errado: {{detail}}"
}
}
+78 -7
View File
@@ -172,7 +172,9 @@
"clearCacheFailed": "Не удалось очистить кэш"
},
"disableAutoUpdates": "Отключить автообновление приложения",
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются."
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной."
},
"header": {
"searchPlaceholder": "Поиск профилей...",
@@ -221,7 +223,10 @@
"assignToGroup": "Назначить группе",
"changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль",
"launchHook": "URL хука запуска"
"launchHook": "URL хука запуска",
"setPassword": "Установить пароль",
"changePassword": "Изменить пароль",
"removePassword": "Удалить пароль"
},
"synchronizer": {
"launchWithSync": "Запустить с синхронизатором",
@@ -240,9 +245,8 @@
"flakyTooltip": "У этого профиля разрешение экрана отличается от лидера. Макет страниц может отличаться, что может привести к неправильным кликам и взаимодействиям."
},
"ephemeral": "Временный",
"ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.",
"ephemeralDescription": "Браузер вынужденно записывает данные профиля в память, а не на диск. Учтите, что операционная система может выгружать части памяти на диск (swap) при нехватке ОЗУ, поэтому следы сессии всё же могут оказаться восстановимыми.",
"ephemeralBadge": "Временный",
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Удалить выбранные профили",
"description": "Это действие нельзя отменить. Будет навсегда удалено {{count}} профил(ей) и все связанные данные.",
@@ -265,7 +269,8 @@
"assignProxy": "Назначить прокси",
"assignExtensionGroup": "Назначить группу расширений",
"copyCookies": "Копировать cookies"
}
},
"passwordProtectedBadge": "Защищено паролем"
},
"createProfile": {
"title": "Создать новый профиль",
@@ -312,7 +317,11 @@
"firefoxLabel": "Firefox",
"firefoxSubtitle": "На базе Camoufox",
"camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium.",
"platformUnavailable": "{{browser}} пока недоступен на вашей платформе."
"platformUnavailable": "{{browser}} пока недоступен на вашей платформе.",
"passwordProtect": {
"label": "Защитить этот профиль паролем",
"description": "Шифрует данные профиля на диске. Требуется для запуска."
}
},
"deleteDialog": {
"title": "Удалить профиль",
@@ -892,7 +901,8 @@
"setupProxyListenersFailed": "Не удалось настроить слушатели событий прокси: {{error}}",
"loadVpnConfigsFailed": "Не удалось загрузить конфигурации VPN: {{error}}",
"setupVpnListenersFailed": "Не удалось настроить слушатели событий VPN: {{error}}",
"themeNotFound": "Тема Tokyo Night не найдена"
"themeNotFound": "Тема Tokyo Night не найдена",
"setProfilePasswordFailed": "Не удалось установить пароль профиля: {{error}}"
},
"browser": {
"camoufox": "Camoufox",
@@ -1589,5 +1599,66 @@
"upToDateDescription": "Все версии браузеров актуальны",
"updateAllFailed": "Не удалось обновить версии браузеров"
}
},
"profilePassword": {
"set": {
"title": "Установить пароль профиля",
"description": "Шифрует данные {{name}} на диске. Этот пароль потребуется при каждом запуске профиля.",
"button": "Зашифровать профиль"
},
"unlock": {
"title": "Разблокировать профиль",
"description": "Введите пароль, чтобы разблокировать {{name}}.",
"button": "Разблокировать"
},
"change": {
"title": "Изменить пароль профиля",
"description": "Перезашифровать {{name}} с новым паролем.",
"button": "Изменить пароль"
},
"remove": {
"title": "Удалить пароль профиля",
"description": "Расшифровывает данные {{name}} на диске. Профиль больше не будет защищён паролем.",
"button": "Удалить пароль"
},
"fields": {
"password": "Пароль",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirm": "Подтвердите пароль"
},
"errors": {
"oldPasswordRequired": "Требуется текущий пароль",
"passwordRequired": "Требуется пароль",
"tooShort": "Пароль должен быть не короче {{min}} символов",
"mismatch": "Пароли не совпадают"
},
"toasts": {
"set": "Профиль защищён паролем",
"changed": "Пароль профиля изменён",
"removed": "Пароль профиля удалён"
},
"warnings": {
"forgetWarningTitle": "Важно: пароль восстановить нельзя",
"forgetWarningBody": "Donut Browser не может сбросить, восстановить или обойти этот пароль. Если вы его забудете, доступ к данным этого профиля будет утрачен навсегда."
}
},
"backendErrors": {
"incorrectPassword": "Неверный пароль",
"lockedOut": "Слишком много неудачных попыток. Повторите через {{duration}}.",
"lockedOutDuration": {
"seconds": "{{seconds}}с",
"minutes": "{{minutes}} мин",
"hours": "{{hours}} ч"
},
"profileNotFound": "Профиль не найден",
"profileNotProtected": "Профиль не защищён паролем",
"profileAlreadyProtected": "Профиль уже защищён паролем",
"profileRunning": "Невозможно выполнить это действие, пока профиль запущен",
"profileMissingSalt": "У профиля отсутствует соль шифрования",
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
"invalidProfileId": "Недействительный идентификатор профиля",
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
"internal": "Что-то пошло не так: {{detail}}"
}
}
+78 -7
View File
@@ -172,7 +172,9 @@
"clearCacheFailed": "清除缓存失败"
},
"disableAutoUpdates": "禁用应用自动更新",
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。"
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。"
},
"header": {
"searchPlaceholder": "搜索配置文件...",
@@ -221,7 +223,10 @@
"assignToGroup": "分配到组",
"changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件",
"launchHook": "启动钩子 URL"
"launchHook": "启动钩子 URL",
"setPassword": "设置密码",
"changePassword": "更改密码",
"removePassword": "移除密码"
},
"synchronizer": {
"launchWithSync": "使用同步器启动",
@@ -240,9 +245,8 @@
"flakyTooltip": "此配置文件的屏幕分辨率与领导者不同。页面布局可能不同,导致点击和交互可能命中错误的元素。"
},
"ephemeral": "临时",
"ephemeralDescription": "浏览器被强制将配置数据写入内存而磁盘。关闭浏览器时数据将被删除。",
"ephemeralDescription": "浏览器被强制将配置文件数据写入内存而不是磁盘。请注意,在系统负载较高时,操作系统可能会将部分内存换出到磁盘(swap),因此会话的某些痕迹仍可能被恢复。",
"ephemeralBadge": "临时",
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "删除所选配置文件",
"description": "此操作无法撤销。这将永久删除 {{count}} 个配置文件及其关联的所有数据。",
@@ -265,7 +269,8 @@
"assignProxy": "分配代理",
"assignExtensionGroup": "分配扩展分组",
"copyCookies": "复制 Cookie"
}
},
"passwordProtectedBadge": "密码保护"
},
"createProfile": {
"title": "创建新配置文件",
@@ -312,7 +317,11 @@
"firefoxLabel": "Firefox",
"firefoxSubtitle": "由 Camoufox 驱动",
"camoufoxWarning": "FirefoxCamoufox)由第三方组织维护。在生产环境中,请使用 Chromium。",
"platformUnavailable": "{{browser}} 在您的平台上尚不可用。"
"platformUnavailable": "{{browser}} 在您的平台上尚不可用。",
"passwordProtect": {
"label": "为此配置文件设置密码保护",
"description": "加密磁盘上的配置文件数据。启动时需要密码。"
}
},
"deleteDialog": {
"title": "删除配置文件",
@@ -892,7 +901,8 @@
"setupProxyListenersFailed": "设置代理事件监听器失败: {{error}}",
"loadVpnConfigsFailed": "加载 VPN 配置失败: {{error}}",
"setupVpnListenersFailed": "设置 VPN 事件监听器失败: {{error}}",
"themeNotFound": "未找到 Tokyo Night 主题"
"themeNotFound": "未找到 Tokyo Night 主题",
"setProfilePasswordFailed": "设置配置文件密码失败: {{error}}"
},
"browser": {
"camoufox": "Camoufox",
@@ -1589,5 +1599,66 @@
"upToDateDescription": "所有浏览器版本都是最新的",
"updateAllFailed": "更新浏览器版本失败"
}
},
"profilePassword": {
"set": {
"title": "设置配置文件密码",
"description": "加密 {{name}} 的磁盘数据。每次启动配置文件时都需要输入此密码。",
"button": "加密配置文件"
},
"unlock": {
"title": "解锁配置文件",
"description": "输入密码以解锁 {{name}}。",
"button": "解锁"
},
"change": {
"title": "更改配置文件密码",
"description": "使用新密码重新加密 {{name}}。",
"button": "更改密码"
},
"remove": {
"title": "移除配置文件密码",
"description": "解密 {{name}} 的磁盘数据。配置文件将不再受密码保护。",
"button": "移除密码"
},
"fields": {
"password": "密码",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirm": "确认密码"
},
"errors": {
"oldPasswordRequired": "需要当前密码",
"passwordRequired": "需要密码",
"tooShort": "密码至少需要 {{min}} 个字符",
"mismatch": "密码不匹配"
},
"toasts": {
"set": "配置文件现在受密码保护",
"changed": "配置文件密码已更改",
"removed": "配置文件密码已移除"
},
"warnings": {
"forgetWarningTitle": "重要:此密码无法恢复",
"forgetWarningBody": "Donut Browser 无法重置、恢复或绕过此密码。如果忘记,您将永久无法访问此配置文件的数据。"
}
},
"backendErrors": {
"incorrectPassword": "密码不正确",
"lockedOut": "尝试次数过多。请在 {{duration}} 后重试。",
"lockedOutDuration": {
"seconds": "{{seconds}}秒",
"minutes": "{{minutes}} 分钟",
"hours": "{{hours}} 小时"
},
"profileNotFound": "未找到配置文件",
"profileNotProtected": "配置文件未受密码保护",
"profileAlreadyProtected": "配置文件已受密码保护",
"profileRunning": "配置文件运行时无法执行此操作",
"profileMissingSalt": "配置文件缺少加密盐",
"profileLocked": "配置文件已锁定。请先输入密码。",
"invalidProfileId": "配置文件 ID 无效",
"passwordTooShort": "密码至少需要 {{min}} 个字符",
"internal": "出现问题:{{detail}}"
}
}
+121
View File
@@ -0,0 +1,121 @@
import type { TFunction } from "i18next";
/**
* Backend error codes returned from Rust Tauri commands.
* Keep this list in sync with the codes used in `src-tauri/src/profile/password.rs`.
*/
export type BackendErrorCode =
| "INCORRECT_PASSWORD"
| "LOCKED_OUT"
| "PROFILE_NOT_FOUND"
| "PROFILE_NOT_PROTECTED"
| "PROFILE_ALREADY_PROTECTED"
| "PROFILE_RUNNING"
| "PROFILE_MISSING_SALT"
| "PROFILE_LOCKED"
| "INVALID_PROFILE_ID"
| "PASSWORD_TOO_SHORT"
| "INTERNAL_ERROR";
export interface BackendError {
code: BackendErrorCode;
params?: Record<string, string>;
}
/**
* Try to parse a backend error string as a structured `{code, params}` payload.
* Returns null if the string isn't structured (e.g. raw error from a command
* that doesn't yet emit codes caller should fall back to showing the raw text).
*/
export function parseBackendError(err: unknown): BackendError | null {
const message = err instanceof Error ? err.message : String(err);
if (!message.startsWith("{")) return null;
try {
const parsed = JSON.parse(message);
if (
parsed &&
typeof parsed === "object" &&
typeof parsed.code === "string"
) {
return parsed as BackendError;
}
} catch {
// not JSON
}
return null;
}
/**
* Translate a backend error to a localized string. Falls back to the raw
* message if the error isn't a structured backend error.
*/
export function translateBackendError(t: TFunction, err: unknown): string {
const parsed = parseBackendError(err);
if (!parsed) {
return err instanceof Error ? err.message : String(err);
}
switch (parsed.code) {
case "INCORRECT_PASSWORD":
return t("backendErrors.incorrectPassword");
case "LOCKED_OUT": {
const seconds = Number.parseInt(parsed.params?.seconds ?? "0", 10);
return t("backendErrors.lockedOut", {
duration: formatLockoutDuration(t, seconds),
});
}
case "PROFILE_NOT_FOUND":
return t("backendErrors.profileNotFound");
case "PROFILE_NOT_PROTECTED":
return t("backendErrors.profileNotProtected");
case "PROFILE_ALREADY_PROTECTED":
return t("backendErrors.profileAlreadyProtected");
case "PROFILE_RUNNING":
return t("backendErrors.profileRunning");
case "PROFILE_MISSING_SALT":
return t("backendErrors.profileMissingSalt");
case "PROFILE_LOCKED":
return t("backendErrors.profileLocked");
case "INVALID_PROFILE_ID":
return t("backendErrors.invalidProfileId");
case "PASSWORD_TOO_SHORT": {
const min = Number.parseInt(parsed.params?.min ?? "8", 10);
return t("backendErrors.passwordTooShort", { min });
}
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
});
default:
return err instanceof Error ? err.message : String(err);
}
}
export function formatLockoutDuration(t: TFunction, seconds: number): string {
if (seconds < 60)
return t("backendErrors.lockedOutDuration.seconds", { seconds });
const minutes = Math.ceil(seconds / 60);
if (minutes < 60)
return t("backendErrors.lockedOutDuration.minutes", { minutes });
const hours = Math.ceil(minutes / 60);
return t("backendErrors.lockedOutDuration.hours", { hours });
}
/**
* Extract the lockout countdown in seconds from a backend error, or null.
*/
export function extractLockoutSeconds(err: unknown): number | null {
const parsed = parseBackendError(err);
if (parsed?.code !== "LOCKED_OUT") return null;
const secs = Number.parseInt(parsed.params?.seconds ?? "0", 10);
return Number.isFinite(secs) && secs > 0 ? secs : null;
}
/**
* True if the error is a known structured backend error code.
*/
export function isBackendErrorCode(
err: unknown,
code: BackendErrorCode,
): boolean {
return parseBackendError(err)?.code === code;
}
+1
View File
@@ -37,6 +37,7 @@ export interface BrowserProfile {
created_by_id?: string;
created_by_email?: string;
dns_blocklist?: string;
password_protected?: boolean;
}
export interface Extension {