Compare commits

..

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

Declare libxdo3 (Debian/Ubuntu) and libxdo (Fedora/openSUSE) explicitly
in bundle.linux.{deb,rpm}.depends so package managers pull the library
during install.
2026-04-29 10:49:33 -03:00
github-actions[bot] ef1dc3407f chore: update flake.nix for v0.22.4 [skip ci] (#324)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-28 21:24:36 +00:00
github-actions[bot] 1162f1e9f3 docs: update CHANGELOG.md and README.md for v0.22.4 [skip ci] (#323)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-28 21:24:17 +00:00
zhom 8d524e07f4 chore: version bump 2026-04-28 23:55:15 +04:00
zhom f8ce56481f chore: i18n 2026-04-28 23:50:56 +04:00
github-actions[bot] 97d01e4b54 chore: update flake.nix for v0.22.3 [skip ci] (#321)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 19:59:16 +00:00
github-actions[bot] 5980ce5e8d docs: update CHANGELOG.md and README.md for v0.22.3 [skip ci] (#320)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 19:58:56 +00:00
zhom 4cfbcde3de chore: version bump 2026-04-27 22:24:40 +04:00
zhom c9ae34f225 fix: correct browser port mapping 2026-04-27 22:24:40 +04:00
github-actions[bot] 0b30939b8f chore: update flake.nix for v0.22.2 [skip ci] (#315)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 03:57:48 +00:00
github-actions[bot] 3e99bffe06 docs: update CHANGELOG.md and README.md for v0.22.2 [skip ci] (#314)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 03:57:35 +00:00
zhom 37da41da6c chore: version bump 2026-04-27 06:20:31 +04:00
zhom b5a8a23b55 refactor: cookie management 2026-04-27 06:11:50 +04:00
github-actions[bot] 32888a90b3 chore: update flake.nix for v0.22.1 [skip ci] (#313)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 01:31:19 +00:00
github-actions[bot] 50bf6a0ea1 docs: update CHANGELOG.md and README.md for v0.22.1 [skip ci] (#312)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 01:30:59 +00:00
119 changed files with 13072 additions and 3293 deletions
+8
View File
@@ -1,4 +1,12 @@
# macOS code signing + notarization for `pnpm tauri build`.
# Loaded into the build environment via scripts/run-with-env.mjs (and direnv via .envrc).
# APPLE_SIGNING_IDENTITY: the exact name of your Developer ID Application
# certificate as it appears in `security find-identity -v -p codesigning`.
# Example: "Developer ID Application: Your Name (TEAMID)"
# APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID: credentials for notarytool.
# APPLE_PASSWORD must be an app-specific password from appleid.apple.com,
# not your real Apple ID password.
APPLE_TEAM_ID= APPLE_TEAM_ID=
APPLE_ID= APPLE_ID=
APPLE_PASSWORD= APPLE_PASSWORD=
+4
View File
@@ -1 +1,5 @@
use flake use flake
# Load .env on top of the flake's environment so APPLE_SIGNING_IDENTITY,
# APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. are available to `tauri build`
# and any other tools spawned from this directory.
dotenv_if_exists .env
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3 uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with: with:
run_install: false run_install: false
+349 -69
View File
@@ -16,6 +16,11 @@ permissions:
pull-requests: write pull-requests: write
id-token: write id-token: write
env:
# Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: anthropic/claude-opus-4.7
COMPOSER_MODEL: anthropic/claude-opus-4.7
jobs: jobs:
analyze-issue: analyze-issue:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues' if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
@@ -40,42 +45,207 @@ jobs:
echo "is_first_time=false" >> $GITHUB_OUTPUT echo "is_first_time=false" >> $GITHUB_OUTPUT
fi fi
- name: Build repo context and find related files - name: Parse issue template fields
env:
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
node <<'EOF'
const fs = require('node:fs');
const body = process.env.ISSUE_BODY || '';
// GitHub issue templates render fields as `### Heading\nValue` blocks.
// Split on `###` at line start to recover them.
const fields = {};
const sections = body.split(/^###\s+/m);
for (const section of sections.slice(1)) {
const nl = section.indexOf('\n');
if (nl < 0) continue;
const heading = section.slice(0, nl).trim();
const value = section.slice(nl + 1).trim();
fields[heading] = value === '_No response_' ? '' : value;
}
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
// Convenience extractions for the prompt — empty string if missing.
const get = (k) => fields[k] || '';
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?'));
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
EOF
echo "Parsed fields:"
cat /tmp/issue-fields.json
- name: Build repo context
env: env:
ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_BODY: ${{ github.event.issue.body }}
run: | run: |
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt cp CLAUDE.md /tmp/repo-context.txt
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
# List all source files for the AI to pick from # List all source files for the AI to choose from
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \ find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \ ! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \ ! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
| sed 's|^\./||' | sort > /tmp/all-source-files.txt | sed 's|^\./||' | sort > /tmp/all-source-files.txt
- name: Select relevant files with AI - name: Write shared knowledge files (scope + pricing)
run: |
cat > /tmp/scope-and-pricing.md <<'EOF'
# PROJECT SCOPE
- **Donut Browser** — this repo. A Tauri desktop launcher (Rust + Next.js) that
downloads, manages, and launches anti-detect browser profiles. In-scope for bug
reports about profile management, downloads, sync, proxy, VPN, the launcher UI,
its API, MCP server, and the bundled `donut-sync` self-hosted server.
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
bugs are in-scope here unless they are obviously upstream Chromium issues.
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
contribute to Camoufox and CANNOT fix bugs in it.
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
https://github.com/daijro/camoufox/issues.
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
in-scope here.
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
supported. Feature requests asking for them are out of scope.
# PAID vs FREE FEATURES
Source: donutbrowser.com pricing tiers (verbatim from translations).
## Free (no account required)
- Unlimited local profiles
- Chromium (Wayfern) and Firefox (Camoufox) browser engines
- Proxy support (HTTP/SOCKS5)
- VPN support (WireGuard)
- Profile Management API & MCP (list / create / launch / kill / config)
- Cookie & Extension Management
- Set as default browser
- **Profile sync IS FREE if the user self-hosts the `donut-sync` server**
## Pro ($16/mo) — adds:
- Browser Manipulation API & MCP (`type_text`, `click_element`,
`evaluate_javascript`, `screenshot`, `navigate`, etc.)
- Cross-OS fingerprinting (e.g. macOS user appearing as Windows)
- Profile Synchronizer for Wayfern
- 20 cloud profile backup (cloud sync via donutbrowser.com)
- Commercial use license
## Team ($80/mo) — adds:
- 100 cloud profile sync
- Team collaboration, profile sharing, unlimited seats
# ANTI-PATTERNS
- **Regression**: user explicitly mentions a previous version that worked
differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
which exact version was the last working one and what changed.
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
behavior. Redirect, do not collect logs.
- **Fork-support request**: asks the maintainer to support an alternative
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
"clear", "reasonable", "well-thought-out", etc.
- **AI-generated / template-violating report**: report doesn't follow the
template, may cite "official documentation" via context7, deepwiki, or any
non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative
sources are this GitHub repo and donutbrowser.com.
- **Speculation about internals**: never write a "Possible cause" / "Likely
cause" / "Root cause" section. Never cite internal file paths or line
numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
- 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: env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: | run: |
# The triage call returns ONLY JSON. It classifies the issue and picks a
# short list of source files for the composer to read.
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \
--arg model "$TRIAGE_MODEL" \
--rawfile system_prompt /tmp/triage-system.txt \
--rawfile title /tmp/issue-title.txt \ --rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \ --rawfile body /tmp/issue-body.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile files /tmp/all-source-files.txt \ --rawfile files /tmp/all-source-files.txt \
'{ '{
model: "anthropic/claude-opus-4.6", model: $model,
messages: [ messages: [
{ { role: "system", content: $system_prompt },
role: "system", { role: "user",
content: "You are a file selector for Donut Browser (Tauri + Next.js + Rust anti-detect browser). Given an issue and a list of source files, output ONLY the 10 most likely relevant file paths, one per line. No explanations, no numbering, just paths." content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
},
{
role: "user",
content: ("Issue: " + $title + "\n\n" + $body + "\n\nFiles:\n" + $files)
}
] ]
}') }')
@@ -84,65 +254,167 @@ jobs:
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$PAYLOAD") -d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/selected-files.txt jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
# Read the selected files in full (skip binary files) # Strip ```json fences if the model couldn't help itself.
echo "" > /tmp/file-contents.txt sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
while IFS= read -r filepath; do
# Validate; if the model returned junk, fall back to a minimal stub so the
# composer still gets called and produces SOMETHING.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification"
cat /tmp/triage-raw.txt
jq -n '{
language: "en",
classification: "bug-in-scope",
operating_system: "unknown",
is_paid_feature: false,
user_followed_template: true,
regression_signal: null,
user_cited_external_docs: null,
files_to_read: [],
notes: "triage call failed; defaulting"
}' > /tmp/triage.json
fi
echo "Triage result:"
cat /tmp/triage.json
- name: Read files chosen by triage
run: |
: > /tmp/file-context.txt
# files_to_read may be empty (e.g. upstream Camoufox) — that's fine.
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
filepath=$(echo "$filepath" | xargs) filepath=$(echo "$filepath" | xargs)
[ -z "$filepath" ] && continue [ -z "$filepath" ] && continue
# Reject paths that escape the repo or look fishy
case "$filepath" in
/*|*..*|*$'\n'*) continue ;;
esac
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath ===" >> /tmp/file-contents.txt echo "=== $filepath ===" >> /tmp/file-context.txt
cat "$filepath" >> /tmp/file-contents.txt cat "$filepath" >> /tmp/file-context.txt
echo "" >> /tmp/file-contents.txt echo "" >> /tmp/file-context.txt
fi fi
done < /tmp/selected-files.txt done
# Cap total context at 100 KB to keep token cost bounded.
head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt
mv /tmp/file-context.capped.txt /tmp/file-context.txt
wc -c /tmp/file-context.txt
# Cap total context at 100KB - name: Build composer system prompt
head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt run: |
# Same reason as the triage prompt: lots of apostrophes, no shell-quoting
# gymnastics. Build it to a file, load via --rawfile.
{
cat <<'COMPOSER_HEAD'
You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.
- name: Analyze issue with AI COMPOSER_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'COMPOSER_TAIL'
# RULES — STRICT
## Output shape
- One sentence acknowledging the report.
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.
- Maximum 15 lines.
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
- No closing pleasantries ("please let me know", "happy to help", etc.).
## Forbidden — never do these
- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
- NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
- NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
- NEVER list more than one OS log path. Use ONLY the path matching the user's reported OS. If OS is unknown, ask for it instead of listing all three.
- NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
- NEVER call a report "a known and expected behavior" or "a false positive" if the user mentions a regression. The triage tells you when this applies.
## Classification handling
The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.
## Paid-feature awareness
If `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.
## Language
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
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: env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }} ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }} IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
run: | run: |
GREETING="" GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING='This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"' # Use printf with %s so the apostrophe inside the string never has to
# cross a shell single-quote boundary.
printf '%s' 'This is the first issue from this user — start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
else
: > /tmp/greeting.txt
fi fi
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile system_prompt /tmp/composer-system.txt \
--rawfile title /tmp/issue-title.txt \ --rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \ --rawfile body /tmp/issue-body.txt \
--rawfile author /tmp/issue-author.txt \ --rawfile author /tmp/issue-author.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile triage /tmp/triage.json \
--rawfile greeting /tmp/greeting.txt \ --rawfile greeting /tmp/greeting.txt \
--rawfile repo_context /tmp/repo-context.txt \ --rawfile files /tmp/file-context.txt \
--rawfile context /tmp/file-context.txt \
'{ '{
model: "anthropic/claude-opus-4.6", model: $model,
messages: [ messages: [
{ { role: "system", content: $system_prompt },
role: "system", { role: "user",
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <container>` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n - Only ask for information that is actually missing. If the issue is already detailed, just acknowledge it.\n3. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Do NOT include a \"Possible cause\" section. Do not speculate about what code might be causing the issue.\n- Be brief and focused on collecting actionable information from the reporter.\n- If the issue already has everything needed (steps to reproduce, logs, version, OS), just acknowledge it.\n- Never exceed 15 lines.") content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
}, + "Title: " + $title
{ + "\nAuthor: " + $author
role: "user", + "\n\n## Triage result\n" + $triage
content: ( + "\n\n## Parsed template fields\n" + $fields
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) + + "\n\n## Raw issue body\n" + $body
"Analyze this issue:\n\nTitle: " + $title + + "\n\n## Source files (selected by triage)\n" + $files) }
"\nAuthor: " + $author +
"\n\nBody:\n" + $body +
"\n\nRelevant source files:\n" + $context
)
}
] ]
}') }')
@@ -154,28 +426,41 @@ jobs:
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty" echo "::error::Composer returned empty response"
echo "Raw response:" echo "Raw response:"
echo "$RESPONSE" echo "$RESPONSE"
exit 1 exit 1
fi fi
- name: Post comment and label - name: Strip forbidden sections (defense in depth)
run: |
# Even with explicit prompt rules, LLMs sometimes still emit "Possible cause"
# and friends. Strip any such heading + its block. Also drop any stray
# `Label:` lines from earlier prompt iterations.
python3 - <<'EOF'
import re
path = '/tmp/ai-comment.txt'
text = open(path).read()
# Drop forbidden section headers and everything until a blank line or another header.
forbidden = re.compile(
r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)',
re.IGNORECASE | re.MULTILINE | re.DOTALL,
)
text = forbidden.sub('', text)
# Drop stale Label: lines (we don't label anymore).
text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE)
# Collapse 3+ blank lines.
text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n'
open(path, 'w').write(text)
EOF
- name: Post comment (no labeling)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_NUMBER: ${{ github.event.issue.number }}
run: | run: |
LABEL=$(grep -oP '^Label:\s*\K.*' /tmp/ai-comment.txt | tail -1 | tr '[:upper:]' '[:lower:]' | xargs)
sed -i '/^Label:/d' /tmp/ai-comment.txt
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
if [ "$LABEL" = "bug" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "bug" 2>/dev/null || true
elif [ "$LABEL" = "enhancement" ]; then
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "enhancement" 2>/dev/null || true
fi
analyze-pr: analyze-pr:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -204,26 +489,20 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
run: | run: |
# Get changed files list
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \ gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \ --jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
> /tmp/pr-files.txt > /tmp/pr-files.txt
# Get the actual diff
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \ gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
--header "Accept: application/vnd.github.diff" \ --header "Accept: application/vnd.github.diff" \
> /tmp/pr-diff-full.txt 2>/dev/null || true > /tmp/pr-diff-full.txt 2>/dev/null || true
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
# Get CONTRIBUTING.md and README.md for context
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
# Read project guidelines (contains repo structure)
cp CLAUDE.md /tmp/repo-context.txt cp CLAUDE.md /tmp/repo-context.txt
# Read full contents of all changed files (skip binary) : > /tmp/related-file-contents.txt
echo "" > /tmp/related-file-contents.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
@@ -258,6 +537,7 @@ jobs:
printf '%s' "$GREETING" > /tmp/greeting.txt printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile title /tmp/pr-title.txt \ --rawfile title /tmp/pr-title.txt \
--rawfile body /tmp/pr-body.txt \ --rawfile body /tmp/pr-body.txt \
--rawfile author /tmp/pr-author.txt \ --rawfile author /tmp/pr-author.txt \
@@ -270,7 +550,7 @@ jobs:
--rawfile contributing /tmp/contributing.txt \ --rawfile contributing /tmp/contributing.txt \
--rawfile file_context /tmp/pr-file-context.txt \ --rawfile file_context /tmp/pr-file-context.txt \
'{ '{
model: "anthropic/claude-opus-4.6", model: $model,
messages: [ messages: [
{ {
role: "system", role: "system",
@@ -327,7 +607,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode - name: Run opencode
uses: anomalyco/opencode/github@da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65 #v1.14.24 uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
env: env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3 uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with: with:
run_install: false run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3 uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with: with:
run_install: false 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 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3 uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with: with:
run_install: false run_install: false
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3 uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with: with:
run_install: false run_install: false
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository - name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 #v1.45.1 uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3 uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with: with:
run_install: false run_install: false
@@ -94,7 +94,7 @@ jobs:
done done
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3 uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
with: with:
run_install: false run_install: false
+10
View File
@@ -60,6 +60,16 @@ donutbrowser/
- Don't duplicate code unless there's a very good reason; keep the same logic in one place - Don't duplicate code unless there's a very good reason; keep the same logic in one place
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files - Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Translations (mandatory)
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
- Adding a new string means adding the key to ALL seven locale files in `src/i18n/locales/` (en, es, fr, ja, pt, ru, zh) — not just `en.json`. The English version alone is incomplete work.
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
## Singletons ## Singletons
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise - If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
+112
View File
@@ -1,6 +1,118 @@
# Changelog # 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
- vpn manipulation via the api
### Refactoring
- don't block ui on clade check
### Documentation
- update CHANGELOG.md and README.md for v0.22.5 [skip ci] (#327)
### Maintenance
- chore: version bump
- chore: rand bump
- chore: pnpm bump
- ci(deps): bump the github-actions group with 3 updates (#330)
- chore: update flake.nix for v0.22.5 [skip ci] (#328)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#331)
## v0.22.5 (2026-04-29)
### Bug Fixes
- declare libxdo as runtime dependency
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.4 [skip ci] (#324)
## v0.22.4 (2026-04-28)
### Maintenance
- chore: version bump
- chore: i18n
- chore: update flake.nix for v0.22.3 [skip ci] (#321)
## v0.22.3 (2026-04-27)
### Bug Fixes
- correct browser port mapping
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.2 [skip ci] (#315)
## v0.22.2 (2026-04-27)
### Refactoring
- cookie management
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.1 [skip ci] (#313)
## v0.22.1 (2026-04-27)
### Bug Fixes
- link proper wayfern tos
### Refactoring
- vpn refresh and remove openvpn support
### Documentation
- update CHANGELOG.md and README.md for v0.22.0 [skip ci] (#306)
### Maintenance
- chore: version bump
- chore: linting
- chore: audit
- chore: update flake.nix for v0.22.0 [skip ci] (#307)
### Other
- deps(rust)(deps): bump the rust-dependencies group across 1 directory with 34 updates (#305)
## v0.22.0 (2026-04-25) ## v0.22.0 (2026-04-25)
### Refactoring ### Refactoring
+12 -8
View File
@@ -16,9 +16,6 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank"> <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"> <img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a> </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"> <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"> <img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a> </a>
@@ -51,7 +48,7 @@
| | Apple Silicon | Intel | | | Apple Silicon | Intel |
|---|---|---| |---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_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: Or install via Homebrew:
@@ -61,15 +58,15 @@ brew install --cask donut
### Windows ### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_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 ### Linux
| Format | x86_64 | ARM64 | | Format | x86_64 | ARM64 |
|---|---|---| |---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_arm64.deb) | | **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.0/Donut-0.22.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut-0.22.0-1.aarch64.rpm) | | **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.0/Donut_0.22.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_aarch64.AppImage) | | **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 --> <!-- install-links-end -->
Or install via package manager: Or install via package manager:
@@ -160,6 +157,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<br /> <br />
<sub><b>Jory Severijnse</b></sub> <sub><b>Jory Severijnse</b></sub>
</a> </a>
</td>
<td align="center">
<a href="https://github.com/ThiagoMafra-Integrare">
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
<br />
<sub><b>Thiago Mafra</b></sub>
</a>
</td> </td>
</tr> </tr>
<tbody> <tbody>
+1 -1
View File
@@ -117,7 +117,7 @@ export class SyncController {
@Get("subscribe") @Get("subscribe")
@Sse() @Sse()
subscribe(@Req() req: Request): Observable<MessageEvent> { 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) => ({ map((event) => ({
data: event, data: event,
})), })),
+239 -47
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { import {
CreateBucketCommand, CreateBucketCommand,
DeleteObjectCommand, DeleteObjectCommand,
@@ -41,6 +42,18 @@ import type {
SubscribeEventDto, SubscribeEventDto,
} from "./dto/sync.dto.js"; } 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() @Injectable()
export class SyncService implements OnModuleInit { export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name); private readonly logger = new Logger(SyncService.name);
@@ -149,6 +162,71 @@ export class SyncService implements OnModuleInit {
return `${ctx.prefix}${key}`; 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. * Validate that a key is accessible by the user.
* For cloud mode, key must start with user's prefix or team prefix. * 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); 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 { return {
url, url,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
@@ -294,6 +377,10 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx); this.reportProfileUsageAsync(ctx);
} }
if (deleted || tombstoneCreated) {
void this.bumpManifest(ctx, key);
}
return { deleted, tombstoneCreated }; return { deleted, tombstoneCreated };
} }
@@ -311,19 +398,22 @@ export class SyncService implements OnModuleInit {
const userPrefix = ctx?.prefix || ""; const userPrefix = ctx?.prefix || "";
const teamPrefix = ctx?.teamPrefix || ""; const teamPrefix = ctx?.teamPrefix || "";
const objects = (response.Contents || []).map((obj) => { const objects = (response.Contents || [])
let key = obj.Key || ""; // Don't leak donut-sync's internal manifest object to clients.
if (teamPrefix && key.startsWith(teamPrefix)) { .filter((obj) => !(obj.Key || "").endsWith(MANIFEST_KEY))
key = key.substring(teamPrefix.length); .map((obj) => {
} else if (userPrefix && key.startsWith(userPrefix)) { let key = obj.Key || "";
key = key.substring(userPrefix.length); if (teamPrefix && key.startsWith(teamPrefix)) {
} key = key.substring(teamPrefix.length);
return { } else if (userPrefix && key.startsWith(userPrefix)) {
key, key = key.substring(userPrefix.length);
lastModified: obj.LastModified?.toISOString() || "", }
size: obj.Size || 0, return {
}; key,
}); lastModified: obj.LastModified?.toISOString() || "",
size: obj.Size || 0,
};
});
return { return {
objects, objects,
@@ -373,6 +463,20 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx); 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 }; return { items };
} }
@@ -475,66 +579,154 @@ export class SyncService implements OnModuleInit {
this.reportProfileUsageAsync(ctx); this.reportProfileUsageAsync(ctx);
} }
if (deletedCount > 0 || tombstoneCreated) {
void this.bumpManifest(ctx, prefix);
}
return { deletedCount, tombstoneCreated }; 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( subscribe(
ctx: UserContext, ctx: UserContext,
pollIntervalMs = 2000, pollIntervalMs = 5000,
): Observable<SubscribeEventDto> { ): Observable<SubscribeEventDto> {
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"]; const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
const scopes = this.scopesFor(ctx);
let prefixes: string[]; // Per-connection state (not shared across subscribers).
if (ctx.mode === "self-hosted") { const lastManifestEtag = new Map<string, string | undefined>();
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)
let lastKnownState = new Map<string, string>(); let lastKnownState = new Map<string, string>();
let initialized = false;
const pollChanges$ = interval(pollIntervalMs).pipe( const pollChanges$ = interval(pollIntervalMs).pipe(
startWith(0), startWith(0),
switchMap(async () => { switchMap(async () => {
const events: SubscribeEventDto[] = []; 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 { try {
const result = await this.list({ prefix, maxKeys: 1000 }); const head = await this.s3Client.send(
for (const obj of result.objects) { new HeadObjectCommand({
const stateKey = `${obj.key}:${obj.lastModified}`; Bucket: this.bucket,
currentState.set(obj.key, stateKey); Key: manifestKey,
}),
const previousStateKey = lastKnownState.get(obj.key); );
if (previousStateKey !== stateKey) { currentEtag = head.ETag;
events.push({ } catch (err: unknown) {
type: "change", const status =
key: obj.key, err && typeof err === "object" && "$metadata" in err
lastModified: obj.lastModified, ? (err as { $metadata?: { httpStatusCode?: number } }).$metadata
size: obj.size, ?.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) { for (const [key] of lastKnownState) {
if (!currentState.has(key)) { if (!currentState.has(key)) {
events.push({ events.push({ type: "delete", key });
type: "delete",
key,
});
} }
} }
lastKnownState = currentState; lastKnownState = currentState;
initialized = true;
return events; return events;
}), }),
switchMap((events) => of(...events)), switchMap((events) => of(...events)),
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" ( pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs pkgConfigLibs ++ map lib.getDev pkgConfigLibs
); );
releaseVersion = "0.22.0"; releaseVersion = "0.22.7";
releaseAppImage = releaseAppImage =
if system == "x86_64-linux" then if system == "x86_64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_amd64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_amd64.AppImage";
hash = "sha256-3/GJD0yPFvRC3hIrplH9dBd5K22VQznINC3JYJI8q68="; hash = "sha256-pnIiyXxCY/WxczM5IAjzCq+6C96oXOesmz27y78tJSI=";
} }
else if system == "aarch64-linux" then else if system == "aarch64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.0/Donut_0.22.0_aarch64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.7/Donut_0.22.7_aarch64.AppImage";
hash = "sha256-znOAotD5UYfj2bhXzAhHkaKDxGNSRc3fdeckna9J+RY="; hash = "sha256-CyrujVE925Fr2G1U18PaklXCjKCDi+kOAkak7tZ8CW4=";
} }
else else
null; null;
+8 -6
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser", "name": "donutbrowser",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"version": "0.22.1", "version": "0.23.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 12341", "dev": "next dev --turbopack -p 12341",
@@ -16,7 +16,7 @@
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit", "lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", "lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .", "lint:spell": "typos .",
"tauri": "tauri", "tauri": "node scripts/run-with-env.mjs tauri",
"shadcn:add": "pnpm dlx shadcn@latest add", "shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install", "prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", "format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
@@ -45,7 +45,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "~2.10.1", "@tauri-apps/api": "~2.11.0",
"@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "~2.5.0", "@tauri-apps/plugin-fs": "~2.5.0",
@@ -75,7 +75,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.10", "@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "~2.10.1", "@tauri-apps/cli": "~2.11.0",
"@types/color": "^4.2.1", "@types/color": "^4.2.1",
"@types/node": "^25.5.2", "@types/node": "^25.5.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@@ -93,10 +93,12 @@
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4", "picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0", "path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12", "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.0", "packageManager": "pnpm@10.33.2",
"lint-staged": { "lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [ "**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix" "biome check --fix"
+77 -68
View File
@@ -9,6 +9,8 @@ overrides:
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0' path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.10: '>=8.5.12' 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'
importers: importers:
@@ -54,8 +56,8 @@ importers:
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tauri-apps/api': '@tauri-apps/api':
specifier: ~2.10.1 specifier: ~2.11.0
version: 2.10.1 version: 2.11.0
'@tauri-apps/plugin-deep-link': '@tauri-apps/plugin-deep-link':
specifier: ^2.4.7 specifier: ^2.4.7
version: 2.4.7 version: 2.4.7
@@ -139,8 +141,8 @@ importers:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ~2.10.1 specifier: ~2.11.0
version: 2.10.1 version: 2.11.0
'@types/color': '@types/color':
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@@ -2678,82 +2680,82 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@tauri-apps/api@2.10.1': '@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
'@tauri-apps/cli-darwin-arm64@2.10.1': '@tauri-apps/cli-darwin-arm64@2.11.0':
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} resolution: {integrity: sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@tauri-apps/cli-darwin-x64@2.10.1': '@tauri-apps/cli-darwin-x64@2.11.0':
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==} resolution: {integrity: sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==} resolution: {integrity: sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.10.1': '@tauri-apps/cli-linux-arm64-gnu@2.11.0':
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==} resolution: {integrity: sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.1': '@tauri-apps/cli-linux-arm64-musl@2.11.0':
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1': '@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.1': '@tauri-apps/cli-linux-x64-gnu@2.11.0':
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.1': '@tauri-apps/cli-linux-x64-musl@2.11.0':
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.1': '@tauri-apps/cli-win32-arm64-msvc@2.11.0':
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.10.1': '@tauri-apps/cli-win32-ia32-msvc@2.11.0':
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==} resolution: {integrity: sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.10.1': '@tauri-apps/cli-win32-x64-msvc@2.11.0':
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==} resolution: {integrity: sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tauri-apps/cli@2.10.1': '@tauri-apps/cli@2.11.0':
resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==} resolution: {integrity: sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
@@ -3784,11 +3786,11 @@ packages:
fast-safe-stringify@2.1.1: fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@3.1.0: fast-uri@3.1.2:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-xml-builder@1.1.5: fast-xml-builder@1.2.0:
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
fast-xml-parser@5.7.2: fast-xml-parser@5.7.2:
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
@@ -5525,6 +5527,10 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} 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: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8343,74 +8349,74 @@ snapshots:
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@8.21.3': {}
'@tauri-apps/api@2.10.1': {} '@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.10.1': '@tauri-apps/cli-darwin-arm64@2.11.0':
optional: true optional: true
'@tauri-apps/cli-darwin-x64@2.10.1': '@tauri-apps/cli-darwin-x64@2.11.0':
optional: true optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
optional: true optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.10.1': '@tauri-apps/cli-linux-arm64-gnu@2.11.0':
optional: true optional: true
'@tauri-apps/cli-linux-arm64-musl@2.10.1': '@tauri-apps/cli-linux-arm64-musl@2.11.0':
optional: true optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1': '@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
optional: true optional: true
'@tauri-apps/cli-linux-x64-gnu@2.10.1': '@tauri-apps/cli-linux-x64-gnu@2.11.0':
optional: true optional: true
'@tauri-apps/cli-linux-x64-musl@2.10.1': '@tauri-apps/cli-linux-x64-musl@2.11.0':
optional: true optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.10.1': '@tauri-apps/cli-win32-arm64-msvc@2.11.0':
optional: true optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.10.1': '@tauri-apps/cli-win32-ia32-msvc@2.11.0':
optional: true optional: true
'@tauri-apps/cli-win32-x64-msvc@2.10.1': '@tauri-apps/cli-win32-x64-msvc@2.11.0':
optional: true optional: true
'@tauri-apps/cli@2.10.1': '@tauri-apps/cli@2.11.0':
optionalDependencies: optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.10.1 '@tauri-apps/cli-darwin-arm64': 2.11.0
'@tauri-apps/cli-darwin-x64': 2.10.1 '@tauri-apps/cli-darwin-x64': 2.11.0
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1 '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.0
'@tauri-apps/cli-linux-arm64-gnu': 2.10.1 '@tauri-apps/cli-linux-arm64-gnu': 2.11.0
'@tauri-apps/cli-linux-arm64-musl': 2.10.1 '@tauri-apps/cli-linux-arm64-musl': 2.11.0
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.1 '@tauri-apps/cli-linux-riscv64-gnu': 2.11.0
'@tauri-apps/cli-linux-x64-gnu': 2.10.1 '@tauri-apps/cli-linux-x64-gnu': 2.11.0
'@tauri-apps/cli-linux-x64-musl': 2.10.1 '@tauri-apps/cli-linux-x64-musl': 2.11.0
'@tauri-apps/cli-win32-arm64-msvc': 2.10.1 '@tauri-apps/cli-win32-arm64-msvc': 2.11.0
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-ia32-msvc': 2.11.0
'@tauri-apps/cli-win32-x64-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.11.0
'@tauri-apps/plugin-deep-link@2.4.7': '@tauri-apps/plugin-deep-link@2.4.7':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-dialog@2.7.0': '@tauri-apps/plugin-dialog@2.7.0':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-fs@2.5.0': '@tauri-apps/plugin-fs@2.5.0':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-log@2.8.0': '@tauri-apps/plugin-log@2.8.0':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.11.0
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
dependencies: dependencies:
@@ -8807,7 +8813,7 @@ snapshots:
ajv@8.18.0: ajv@8.18.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
fast-uri: 3.1.0 fast-uri: 3.1.2
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
@@ -9422,16 +9428,17 @@ snapshots:
fast-safe-stringify@2.1.1: {} 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: dependencies:
path-expression-matcher: 1.5.0 path-expression-matcher: 1.5.0
xml-naming: 0.1.0
fast-xml-parser@5.7.2: fast-xml-parser@5.7.2:
dependencies: dependencies:
'@nodable/entities': 2.1.0 '@nodable/entities': 2.1.0
fast-xml-builder: 1.1.5 fast-xml-builder: 1.2.0
path-expression-matcher: 1.5.0 path-expression-matcher: 1.5.0
strnum: 2.2.3 strnum: 2.2.3
@@ -11037,7 +11044,7 @@ snapshots:
tauri-plugin-macos-permissions-api@2.3.0: tauri-plugin-macos-permissions-api@2.3.0:
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.11.0
terser-webpack-plugin@5.4.0(webpack@5.105.4): terser-webpack-plugin@5.4.0(webpack@5.105.4):
dependencies: dependencies:
@@ -11385,6 +11392,8 @@ snapshots:
imurmurhash: 0.1.4 imurmurhash: 0.1.4
signal-exit: 4.1.0 signal-exit: 4.1.0
xml-naming@0.1.0: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@3.1.1: {} yallist@3.1.1: {}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Wrapper that loads `.env` into process.env (without overwriting anything
// already in the environment) and execs the given command. Used by the
// `tauri` npm script so `pnpm tauri build` picks up APPLE_SIGNING_IDENTITY,
// APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. without requiring direnv.
//
// Plain shell `source .env` works on macOS/Linux but not Windows; this
// wrapper is platform-agnostic.
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const envPath = resolve(projectRoot, ".env");
if (existsSync(envPath)) {
const content = readFileSync(envPath, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
// Don't overwrite values already exported by the parent shell — direnv
// / CI secrets / one-off `FOO=bar pnpm tauri ...` invocations win.
if (process.env[key] === undefined) {
process.env[key] = val;
}
}
}
const [, , cmd, ...args] = process.argv;
if (!cmd) {
console.error("usage: run-with-env.mjs <command> [args...]");
process.exit(2);
}
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
child.on("error", (err) => {
console.error(`Failed to spawn ${cmd}:`, err.message);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code ?? 1);
}
});
+12 -1
View File
@@ -171,10 +171,21 @@ async function startMinio(minioBin) {
async function buildDonutSync() { async function buildDonutSync() {
log("Building donut-sync..."); 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", { execSync("pnpm build", {
cwd: path.join(ROOT_DIR, "donut-sync"), cwd: syncDir,
stdio: process.env.VERBOSE ? "inherit" : "ignore", 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"); log("donut-sync built");
} }
+220 -706
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "donutbrowser" name = "donutbrowser"
version = "0.22.1" version = "0.23.0"
description = "Simple Yet Powerful Anti-Detect Browser" description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"] authors = ["zhom@github"]
edition = "2021" 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"] } reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
tokio = { version = "1", features = ["full", "sync"] } tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7" tokio-util = "0.7"
sysinfo = "0.38" sysinfo = "0.39"
lazy_static = "1.5" lazy_static = "1.5"
base64 = "0.22" base64 = "0.22"
libc = "0.2" libc = "0.2"
@@ -102,7 +102,7 @@ serde_yaml = "0.9"
thiserror = "2.0" thiserror = "2.0"
regex-lite = "0.1" regex-lite = "0.1"
tempfile = "3" tempfile = "3"
maxminddb = "0.27" maxminddb = "0.28"
quick-xml = { version = "0.39", features = ["serialize"] } quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support # VPN support
@@ -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"] } smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon) # Daemon dependencies (tray icon)
tray-icon = "0.22" tray-icon = "0.24"
tao = "0.35" tao = "0.35"
image = "0.25" image = "0.25"
dirs = "6" dirs = "6"
+134 -6
View File
@@ -41,6 +41,7 @@ pub struct ApiProfile {
pub tags: Vec<String>, pub tags: Vec<String>,
pub is_running: bool, pub is_running: bool,
pub proxy_bypass_rules: Vec<String>, pub proxy_bypass_rules: Vec<String>,
pub vpn_id: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -60,6 +61,7 @@ pub struct CreateProfileRequest {
pub browser: String, pub browser: String,
pub version: String, pub version: String,
pub proxy_id: Option<String>, pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
pub launch_hook: Option<String>, pub launch_hook: Option<String>,
pub release_type: Option<String>, pub release_type: Option<String>,
#[schema(value_type = Object)] #[schema(value_type = Object)]
@@ -76,6 +78,7 @@ pub struct UpdateProfileRequest {
pub browser: Option<String>, pub browser: Option<String>,
pub version: Option<String>, pub version: Option<String>,
pub proxy_id: Option<String>, pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
pub launch_hook: Option<String>, pub launch_hook: Option<String>,
pub release_type: Option<String>, pub release_type: Option<String>,
#[schema(value_type = Object)] #[schema(value_type = Object)]
@@ -140,6 +143,16 @@ struct ApiVpnResponse {
last_used: Option<i64>, last_used: Option<i64>,
} }
#[derive(Debug, Serialize, ToSchema)]
struct ApiVpnExportResponse {
id: String,
name: String,
/// Always "WireGuard"
vpn_type: String,
/// Raw `.conf` file content (decrypted)
config_data: String,
}
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
struct ImportVpnRequest { struct ImportVpnRequest {
/// Raw WireGuard `.conf` file content /// Raw WireGuard `.conf` file content
@@ -357,6 +370,7 @@ impl ApiServer {
.routes(routes!(get_proxy, update_proxy, delete_proxy)) .routes(routes!(get_proxy, update_proxy, delete_proxy))
.routes(routes!(get_vpns, create_vpn)) .routes(routes!(get_vpns, create_vpn))
.routes(routes!(import_vpn)) .routes(routes!(import_vpn))
.routes(routes!(export_vpn))
.routes(routes!(get_vpn, update_vpn, delete_vpn)) .routes(routes!(get_vpn, update_vpn, delete_vpn))
.routes(routes!(get_extensions)) .routes(routes!(get_extensions))
.routes(routes!(delete_extension_api)) .routes(routes!(delete_extension_api))
@@ -387,6 +401,10 @@ impl ApiServer {
.merge(v1_routes) .merge(v1_routes)
.nest("/ws", ws_routes) .nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) })) .route("/openapi.json", get(move || async move { Json(api) }))
// 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()) .layer(CorsLayer::permissive())
.with_state(state); .with_state(state);
@@ -440,6 +458,8 @@ async fn auth_middleware(
request: axum::extract::Request, request: axum::extract::Request,
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let path = request.uri().path().to_string();
// Get the Authorization header // Get the Authorization header
let auth_header = headers let auth_header = headers
.get("Authorization") .get("Authorization")
@@ -448,19 +468,31 @@ async fn auth_middleware(
let token = match auth_header { let token = match auth_header {
Some(token) => token, 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 // Get the stored token
let settings_manager = crate::settings_manager::SettingsManager::instance(); let settings_manager = crate::settings_manager::SettingsManager::instance();
let stored_token = match settings_manager.get_api_token(&state.app_handle).await { let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token, Ok(Some(stored_token)) => stored_token,
Ok(None) => return Err(StatusCode::UNAUTHORIZED), Ok(None) => {
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), 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 // Compare tokens
if token != stored_token { if token != stored_token {
log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
@@ -468,6 +500,38 @@ async fn auth_middleware(
Ok(next.run(request).await) 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 // Global API server instance
lazy_static! { lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new())); pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
@@ -542,6 +606,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
tags: profile.tags.clone(), tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id is_running: profile.process_id.is_some(), // Simple check based on process_id
proxy_bypass_rules: profile.proxy_bypass_rules.clone(), proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
}) })
.collect(); .collect();
@@ -598,6 +663,7 @@ async fn get_profile(
tags: profile.tags.clone(), tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id is_running: profile.process_id.is_some(), // Simple check based on process_id
proxy_bypass_rules: profile.proxy_bypass_rules.clone(), proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
}, },
})) }))
} else { } else {
@@ -652,7 +718,7 @@ async fn create_profile(
&request.version, &request.version,
request.release_type.as_deref().unwrap_or("stable"), request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(), request.proxy_id.clone(),
None, // vpn_id request.vpn_id.clone(),
camoufox_config, camoufox_config,
wayfern_config, wayfern_config,
request.group_id.clone(), request.group_id.clone(),
@@ -700,6 +766,7 @@ async fn create_profile(
tags: profile.tags, tags: profile.tags,
is_running: false, is_running: false,
proxy_bypass_rules: profile.proxy_bypass_rules, proxy_bypass_rules: profile.proxy_bypass_rules,
vpn_id: profile.vpn_id,
}, },
})) }))
} }
@@ -733,6 +800,12 @@ async fn update_profile(
) -> Result<Json<ApiProfileResponse>, StatusCode> { ) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty())
&& request.vpn_id.as_deref().is_some_and(|s| !s.is_empty())
{
return Err(StatusCode::BAD_REQUEST);
}
// Update profile fields // Update profile fields
if let Some(new_name) = request.name { if let Some(new_name) = request.name {
if profile_manager if profile_manager
@@ -762,6 +835,21 @@ async fn update_profile(
} }
} }
if let Some(vpn_id) = request.vpn_id {
let normalized = if vpn_id.is_empty() {
None
} else {
Some(vpn_id)
};
if profile_manager
.update_profile_vpn(state.app_handle.clone(), &id, normalized)
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(launch_hook) = request.launch_hook { if let Some(launch_hook) = request.launch_hook {
let normalized = if launch_hook.trim().is_empty() { let normalized = if launch_hook.trim().is_empty() {
None None
@@ -1308,6 +1396,37 @@ async fn get_vpn(
.ok_or(StatusCode::NOT_FOUND) .ok_or(StatusCode::NOT_FOUND)
} }
#[utoipa::path(
get,
path = "/v1/vpns/{id}/export",
params(("id" = String, Path, description = "VPN configuration ID")),
responses(
(status = 200, description = "Decrypted VPN configuration", body = ApiVpnExportResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "VPN configuration not found"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn export_vpn(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<ApiVpnExportResponse>, StatusCode> {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match storage.load_config(&id) {
Ok(config) => Ok(Json(ApiVpnExportResponse {
id: config.id,
name: config.name,
vpn_type: config.vpn_type.to_string(),
config_data: config.config_data,
})),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/v1/vpns/import", path = "/v1/vpns/import",
@@ -1584,8 +1703,17 @@ async fn run_profile(
.await .await
.map_err(|_| StatusCode::CONFLICT)?; .map_err(|_| StatusCode::CONFLICT)?;
// Generate a random port for remote debugging let remote_debugging_port = {
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000); let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let port = listener
.local_addr()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.port();
drop(listener);
port
};
// Use the same launch method as the main app, but with remote debugging enabled // Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging( match crate::browser_runner::launch_browser_profile_with_debugging(
+29 -12
View File
@@ -928,18 +928,35 @@ impl AppAutoUpdater {
// Move new app to current location // Move new app to current location
fs::rename(installer_path, &current_app_path)?; fs::rename(installer_path, &current_app_path)?;
// Remove quarantine attributes from the new app // Remove the macOS quarantine attribute from the freshly-installed app
let _ = Command::new("xattr") // so Gatekeeper doesn't block its first launch — but only if it's
.args([ // actually present. macOS Sequoia's App Management TCC fires on the
"-dr", // modify-class syscall regardless of whether anything is actually
"com.apple.quarantine", // modified, so we gate the call behind a read-only `getxattr` check.
current_app_path.to_str().unwrap(), let needs_quarantine_removal = {
]) use std::ffi::CString;
.output(); use std::os::unix::ffi::OsStrExt;
let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok();
let _ = Command::new("xattr") let attr_c = CString::new("com.apple.quarantine").ok();
.args(["-cr", current_app_path.to_str().unwrap()]) match (path_c, attr_c) {
.output(); (Some(p), Some(a)) => {
// SAFETY: getxattr with a null buffer is a read-only size query.
let result =
unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
result >= 0
}
_ => false,
}
};
if needs_quarantine_removal {
let _ = Command::new("xattr")
.args([
"-dr",
"com.apple.quarantine",
current_app_path.to_str().unwrap(),
])
.output();
}
// Clean up backup after successful installation // Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path); let _ = fs::remove_dir_all(&backup_path);
+1
View File
@@ -701,6 +701,7 @@ mod tests {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
} }
} }
+1
View File
@@ -1218,6 +1218,7 @@ mod tests {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
}; };
let path = profile.get_profile_data_path(&profiles_dir); 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 // Create ephemeral dir for ephemeral or password-protected profiles
let override_profile_path = if profile.ephemeral { 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()) let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?; .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(dir) Some(dir)
@@ -542,8 +546,11 @@ impl BrowserRunner {
); );
} }
// Create ephemeral dir for ephemeral profiles // Create ephemeral dir for ephemeral or password-protected profiles
if profile.ephemeral { 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()) crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?; .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()); 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()); crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
} }
+18 -2
View File
@@ -127,8 +127,16 @@ lazy_static! {
impl CloudAuthManager { impl CloudAuthManager {
fn new() -> Self { fn new() -> Self {
let state = Self::load_auth_state_from_disk(); let state = Self::load_auth_state_from_disk();
// Bound every cloud API call so no single slow / hung request can stall
// the startup chain (sync-token → proxy-config → wayfern-token), which
// otherwise gates Wayfern launch behind whichever endpoint is slowest.
let client = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| Client::new());
Self { Self {
client: Client::new(), client,
state: Mutex::new(state), state: Mutex::new(state),
refresh_lock: tokio::sync::Mutex::new(()), refresh_lock: tokio::sync::Mutex::new(()),
wayfern_token: Mutex::new(None), wayfern_token: Mutex::new(None),
@@ -990,7 +998,15 @@ impl CloudAuthManager {
let token = self let token = self
.api_call_with_retry(|access_token| { .api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start"); let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
let client = reqwest::Client::new(); // Bound the request: without a timeout, an unreachable
// api.donutbrowser.com hangs the background fetch indefinitely,
// which in turn forces wayfern_manager's launch-time wait to
// exhaust its full polling budget every time.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.connect_timeout(std::time::Duration::from_secs(4))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
async move { async move {
let response = client let response = client
.post(&url) .post(&url)
+20 -42
View File
@@ -50,20 +50,6 @@ pub mod chrome_decrypt {
key key
} }
/// Get the encryption key for Chrome cookies.
///
/// Wayfern stores `os_crypt_key` as a plain file inside the profile's
/// user-data-dir on all platforms (see the wayfern patches for
/// `os_crypt_mac.mm` and `os_crypt_linux.cc`). The file contains a
/// base64-encoded 128-bit random value that is used as the PBKDF2
/// password — not as the raw AES key — matching Chromium's
/// `OSCryptImpl::DeriveKey` flow.
///
/// If the file is missing we return `None`. We must NEVER fall back to the
/// real macOS Keychain or any other system credential store. Wayfern
/// profiles are fully self-contained and reaching into another app's entry
/// would trigger the macOS "confidential information stored in …" prompt
/// and the "prevented from modifying other apps" warning.
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> { pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
let key_file = profile_data_path.join("os_crypt_key"); let key_file = profile_data_path.join("os_crypt_key");
// Read as raw bytes and do NOT trim — Chromium's `ReadFileToString` // Read as raw bytes and do NOT trim — Chromium's `ReadFileToString`
@@ -186,32 +172,34 @@ impl CookieManager {
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01 /// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
const WINDOWS_EPOCH_DIFF: i64 = 11644473600; const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
/// Get the Chrome cookie encryption key for a Wayfern profile
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> { fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
let profile_data_path = profile.get_profile_data_path(profiles_dir); let profile_data_path = profile.get_profile_data_path(profiles_dir);
chrome_decrypt::get_encryption_key(&profile_data_path) chrome_decrypt::get_encryption_key(&profile_data_path)
} }
fn wayfern_cookie_path(profile_data_path: &Path) -> PathBuf {
let default_dir = profile_data_path.join("Default");
#[cfg(target_os = "windows")]
{
default_dir.join("Network").join("Cookies")
}
#[cfg(not(target_os = "windows"))]
{
default_dir.join("Cookies")
}
}
/// Get the cookie database path for a profile (read-side: errors if missing). /// Get the cookie database path for a profile (read-side: errors if missing).
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> { fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir); let profile_data_path = profile.get_profile_data_path(profiles_dir);
match profile.browser.as_str() { match profile.browser.as_str() {
"wayfern" => { "wayfern" => {
let network_path = profile_data_path let path = Self::wayfern_cookie_path(&profile_data_path);
.join("Default") if path.exists() {
.join("Network") Ok(path)
.join("Cookies");
let legacy_path = profile_data_path.join("Default").join("Cookies");
if network_path.exists() {
Ok(network_path)
} else if legacy_path.exists() {
Ok(legacy_path)
} else { } else {
Err(format!( Err(format!("Cookie database not found at: {}", path.display()))
"Cookie database not found at: {}",
network_path.display()
))
} }
} }
"camoufox" => { "camoufox" => {
@@ -241,21 +229,11 @@ impl CookieManager {
match profile.browser.as_str() { match profile.browser.as_str() {
"wayfern" => { "wayfern" => {
let network_path = profile_data_path let path = Self::wayfern_cookie_path(&profile_data_path);
.join("Default") if !path.exists() {
.join("Network") Self::create_empty_chrome_cookies_db(&path)?;
.join("Cookies");
let legacy_path = profile_data_path.join("Default").join("Cookies");
if network_path.exists() {
Ok(network_path)
} else if legacy_path.exists() {
Ok(legacy_path)
} else {
let dir = network_path.parent().unwrap();
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create Network dir: {e}"))?;
Self::create_empty_chrome_cookies_db(&network_path)?;
Ok(network_path)
} }
Ok(path)
} }
"camoufox" => { "camoufox" => {
let path = profile_data_path.join("cookies.sqlite"); let path = profile_data_path.join("cookies.sqlite");
+2 -1
View File
@@ -240,7 +240,7 @@ fn cleanup_legacy_dirs() {
} }
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf { 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()) { if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
return dir; return dir;
} }
@@ -279,6 +279,7 @@ mod tests {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
} }
} }
+70 -23
View File
@@ -12,6 +12,39 @@ use tokio::process::Command;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use std::fs::create_dir_all; use std::fs::create_dir_all;
/// Returns true if `path` carries a `com.apple.quarantine` extended attribute.
///
/// Uses `getxattr` with a null buffer to query the attribute size only —
/// this is a read-only syscall and does NOT trigger macOS Sequoia's App
/// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS
/// fires the prompt on the modify-class syscall (`removexattr`) even when
/// the operation is a no-op, so skipping the call entirely when the
/// attribute is absent is the only way to stay quiet.
#[cfg(target_os = "macos")]
fn has_quarantine_attr(path: &Path) -> bool {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else {
return false;
};
let Ok(attr_c) = CString::new("com.apple.quarantine") else {
return false;
};
// SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0
// makes it a pure read-only size query.
let result = unsafe {
libc::getxattr(
path_c.as_ptr(),
attr_c.as_ptr(),
std::ptr::null_mut(),
0,
0,
0,
)
};
result >= 0
}
pub struct Extractor; pub struct Extractor;
impl Extractor { impl Extractor {
@@ -207,18 +240,23 @@ impl Extractor {
match extraction_result { match extraction_result {
Ok(path) => { Ok(path) => {
// Remove quarantine attributes on macOS to prevent // Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
// "app was prevented from modifying data" prompts // but only if there's actually something to remove. Calling the
// modify-class `removexattr` syscall on a file without quarantine still
// fires macOS Sequoia's App Management TCC notification, so we skip
// the call entirely when the attribute is absent.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let _ = tokio::process::Command::new("xattr") if has_quarantine_attr(dest_dir) {
.args([ let _ = tokio::process::Command::new("xattr")
"-dr", .args([
"com.apple.quarantine", "-dr",
dest_dir.to_str().unwrap_or("."), "com.apple.quarantine",
]) dest_dir.to_str().unwrap_or("."),
.output() ])
.await; .output()
.await;
}
} }
log::info!( log::info!(
@@ -419,9 +457,15 @@ impl Extractor {
log::info!("Copying .app to: {}", app_path.display()); log::info!("Copying .app to: {}", app_path.display());
// `-X` strips extended attributes (notably com.apple.quarantine) during
// the copy itself. Without it, `cp -R` preserves quarantine from the
// mounted DMG, which then has to be removed with `xattr -dr` — and that
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
// Management TCC notification ("Donut.app was prevented from modifying
// apps on your Mac"). Stripping at copy time is silent.
let output = Command::new("cp") let output = Command::new("cp")
.args([ .args([
"-R", "-RX",
app_entry.to_str().unwrap(), app_entry.to_str().unwrap(),
app_path.to_str().unwrap(), app_path.to_str().unwrap(),
]) ])
@@ -444,18 +488,21 @@ impl Extractor {
log::info!("Successfully copied .app bundle"); log::info!("Successfully copied .app bundle");
// Remove quarantine attributes // Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
let _ = Command::new("xattr") // — but only if it's actually present. A no-op `removexattr` syscall on a
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()]) // signed .app bundle still trips macOS Sequoia's App Management privacy
.output() // prompt ("Donut.app was prevented from modifying apps on your Mac"),
.await; // even when no modification actually happens, so we gate the call behind
// a read-only `getxattr` check.
let _ = Command::new("xattr") if has_quarantine_attr(&app_path) {
.args(["-cr", app_path.to_str().unwrap()]) let _ = Command::new("xattr")
.output() .args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.await; .output()
.await;
log::info!("Removed quarantine attributes"); log::info!("Removed quarantine attributes");
} else {
log::info!("No quarantine attribute on .app, skipping xattr removal");
}
// Unmount the DMG // Unmount the DMG
let output = Command::new("hdiutil") let output = Command::new("hdiutil")
+74 -24
View File
@@ -72,6 +72,11 @@ use profile::manager::{
update_wayfern_config, 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::{ use browser_version_manager::{
fetch_browser_versions_cached_first, fetch_browser_versions_with_count, fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_supported_browsers, fetch_browser_versions_with_count_cached_first, get_supported_browsers,
@@ -675,11 +680,17 @@ fn find_claude_cli() -> Option<std::path::PathBuf> {
} }
#[tauri::command] #[tauri::command]
fn is_mcp_in_claude_code() -> Result<bool, String> { async fn is_mcp_in_claude_code() -> Result<bool, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?; let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let output = std::process::Command::new(&cli) // `claude mcp list` health-checks every registered MCP server, so a
// missing or stalled server can hang the call for many seconds. Cap it
// — for this dialog, a slow `claude` is treated the same as "not registered".
let fut = tokio::process::Command::new(&cli)
.args(["mcp", "list"]) .args(["mcp", "list"])
.output() .output();
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.map_err(|_| "claude mcp list timed out".to_string())?
.map_err(|e| format!("Failed to run claude: {e}"))?; .map_err(|e| format!("Failed to run claude: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.contains("donut-browser")) Ok(stdout.contains("donut-browser"))
@@ -1127,6 +1138,7 @@ async fn generate_sample_fingerprint(
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
}; };
if browser == "camoufox" { if browser == "camoufox" {
@@ -1232,7 +1244,7 @@ pub fn run() {
#[allow(unused_variables)] #[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser") .title("Donut Browser")
.inner_size(800.0, 500.0) .inner_size(840.0, 500.0)
.resizable(false) .resizable(false)
.fullscreen(false) .fullscreen(false)
.center() .center()
@@ -1338,18 +1350,31 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await; 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 mcp_handle = app.handle().clone();
let settings_mgr = settings_manager::SettingsManager::instance(); let settings_mgr = settings_manager::SettingsManager::instance();
if let Ok(settings) = settings_mgr.load_settings() { match settings_mgr.load_settings() {
if settings.mcp_enabled { Ok(settings) => {
tauri::async_runtime::spawn(async move { if settings.mcp_enabled {
match mcp_server::McpServer::instance().start(mcp_handle).await { log::info!("MCP server is enabled in settings, attempting auto-start");
Ok(port) => log::info!("MCP server auto-started on port {port}"), tauri::async_runtime::spawn(async move {
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"), 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}");
} }
} }
} }
@@ -1763,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); last_running_states.insert(profile_id, is_running);
} else { } else {
// Update the state even if unchanged to ensure we have it tracked // Update the state even if unchanged to ensure we have it tracked
@@ -1882,21 +1914,31 @@ pub fn run() {
// Start cloud auth background refresh loop // Start cloud auth background refresh loop
let app_handle_cloud = app.handle().clone(); let app_handle_cloud = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// On startup, refresh sync token and proxy if cloud auth is active. // On startup, refresh sync token, proxy config, and wayfern token in
// PARALLEL. Previously they were awaited sequentially, so the wayfern
// token request didn't even start until the earlier two API calls had
// finished. Wayfern launch can race with this task — a few seconds of
// serialized API calls translates directly into a slow first launch
// because launch_wayfern blocks waiting for the token to land.
// api_call_with_retry handles 401/refresh internally — no direct // api_call_with_retry handles 401/refresh internally — no direct
// refresh_access_token call needed. // refresh_access_token call needed.
if cloud_auth::CLOUD_AUTH.is_logged_in().await { if cloud_auth::CLOUD_AUTH.is_logged_in().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await { let sync_token_fut = async {
log::warn!("Failed to refresh cloud sync token on startup: {e}"); if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
} log::warn!("Failed to refresh cloud sync token on startup: {e}");
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
// Request wayfern token on startup for paid users
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
} }
} };
let proxy_fut = async {
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
};
let wayfern_fut = async {
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
}
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
} }
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await; cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
}); });
@@ -2076,6 +2118,13 @@ pub fn run() {
// DNS blocklist commands // DNS blocklist commands
dns_blocklist::get_dns_blocklist_cache_status, dns_blocklist::get_dns_blocklist_cache_status,
dns_blocklist::refresh_dns_blocklists, 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!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
@@ -2122,6 +2171,7 @@ mod tests {
"generate_sample_fingerprint", "generate_sample_fingerprint",
"cloud_get_wayfern_token", "cloud_get_wayfern_token",
"cloud_refresh_wayfern_token", "cloud_refresh_wayfern_token",
"lock_profile",
]; ];
// Extract command names from the generate_handler! macro in this file // 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> { async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await { 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 { return Err(McpError {
code: -32000, code: -32000,
message: format!("{feature} requires an active paid subscription"), message: format!("{feature} requires an active paid subscription"),
@@ -1458,6 +1469,16 @@ impl McpServer {
.cloned() .cloned()
.unwrap_or(serde_json::json!({})); .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 { match tool_name {
"list_profiles" => self.handle_list_profiles().await, "list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).await, "get_profile" => self.handle_get_profile(&arguments).await,
+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_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
}; };
match self match self
@@ -285,6 +286,7 @@ impl ProfileManager {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
}; };
match self match self
@@ -340,6 +342,7 @@ impl ProfileManager {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist, dns_blocklist,
password_protected: false,
}; };
// Save profile info // Save profile info
@@ -987,6 +990,7 @@ impl ProfileManager {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: source.dns_blocklist, dns_blocklist: source.dns_blocklist,
password_protected: false,
}; };
self.save_profile(&new_profile)?; self.save_profile(&new_profile)?;
+2
View File
@@ -1,4 +1,6 @@
pub mod encryption;
pub mod manager; pub mod manager;
pub mod password;
pub mod types; pub mod types;
pub use manager::ProfileManager; 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>, pub created_by_email: Option<String>,
#[serde(default)] #[serde(default)]
pub dns_blocklist: Option<String>, 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 { pub fn default_release_type() -> String {
+3
View File
@@ -584,6 +584,7 @@ impl ProfileImporter {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
}; };
match self match self
@@ -664,6 +665,7 @@ impl ProfileImporter {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
}; };
match self match self
@@ -715,6 +717,7 @@ impl ProfileImporter {
created_by_id: None, created_by_id: None,
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false,
}; };
self.profile_manager.save_profile(&profile)?; self.profile_manager.save_profile(&profile)?;
+51 -11
View File
@@ -174,6 +174,10 @@ pub struct ProxyManager {
// Track active proxy IDs by profile name for targeted cleanup // Track active proxy IDs by profile name for targeted cleanup
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
// Consecutive cleanup passes during which a browser PID looked dead.
// We only reap a worker after it has been missed in N consecutive scans —
// a single sysinfo blip under load shouldn't kill a still-running worker.
dead_browser_misses: Mutex<HashMap<u32, u8>>,
} }
impl ProxyManager { impl ProxyManager {
@@ -183,6 +187,7 @@ impl ProxyManager {
profile_proxies: Mutex::new(HashMap::new()), profile_proxies: Mutex::new(HashMap::new()),
profile_active_proxy_ids: Mutex::new(HashMap::new()), profile_active_proxy_ids: Mutex::new(HashMap::new()),
stored_proxies: Mutex::new(HashMap::new()), stored_proxies: Mutex::new(HashMap::new()),
dead_browser_misses: Mutex::new(HashMap::new()),
}; };
// Load stored proxies on initialization // Load stored proxies on initialization
@@ -2095,17 +2100,52 @@ impl ProxyManager {
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()), sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
); );
let dead_browser_entries: Vec<(u32, String, Option<String>)> = snapshot // Two-state classification: alive PIDs reset their miss counter,
.into_iter() // dead PIDs increment it. A worker is only reaped after MISS_THRESHOLD
.filter(|(browser_pid, _, _)| { // consecutive misses (~60s by default given the 30s cleanup cadence),
// The sentinel PID=0 is used as a placeholder during launch, // so a single sysinfo blip under heavy load doesn't kill a healthy worker.
// before update_proxy_pid has recorded the real browser PID. const MISS_THRESHOLD: u8 = 2;
*browser_pid != 0
&& system let mut alive_pids: Vec<u32> = Vec::new();
.process(sysinfo::Pid::from_u32(*browser_pid)) let mut dead_candidates: Vec<(u32, String, Option<String>)> = Vec::new();
.is_none() let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
}) for (browser_pid, proxy_id, profile_id) in snapshot {
.collect(); snapshot_pids.insert(browser_pid);
// The sentinel PID=0 is used as a placeholder during launch,
// before update_proxy_pid has recorded the real browser PID.
if browser_pid == 0 {
continue;
}
if system
.process(sysinfo::Pid::from_u32(browser_pid))
.is_some()
{
alive_pids.push(browser_pid);
} else {
dead_candidates.push((browser_pid, proxy_id, profile_id));
}
}
let dead_browser_entries: Vec<(u32, String, Option<String>)> = {
let mut misses = self.dead_browser_misses.lock().unwrap();
// Forget PIDs no longer tracked at all (worker already torn down elsewhere).
misses.retain(|pid, _| snapshot_pids.contains(pid));
// Reset miss count for any PID that's currently alive.
for pid in &alive_pids {
misses.remove(pid);
}
// Increment dead candidates and select those past threshold.
let mut to_reap = Vec::new();
for (browser_pid, proxy_id, profile_id) in dead_candidates {
let count = misses.entry(browser_pid).or_insert(0);
*count = count.saturating_add(1);
if *count >= MISS_THRESHOLD {
misses.remove(&browser_pid);
to_reap.push((browser_pid, proxy_id, profile_id));
}
}
to_reap
};
for (browser_pid, proxy_id, profile_id) in dead_browser_entries { for (browser_pid, proxy_id, profile_id) in dead_browser_entries {
log::info!( log::info!(
+83 -41
View File
@@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpListener;
use tokio::net::TcpStream; use tokio::net::TcpStream;
/// Combined read+write trait for tunnel target streams, allowing /// Combined read+write trait for tunnel target streams, allowing
@@ -1232,8 +1231,49 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
log::error!("Attempting to bind proxy server to {}", bind_addr); log::error!("Attempting to bind proxy server to {}", bind_addr);
// Bind to the port // Bind to the port. Use SO_REUSEADDR so that a freshly-restarted worker
let listener = TcpListener::bind(bind_addr).await?; // can bind a port that the previous worker left in TIME_WAIT, and retry
// briefly to absorb transient races with the OS releasing the socket.
let listener = {
let mut attempts: u32 = 0;
loop {
let socket = tokio::net::TcpSocket::new_v4()?;
let _ = socket.set_reuseaddr(true);
match socket.bind(bind_addr) {
Ok(()) => match socket.listen(1024) {
Ok(l) => break l,
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"listen() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(e) => {
return Err(format!("Failed to listen on {bind_addr} after 5 attempts: {e}").into())
}
},
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"bind() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(e) => return Err(format!("Failed to bind {bind_addr} after 5 attempts: {e}").into()),
}
}
};
let actual_port = listener.local_addr()?.port(); let actual_port = listener.local_addr()?.port();
log::error!("Successfully bound to port {}", actual_port); log::error!("Successfully bound to port {}", actual_port);
@@ -1295,52 +1335,54 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
loop { loop {
interval.tick().await; interval.tick().await;
if let Some(tracker) = get_traffic_tracker() { // Catch panics so a poisoned lock or unexpected error inside
let (sent, recv, requests) = tracker.get_snapshot(); // flush_to_disk doesn't abort the flush task and leave stats
let current_bytes = sent + recv; // unwritten for the lifetime of the worker. The captured state
let time_since_activity = last_activity_time.elapsed(); // is all Copy or atomic-assignment, so AssertUnwindSafe is sound.
let time_since_flush = last_flush_time.elapsed(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let has_traffic = current_bytes > 0 || requests > 0; if let Some(tracker) = get_traffic_tracker() {
let (sent, recv, requests) = tracker.get_snapshot();
let current_bytes = sent + recv;
let time_since_activity = last_activity_time.elapsed();
let time_since_flush = last_flush_time.elapsed();
let has_traffic = current_bytes > 0 || requests > 0;
// Determine flush frequency based on activity let desired_interval_secs =
// When active: flush every 5 seconds if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
// When idle: flush every 30 seconds 5u64
let desired_interval_secs = } else {
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) { 30u64
5u64 };
} else {
30u64
};
// Update interval if needed if desired_interval_secs != current_interval_secs {
if desired_interval_secs != current_interval_secs { current_interval_secs = desired_interval_secs;
current_interval_secs = desired_interval_secs; interval =
interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs)); tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
} }
// Only flush if enough time has passed since last flush let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
let flush_interval = std::time::Duration::from_secs(desired_interval_secs); let should_flush = time_since_flush >= flush_interval;
let should_flush = time_since_flush >= flush_interval;
if should_flush { if should_flush {
match tracker.flush_to_disk() { match tracker.flush_to_disk() {
Ok(Some((sent, recv))) => { Ok(Some((sent, recv))) => {
// Successful flush with data last_flush_time = std::time::Instant::now();
last_flush_time = std::time::Instant::now(); if sent > 0 || recv > 0 {
if sent > 0 || recv > 0 { last_activity_time = std::time::Instant::now();
last_activity_time = std::time::Instant::now(); }
}
Ok(None) => {
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
} }
}
Ok(None) => {
// No data to flush - this is normal
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
// Don't update flush time on error - retry sooner
} }
} }
} }
}));
if let Err(panic) = result {
log::error!("Panic caught in proxy traffic flush task; continuing: {panic:?}");
} }
} }
}); });
+7
View File
@@ -57,6 +57,11 @@ pub struct AppSettings {
pub window_resize_warning_dismissed: bool, pub window_resize_warning_dismissed: bool,
#[serde(default)] #[serde(default)]
pub disable_auto_updates: bool, 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)] #[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -92,6 +97,7 @@ impl Default for AppSettings {
language: None, language: None,
window_resize_warning_dismissed: false, window_resize_warning_dismissed: false,
disable_auto_updates: false, disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
} }
} }
} }
@@ -1070,6 +1076,7 @@ mod tests {
language: None, language: None,
window_resize_warning_dismissed: false, window_resize_warning_dismissed: false,
disable_auto_updates: false, disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
}; };
let save_result = manager.save_settings(&test_settings); let save_result = manager.save_settings(&test_settings);
+13 -2
View File
@@ -639,14 +639,25 @@ impl WayfernManager {
.has_active_paid_subscription() .has_active_paid_subscription()
.await .await
{ {
log::info!("Wayfern token not ready for paid user, waiting..."); // Brief wait for the background token fetch — when the API is healthy
for _ in 0..15 { // the token usually lands in well under a second. If api.donutbrowser.com
// is unreachable we don't want to gate the whole launch on it; the
// browser still works without the token (cross-OS fingerprinting just
// won't be enabled for this session, and the next launch will pick it
// up once the token arrives).
log::info!("Wayfern token not ready for paid user, waiting briefly...");
for _ in 0..3 {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if wayfern_token.is_some() { if wayfern_token.is_some() {
break; break;
} }
} }
if wayfern_token.is_none() {
log::warn!(
"Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)"
);
}
} }
if let Some(ref token) = wayfern_token { if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}")); args.push(format!("--wayfern-token={token}"));
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Donut", "productName": "Donut",
"version": "0.22.1", "version": "0.23.0",
"identifier": "com.donutbrowser", "identifier": "com.donutbrowser",
"build": { "build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev", "beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -42,11 +42,11 @@
"linux": { "linux": {
"deb": { "deb": {
"desktopTemplate": "donutbrowser.desktop", "desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"] "depends": ["xdg-utils", "libxdo3"]
}, },
"rpm": { "rpm": {
"desktopTemplate": "donutbrowser.desktop", "desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"] "depends": ["xdg-utils", "libxdo"]
}, },
"appimage": { "appimage": {
"files": { "files": {
+262 -117
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog"; import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CommercialTrialModal } from "@/components/commercial-trial-modal"; import { CommercialTrialModal } from "@/components/commercial-trial-modal";
@@ -11,6 +12,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog"; import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog"; import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog"; import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog"; import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
@@ -22,6 +24,10 @@ import { IntegrationsDialog } from "@/components/integrations-dialog";
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog"; import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
import { PermissionDialog } from "@/components/permission-dialog"; import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table"; import { ProfilesDataTable } from "@/components/profile-data-table";
import {
type PasswordDialogMode,
ProfilePasswordDialog,
} from "@/components/profile-password-dialog";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProfileSyncDialog } from "@/components/profile-sync-dialog"; import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog"; import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
@@ -45,6 +51,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { import {
dismissToast, dismissToast,
showErrorToast, showErrorToast,
@@ -67,6 +74,7 @@ interface PendingUrl {
} }
export default function Home() { export default function Home() {
const { t } = useTranslation();
// Mount global version update listener/toasts // Mount global version update listener/toasts
useVersionUpdater(); useVersionUpdater();
@@ -180,6 +188,11 @@ export default function Home() {
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] = const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
useState<BrowserProfile | null>(null); useState<BrowserProfile | null>(null);
const [cloneProfile, setCloneProfile] = 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 [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false); const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false); const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
@@ -195,6 +208,7 @@ export default function Home() {
useState(false); useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false); const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false); const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false); const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] = const [currentProfileForSync, setCurrentProfileForSync] =
@@ -392,21 +406,32 @@ export default function Home() {
} }
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]); }, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
const checkNextPermission = useCallback(() => { const checkNextPermission = useCallback(
try { (justGranted?: PermissionType) => {
if (!isMicrophoneAccessGranted) { try {
setCurrentPermissionType("microphone"); // Treat the just-granted permission as already granted even if our
setPermissionDialogOpen(true); // own usePermissions instance hasn't observed it yet — it polls on a
} else if (!isCameraAccessGranted) { // 5 s cadence and would otherwise leave the dialog stuck on the
setCurrentPermissionType("camera"); // permission the user just successfully granted.
setPermissionDialogOpen(true); const micGranted =
} else { isMicrophoneAccessGranted || justGranted === "microphone";
setPermissionDialogOpen(false); const camGranted = isCameraAccessGranted || justGranted === "camera";
if (!micGranted) {
setCurrentPermissionType("microphone");
setPermissionDialogOpen(true);
} else if (!camGranted) {
setCurrentPermissionType("camera");
setPermissionDialogOpen(true);
} else {
setPermissionDialogOpen(false);
}
} catch (error) {
console.error("Failed to check next permission:", error);
} }
} catch (error) { },
console.error("Failed to check next permission:", error); [isMicrophoneAccessGranted, isCameraAccessGranted],
} );
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
const listenForUrlEvents = useCallback(async () => { const listenForUrlEvents = useCallback(async () => {
try { try {
@@ -428,9 +453,7 @@ export default function Home() {
"Received show create profile dialog request:", "Received show create profile dialog request:",
event.payload, event.payload,
); );
showErrorToast( showErrorToast(t("errors.noProfilesForUrl"));
"No profiles available. Please create a profile first before opening URLs.",
);
setCreateProfileDialogOpen(true); setCreateProfileDialogOpen(true);
}); });
@@ -455,7 +478,7 @@ export default function Home() {
} catch (error) { } catch (error) {
console.error("Failed to setup URL listener:", error); console.error("Failed to setup URL listener:", error);
} }
}, [handleUrlOpen]); }, [handleUrlOpen, t]);
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => { const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
setCurrentProfileForCamoufoxConfig(profile); setCurrentProfileForCamoufoxConfig(profile);
@@ -474,12 +497,14 @@ export default function Home() {
} catch (err: unknown) { } catch (err: unknown) {
console.error("Failed to update camoufox config:", err); console.error("Failed to update camoufox config:", err);
showErrorToast( showErrorToast(
`Failed to update camoufox config: ${JSON.stringify(err)}`, t("errors.updateCamoufoxConfigFailed", {
error: JSON.stringify(err),
}),
); );
throw err; throw err;
} }
}, },
[], [t],
); );
const handleSaveWayfernConfig = useCallback( const handleSaveWayfernConfig = useCallback(
@@ -494,12 +519,12 @@ export default function Home() {
} catch (err: unknown) { } catch (err: unknown) {
console.error("Failed to update wayfern config:", err); console.error("Failed to update wayfern config:", err);
showErrorToast( showErrorToast(
`Failed to update wayfern config: ${JSON.stringify(err)}`, t("errors.updateWayfernConfigFailed", { error: JSON.stringify(err) }),
); );
throw err; throw err;
} }
}, },
[], [t],
); );
const handleCreateProfile = useCallback( const handleCreateProfile = useCallback(
@@ -517,6 +542,7 @@ export default function Home() {
ephemeral?: boolean; ephemeral?: boolean;
dnsBlocklist?: string; dnsBlocklist?: string;
launchHook?: string; launchHook?: string;
password?: string;
}) => { }) => {
try { try {
const profile = await invoke<BrowserProfile>( const profile = await invoke<BrowserProfile>(
@@ -550,88 +576,146 @@ 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 // No need to manually reload - useProfileEvents will handle the update
} catch (error) { } catch (error) {
showErrorToast( showErrorToast(
`Failed to create profile: ${ t("errors.createProfileFailed", {
error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error),
}`, }),
); );
} }
}, },
[selectedGroupId], [selectedGroupId, t],
); );
const launchProfile = useCallback(async (profile: BrowserProfile) => { const launchProfile = useCallback(
console.log("Starting launch for profile:", profile.name); async (profile: BrowserProfile) => {
console.log("Starting launch for profile:", profile.name);
// Show one-time warning about window resizing for fingerprinted browsers // Password-protected: must be unlocked before launch
if (profile.browser === "camoufox" || profile.browser === "wayfern") { if (profile.password_protected) {
try { try {
const dismissed = await invoke<boolean>( const isLocked = await invoke<boolean>("is_profile_locked", {
"get_window_resize_warning_dismissed", profileId: profile.id,
);
if (!dismissed) {
const proceed = await new Promise<boolean>((resolve) => {
windowResizeWarningResolver.current = resolve;
setWindowResizeWarningBrowserType(profile.browser);
setWindowResizeWarningOpen(true);
}); });
if (!proceed) { if (isLocked) {
pendingLaunchAfterUnlockRef.current = profile;
setPasswordDialogMode("unlock");
setPasswordDialogProfile(profile);
return; return;
} }
} catch (err) {
console.error("Failed to check profile lock state:", err);
} }
} catch (error) {
console.error("Failed to check window resize warning:", error);
} }
}
try { // Show one-time warning about window resizing for fingerprinted browsers
const result = await invoke<BrowserProfile>("launch_browser_profile", { if (profile.browser === "camoufox" || profile.browser === "wayfern") {
profile, try {
}); const dismissed = await invoke<boolean>(
console.log("Successfully launched profile:", result.name); "get_window_resize_warning_dismissed",
} catch (err: unknown) { );
console.error("Failed to launch browser:", err); if (!dismissed) {
const errorMessage = err instanceof Error ? err.message : String(err); const proceed = await new Promise<boolean>((resolve) => {
showErrorToast(`Failed to launch browser: ${errorMessage}`); windowResizeWarningResolver.current = resolve;
throw err; setWindowResizeWarningBrowserType(profile.browser);
} setWindowResizeWarningOpen(true);
}, []); });
if (!proceed) {
return;
}
}
} catch (error) {
console.error("Failed to check window resize warning:", error);
}
}
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
});
console.log("Successfully launched profile:", result.name);
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(
t("errors.launchBrowserFailed", { error: errorMessage }),
);
throw err;
}
},
[t],
);
const handleCloneProfile = useCallback((profile: BrowserProfile) => { const handleCloneProfile = useCallback((profile: BrowserProfile) => {
setCloneProfile(profile); setCloneProfile(profile);
}, []); }, []);
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => { const handleSetPassword = useCallback((profile: BrowserProfile) => {
console.log("Attempting to delete profile:", profile.name); pendingLaunchAfterUnlockRef.current = null;
setPasswordDialogMode("set");
try { setPasswordDialogProfile(profile);
// First check if the browser is running for this profile
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
if (isRunning) {
showErrorToast(
"Cannot delete profile while browser is running. Please stop the browser first.",
);
return;
}
// Attempt to delete the profile
await invoke("delete_profile", { profileId: profile.id });
console.log("Profile deletion command completed successfully");
// No need to manually reload - useProfileEvents will handle the update
console.log("Profile deleted successfully");
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to delete profile: ${errorMessage}`);
}
}, []); }, []);
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);
try {
// First check if the browser is running for this profile
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
if (isRunning) {
showErrorToast(t("errors.cannotDeleteRunningProfile"));
return;
}
// Attempt to delete the profile
await invoke("delete_profile", { profileId: profile.id });
console.log("Profile deletion command completed successfully");
// No need to manually reload - useProfileEvents will handle the update
console.log("Profile deleted successfully");
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(
t("errors.deleteProfileFailed", { error: errorMessage }),
);
}
},
[t],
);
const handleRenameProfile = useCallback( const handleRenameProfile = useCallback(
async (profileId: string, newName: string) => { async (profileId: string, newName: string) => {
try { try {
@@ -639,28 +723,33 @@ export default function Home() {
// No need to manually reload - useProfileEvents will handle the update // No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) { } catch (err: unknown) {
console.error("Failed to rename profile:", err); console.error("Failed to rename profile:", err);
showErrorToast(`Failed to rename profile: ${JSON.stringify(err)}`); showErrorToast(
t("errors.renameProfileFailed", { error: JSON.stringify(err) }),
);
throw err; throw err;
} }
}, },
[], [t],
); );
const handleKillProfile = useCallback(async (profile: BrowserProfile) => { const handleKillProfile = useCallback(
console.log("Starting kill for profile:", profile.name); async (profile: BrowserProfile) => {
console.log("Starting kill for profile:", profile.name);
try { try {
await invoke("kill_browser_profile", { profile }); await invoke("kill_browser_profile", { profile });
console.log("Successfully killed profile:", profile.name); console.log("Successfully killed profile:", profile.name);
// No need to manually reload - useProfileEvents will handle the update // No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) { } catch (err: unknown) {
console.error("Failed to kill browser:", err); console.error("Failed to kill browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to kill browser: ${errorMessage}`); showErrorToast(t("errors.killBrowserFailed", { error: errorMessage }));
// Re-throw the error so the table component can handle loading state cleanup // Re-throw the error so the table component can handle loading state cleanup
throw err; throw err;
} }
}, []); },
[t],
);
const handleDeleteSelectedProfiles = useCallback( const handleDeleteSelectedProfiles = useCallback(
async (profileIds: string[]) => { async (profileIds: string[]) => {
@@ -670,11 +759,13 @@ export default function Home() {
} catch (err: unknown) { } catch (err: unknown) {
console.error("Failed to delete selected profiles:", err); console.error("Failed to delete selected profiles:", err);
showErrorToast( showErrorToast(
`Failed to delete selected profiles: ${JSON.stringify(err)}`, t("errors.deleteSelectedProfilesFailed", {
error: JSON.stringify(err),
}),
); );
} }
}, },
[], [t],
); );
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => { const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
@@ -701,12 +792,14 @@ export default function Home() {
} catch (error) { } catch (error) {
console.error("Failed to delete selected profiles:", error); console.error("Failed to delete selected profiles:", error);
showErrorToast( showErrorToast(
`Failed to delete selected profiles: ${JSON.stringify(error)}`, t("errors.deleteSelectedProfilesFailed", {
error: JSON.stringify(error),
}),
); );
} finally { } finally {
setIsBulkDeleting(false); setIsBulkDeleting(false);
} }
}, [selectedProfiles]); }, [selectedProfiles, t]);
const handleBulkGroupAssignment = useCallback(() => { const handleBulkGroupAssignment = useCallback(() => {
if (selectedProfiles.length === 0) return; if (selectedProfiles.length === 0) return;
@@ -749,14 +842,12 @@ export default function Home() {
(p.browser === "wayfern" || p.browser === "camoufox"), (p.browser === "wayfern" || p.browser === "camoufox"),
); );
if (eligibleProfiles.length === 0) { if (eligibleProfiles.length === 0) {
showErrorToast( showErrorToast(t("errors.cookieCopyUnsupportedBrowser"));
"Cookie copy only works with Wayfern and Camoufox profiles",
);
return; return;
} }
setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id)); setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id));
setCookieCopyDialogOpen(true); setCookieCopyDialogOpen(true);
}, [selectedProfiles, profiles]); }, [selectedProfiles, profiles, t]);
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => { const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
setSelectedProfilesForCookies([profile.id]); setSelectedProfilesForCookies([profile.id]);
@@ -804,10 +895,10 @@ export default function Home() {
}); });
} catch (error) { } catch (error) {
console.error("Failed to toggle sync:", error); console.error("Failed to toggle sync:", error);
showErrorToast("Failed to update sync settings"); showErrorToast(t("errors.updateSyncSettingsFailed"));
} }
}, },
[], [t],
); );
useEffect(() => { useEffect(() => {
@@ -825,19 +916,22 @@ export default function Home() {
const { profile_id, status, error, profile_name } = event.payload; const { profile_id, status, error, profile_name } = event.payload;
const toastId = `sync-${profile_id}`; const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id); const profile = profiles.find((p) => p.id === profile_id);
const name = profile_name || profile?.name || "Unknown"; const name =
profile_name || profile?.name || t("common.labels.unknownProfile");
if (status === "synced") { if (status === "synced") {
dismissToast(toastId); dismissToast(toastId);
if (profilesWithTransfer.has(profile_id)) { if (profilesWithTransfer.has(profile_id)) {
profilesWithTransfer.delete(profile_id); profilesWithTransfer.delete(profile_id);
showSuccessToast(`Profile '${name}' synced successfully`); showSuccessToast(t("sync.toast.profileSynced", { name }));
} }
} else if (status === "error") { } else if (status === "error") {
dismissToast(toastId); dismissToast(toastId);
profilesWithTransfer.delete(profile_id); profilesWithTransfer.delete(profile_id);
showErrorToast( showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`, error
? t("sync.toast.profileSyncFailedWithError", { name, error })
: t("sync.toast.profileSyncFailed", { name }),
); );
} }
}); });
@@ -857,7 +951,10 @@ export default function Home() {
const payload = event.payload; const payload = event.payload;
const toastId = `sync-${payload.profile_id}`; const toastId = `sync-${payload.profile_id}`;
const profile = profiles.find((p) => p.id === payload.profile_id); const profile = profiles.find((p) => p.id === payload.profile_id);
const name = payload.profile_name || profile?.name || "Unknown"; const name =
payload.profile_name ||
profile?.name ||
t("common.labels.unknownProfile");
if ( if (
payload.phase === "started" || payload.phase === "started" ||
@@ -889,7 +986,7 @@ export default function Home() {
if (unlistenStatus) unlistenStatus(); if (unlistenStatus) unlistenStatus();
if (unlistenProgress) unlistenProgress(); if (unlistenProgress) unlistenProgress();
}; };
}, [profiles]); }, [profiles, t]);
useEffect(() => { useEffect(() => {
// Check for startup default browser prompt // Check for startup default browser prompt
@@ -1047,7 +1144,7 @@ export default function Home() {
return ( return (
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background"> <div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
<main className="flex flex-col items-center w-full max-w-3xl"> <main className="flex flex-col items-center w-full max-w-4xl px-3">
<div className="w-full"> <div className="w-full">
<HomeHeader <HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen} onCreateProfileDialogOpen={setCreateProfileDialogOpen}
@@ -1074,6 +1171,9 @@ export default function Home() {
onLaunchProfile={launchProfile} onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile} onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile} onCloneProfile={handleCloneProfile}
onSetPassword={handleSetPassword}
onChangePassword={handleChangePassword}
onRemovePassword={handleRemovePassword}
onDeleteProfile={handleDeleteProfile} onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile} onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox} onConfigureCamoufox={handleConfigureCamoufox}
@@ -1179,6 +1279,26 @@ export default function Home() {
profile={cloneProfile} 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 <CamoufoxConfigDialog
isOpen={camoufoxConfigDialogOpen} isOpen={camoufoxConfigDialogOpen}
onClose={() => { onClose={() => {
@@ -1272,9 +1392,13 @@ export default function Home() {
setShowBulkDeleteConfirmation(false); setShowBulkDeleteConfirmation(false);
}} }}
onConfirm={confirmBulkDelete} onConfirm={confirmBulkDelete}
title="Delete Selected Profiles" title={t("profiles.bulkDelete.title")}
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`} description={t("profiles.bulkDelete.description", {
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`} count: selectedProfiles.length,
})}
confirmButtonText={t("profiles.bulkDelete.confirmButton", {
count: selectedProfiles.length,
})}
isLoading={isBulkDeleting} isLoading={isBulkDeleting}
profileIds={selectedProfiles} profileIds={selectedProfiles}
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))} profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
@@ -1289,8 +1413,29 @@ export default function Home() {
setSyncAllDialogOpen(true); setSyncAllDialogOpen(true);
} }
}} }}
onLoginStarted={() => {
// Hand the verify step off to its own dialog. We close this one
// first so the verify dialog isn't stacked on top of it (and
// can't end up stacked on top of the profile selector either).
setSyncConfigDialogOpen(false);
setDeviceCodeDialogOpen(true);
}}
/> />
{/* Only render while no profile-selector flow is in progress, so the
verify dialog never lands on top of a deep-link-triggered selector. */}
{pendingUrls.length === 0 && (
<DeviceCodeVerifyDialog
isOpen={deviceCodeDialogOpen}
onClose={(loginOccurred) => {
setDeviceCodeDialogOpen(false);
if (loginOccurred) {
setSyncAllDialogOpen(true);
}
}}
/>
)}
<SyncAllDialog <SyncAllDialog
isOpen={syncAllDialogOpen} isOpen={syncAllDialogOpen}
onClose={() => { onClose={() => {
+7 -5
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslation } from "react-i18next";
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa"; import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
import { LuCheckCheck } from "react-icons/lu"; import { LuCheckCheck } from "react-icons/lu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -19,6 +20,7 @@ export function AppUpdateToast({
onDismiss, onDismiss,
updateReady = false, updateReady = false,
}: AppUpdateToastProps) { }: AppUpdateToastProps) {
const { t } = useTranslation();
const handleRestartClick = async () => { const handleRestartClick = async () => {
await onRestart(); await onRestart();
}; };
@@ -43,10 +45,10 @@ export function AppUpdateToast({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{updateReady {updateReady
? "Update ready, restart to apply" ? t("appUpdate.toast.updateReady")
: updateInfo.repo_update : updateInfo.repo_update
? "Update available via package manager" ? "Update available via package manager"
: "Manual download required"} : t("appUpdate.toast.manualDownloadRequired")}
</span> </span>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{updateInfo.current_version} {updateInfo.new_version} {updateInfo.current_version} {updateInfo.new_version}
@@ -71,7 +73,7 @@ export function AppUpdateToast({
className="flex gap-2 items-center text-xs" className="flex gap-2 items-center text-xs"
> >
<LuCheckCheck className="w-3 h-3" /> <LuCheckCheck className="w-3 h-3" />
Restart Now {t("appUpdate.toast.restartNow")}
</RippleButton> </RippleButton>
) : ( ) : (
!updateInfo.repo_update && !updateInfo.repo_update &&
@@ -82,7 +84,7 @@ export function AppUpdateToast({
className="flex gap-2 items-center text-xs" className="flex gap-2 items-center text-xs"
> >
<FaExternalLinkAlt className="w-3 h-3" /> <FaExternalLinkAlt className="w-3 h-3" />
View Release {t("appUpdate.toast.viewRelease")}
</RippleButton> </RippleButton>
) )
)} )}
@@ -92,7 +94,7 @@ export function AppUpdateToast({
size="sm" size="sm"
className="text-xs" className="text-xs"
> >
Later {t("appUpdate.toast.later")}
</RippleButton> </RippleButton>
</div> </div>
</div> </div>
+19 -9
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form"; import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { import {
Dialog, Dialog,
@@ -51,6 +52,7 @@ export function CamoufoxConfigDialog({
isRunning = false, isRunning = false,
crossOsUnlocked = false, crossOsUnlocked = false,
}: CamoufoxConfigDialogProps) { }: CamoufoxConfigDialogProps) {
const { t } = useTranslation();
// Use union type to support both Camoufox and Wayfern configs // Use union type to support both Camoufox and Wayfern configs
const [config, setConfig] = useState<CamoufoxConfig | WayfernConfig>(() => ({ const [config, setConfig] = useState<CamoufoxConfig | WayfernConfig>(() => ({
geoip: true, geoip: true,
@@ -93,9 +95,8 @@ export function CamoufoxConfigDialog({
JSON.parse(config.fingerprint); JSON.parse(config.fingerprint);
} catch (_error) { } catch (_error) {
const { toast } = await import("sonner"); const { toast } = await import("sonner");
toast.error("Invalid fingerprint configuration", { toast.error(t("camoufoxDialog.invalidFingerprint"), {
description: description: t("camoufoxDialog.invalidFingerprintDescription"),
"The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
}); });
return; return;
} }
@@ -112,9 +113,11 @@ export function CamoufoxConfigDialog({
} catch (error) { } catch (error) {
console.error("Failed to save config:", error); console.error("Failed to save config:", error);
const { toast } = await import("sonner"); const { toast } = await import("sonner");
toast.error("Failed to save configuration", { toast.error(t("camoufoxDialog.saveFailed"), {
description: description:
error instanceof Error ? error.message : "Unknown error occurred", error instanceof Error
? error.message
: t("camoufoxDialog.unknownError"),
}); });
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -149,8 +152,15 @@ export function CamoufoxConfigDialog({
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col"> <DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle> <DialogTitle>
{isRunning ? "View" : "Configure"} Fingerprint Settings -{" "} {isRunning
{profile.name} ({browserName}) ? t("camoufoxDialog.titleView", {
name: profile.name,
browser: browserName,
})
: t("camoufoxDialog.titleConfigure", {
name: profile.name,
browser: browserName,
})}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -185,7 +195,7 @@ export function CamoufoxConfigDialog({
<DialogFooter className="shrink-0 pt-4 border-t"> <DialogFooter className="shrink-0 pt-4 border-t">
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
{isRunning ? "Close" : "Cancel"} {isRunning ? t("common.buttons.close") : t("common.buttons.cancel")}
</RippleButton> </RippleButton>
{!isRunning && ( {!isRunning && (
<LoadingButton <LoadingButton
@@ -193,7 +203,7 @@ export function CamoufoxConfigDialog({
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
> >
Save {t("common.buttons.save")}
</LoadingButton> </LoadingButton>
)} )}
</DialogFooter> </DialogFooter>
+1 -1
View File
@@ -62,7 +62,7 @@ export function CloneProfileDialog({
onCloneComplete?.(); onCloneComplete?.();
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to clone profile: ${errorMessage}`); showErrorToast(t("errors.cloneProfileFailed", { error: errorMessage }));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
+11 -9
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { import {
Dialog, Dialog,
@@ -22,6 +23,7 @@ export function CommercialTrialModal({
isOpen, isOpen,
onClose, onClose,
}: CommercialTrialModalProps) { }: CommercialTrialModalProps) {
const { t } = useTranslation();
const [isAcknowledging, setIsAcknowledging] = useState(false); const [isAcknowledging, setIsAcknowledging] = useState(false);
const handleAcknowledge = useCallback(async () => { const handleAcknowledge = useCallback(async () => {
@@ -31,14 +33,16 @@ export function CommercialTrialModal({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to acknowledge trial expiration:", error); console.error("Failed to acknowledge trial expiration:", error);
showErrorToast("Failed to save acknowledgment", { showErrorToast(t("commercialTrial.failed"), {
description: description:
error instanceof Error ? error.message : "Please try again", error instanceof Error
? error.message
: t("commercialTrial.tryAgain"),
}); });
} finally { } finally {
setIsAcknowledging(false); setIsAcknowledging(false);
} }
}, [onClose]); }, [onClose, t]);
return ( return (
<Dialog open={isOpen}> <Dialog open={isOpen}>
@@ -55,17 +59,15 @@ export function CommercialTrialModal({
}} }}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>Commercial Trial Expired</DialogTitle> <DialogTitle>{t("commercialTrial.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Your 2-week commercial trial period has ended. {t("commercialTrial.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
If you are using Donut Browser for business purposes, you need to {t("commercialTrial.body")}
purchase a commercial license to continue. You can still use it for
personal use for free.
</p> </p>
</div> </div>
@@ -74,7 +76,7 @@ export function CommercialTrialModal({
onClick={handleAcknowledge} onClick={handleAcknowledge}
isLoading={isAcknowledging} isLoading={isAcknowledging}
> >
I Understand {t("commercialTrial.understandButton")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+57 -27
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
LuChevronDown, LuChevronDown,
LuChevronRight, LuChevronRight,
@@ -66,6 +67,7 @@ export function CookieCopyDialog({
runningProfiles, runningProfiles,
onCopyComplete, onCopyComplete,
}: CookieCopyDialogProps) { }: CookieCopyDialogProps) {
const { t } = useTranslation();
const [sourceProfileId, setSourceProfileId] = useState<string | null>(null); const [sourceProfileId, setSourceProfileId] = useState<string | null>(null);
const [cookieData, setCookieData] = useState<CookieReadResult | null>(null); const [cookieData, setCookieData] = useState<CookieReadResult | null>(null);
const [isLoadingCookies, setIsLoadingCookies] = useState(false); const [isLoadingCookies, setIsLoadingCookies] = useState(false);
@@ -243,10 +245,11 @@ export function CookieCopyDialog({
runningProfiles.has(p.id), runningProfiles.has(p.id),
); );
if (runningTargets.length > 0) { if (runningTargets.length > 0) {
const names = runningTargets.map((p) => p.name).join(", ");
toast.error( toast.error(
`Cannot copy cookies: ${runningTargets.map((p) => p.name).join(", ")} ${ runningTargets.length === 1
runningTargets.length === 1 ? "is" : "are" ? t("cookies.copy.cannotCopyRunningOne", { names })
} still running`, : t("cookies.copy.cannotCopyRunningMany", { names }),
); );
return; return;
} }
@@ -277,10 +280,15 @@ export function CookieCopyDialog({
} }
if (errors.length > 0) { if (errors.length > 0) {
toast.error(`Some errors occurred: ${errors.join(", ")}`); toast.error(
t("cookies.copy.someErrors", { errors: errors.join(", ") }),
);
} else { } else {
toast.success( toast.success(
`Successfully copied ${totalCopied + totalReplaced} cookies (${totalReplaced} replaced)`, t("cookies.copy.successMessage", {
copied: totalCopied + totalReplaced,
replaced: totalReplaced,
}),
); );
onCopyComplete?.(); onCopyComplete?.();
onClose(); onClose();
@@ -288,7 +296,9 @@ export function CookieCopyDialog({
} catch (err) { } catch (err) {
console.error("Failed to copy cookies:", err); console.error("Failed to copy cookies:", err);
toast.error( toast.error(
`Failed to copy cookies: ${err instanceof Error ? err.message : String(err)}`, t("cookies.copy.failedMessage", {
error: err instanceof Error ? err.message : String(err),
}),
); );
} finally { } finally {
setIsCopying(false); setIsCopying(false);
@@ -300,6 +310,7 @@ export function CookieCopyDialog({
buildSelectedCookies, buildSelectedCookies,
onCopyComplete, onCopyComplete,
onClose, onClose,
t,
]); ]);
useEffect(() => { useEffect(() => {
@@ -325,23 +336,30 @@ export function CookieCopyDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" /> <LuCookie className="w-5 h-5" />
Copy Cookies {t("cookies.copy.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Copy cookies from a source profile to {selectedProfiles.length}{" "} {selectedProfiles.length === 1
selected profile{selectedProfiles.length !== 1 ? "s" : ""}. ? t("cookies.copy.dialogDescription_one", {
count: selectedProfiles.length,
})
: t("cookies.copy.dialogDescription_other", {
count: selectedProfiles.length,
})}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4"> <div className="flex-1 overflow-y-auto space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Source Profile</Label> <Label>{t("cookies.copy.sourceProfile")}</Label>
<Select <Select
value={sourceProfileId ?? undefined} value={sourceProfileId ?? undefined}
onValueChange={handleSourceChange} onValueChange={handleSourceChange}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a profile to copy cookies from" /> <SelectValue
placeholder={t("cookies.copy.sourcePlaceholder")}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{eligibleSourceProfiles.map((profile) => { {eligibleSourceProfiles.map((profile) => {
@@ -358,7 +376,7 @@ export function CookieCopyDialog({
<span>{profile.name}</span> <span>{profile.name}</span>
{isRunning && ( {isRunning && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
(running) {t("cookies.copy.running")}
</span> </span>
)} )}
</div> </div>
@@ -370,13 +388,17 @@ export function CookieCopyDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Target Profiles ({targetProfiles.length})</Label> <Label>
{t("cookies.copy.targetProfiles", {
count: targetProfiles.length,
})}
</Label>
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto"> <div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
{targetProfiles.length === 0 ? ( {targetProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{sourceProfileId {sourceProfileId
? "No other Wayfern/Camoufox profiles selected" ? t("cookies.copy.noOtherTargets")
: "Select a source profile first"} : t("cookies.copy.selectSourceFirst")}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -388,7 +410,7 @@ export function CookieCopyDialog({
{p.name} {p.name}
{runningProfiles.has(p.id) && ( {runningProfiles.has(p.id) && (
<span className="text-xs text-destructive"> <span className="text-xs text-destructive">
(running) {t("cookies.copy.running")}
</span> </span>
)} )}
</span> </span>
@@ -402,11 +424,13 @@ export function CookieCopyDialog({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label> <Label>
Select Cookies{" "} {t("cookies.copy.selectCookies")}{" "}
{cookieData && ( {cookieData && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({selectedCookieCount} of {cookieData.total_count}{" "} {t("cookies.copy.selectionStatus", {
selected) selected: selectedCookieCount,
total: cookieData.total_count,
})}
</span> </span>
)} )}
</Label> </Label>
@@ -415,7 +439,7 @@ export function CookieCopyDialog({
<div className="relative"> <div className="relative">
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
placeholder="Search domains or cookies..." placeholder={t("cookies.copy.searchPlaceholder")}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -435,8 +459,8 @@ export function CookieCopyDialog({
) : filteredDomains.length === 0 ? ( ) : filteredDomains.length === 0 ? (
<div className="p-4 text-center text-muted-foreground"> <div className="p-4 text-center text-muted-foreground">
{searchQuery {searchQuery
? "No matching cookies found" ? t("cookies.copy.noMatching")
: "No cookies found"} : t("cookies.copy.noFound")}
</div> </div>
) : ( ) : (
<ScrollArea className="h-[250px] border rounded-md"> <ScrollArea className="h-[250px] border rounded-md">
@@ -457,8 +481,7 @@ export function CookieCopyDialog({
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Existing cookies with the same name and domain will be replaced. {t("cookies.copy.replaceNote")}
Other cookies will be kept.
</p> </p>
</div> </div>
)} )}
@@ -470,15 +493,22 @@ export function CookieCopyDialog({
onClick={onClose} onClick={onClose}
disabled={isCopying} disabled={isCopying}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isCopying} isLoading={isCopying}
onClick={() => void handleCopy()} onClick={() => void handleCopy()}
disabled={!canCopy} disabled={!canCopy}
> >
Copy {selectedCookieCount > 0 ? `${selectedCookieCount} ` : ""} {selectedCookieCount === 0
Cookie{selectedCookieCount !== 1 ? "s" : ""} ? t("cookies.copy.copyButtonEmpty")
: selectedCookieCount === 1
? t("cookies.copy.copyButton_one", {
count: selectedCookieCount,
})
: t("cookies.copy.copyButton_other", {
count: selectedCookieCount,
})}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+68 -41
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs"; import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu"; import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
@@ -122,6 +123,7 @@ export function CookieManagementDialog({
profile, profile,
initialTab = "import", initialTab = "import",
}: CookieManagementDialogProps) { }: CookieManagementDialogProps) {
const { t } = useTranslation();
// Import state // Import state
const [fileContent, setFileContent] = useState<string | null>(null); const [fileContent, setFileContent] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null); const [fileName, setFileName] = useState<string | null>(null);
@@ -171,13 +173,15 @@ export function CookieManagementDialog({
setExportSelection(initSelectionFromCookieData(result)); setExportSelection(initSelectionFromCookieData(result));
} catch (err) { } catch (err) {
toast.error( toast.error(
`Failed to load cookies: ${err instanceof Error ? err.message : String(err)}`, t("cookies.management.loadFailed", {
error: err instanceof Error ? err.message : String(err),
}),
); );
} finally { } finally {
setIsLoadingExportCookies(false); setIsLoadingExportCookies(false);
} }
}, },
[exportCookieData], [exportCookieData, t],
); );
useEffect(() => { useEffect(() => {
@@ -220,19 +224,22 @@ export function CookieManagementDialog({
[resetImportState, resetExportState], [resetImportState, resetExportState],
); );
const handleFileRead = useCallback((file: File) => { const handleFileRead = useCallback(
const reader = new FileReader(); (file: File) => {
reader.onload = (e) => { const reader = new FileReader();
const content = e.target?.result as string; reader.onload = (e) => {
setFileContent(content); const content = e.target?.result as string;
setFileName(file.name); setFileContent(content);
setCookieCount(countCookies(content)); setFileName(file.name);
}; setCookieCount(countCookies(content));
reader.onerror = () => { };
toast.error("Failed to read file"); reader.onerror = () => {
}; toast.error(t("cookies.management.fileReadError"));
reader.readAsText(file); };
}, []); reader.readAsText(file);
},
[t],
);
const handleImport = useCallback(async () => { const handleImport = useCallback(async () => {
if (!fileContent || !profile) return; if (!fileContent || !profile) return;
@@ -297,14 +304,14 @@ export function CookieManagementDialog({
} }
await writeTextFile(filePath, content); await writeTextFile(filePath, content);
toast.success("Cookies exported successfully"); toast.success(t("cookies.export.success"));
handleClose(); handleClose();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : String(error)); toast.error(error instanceof Error ? error.message : String(error));
} finally { } finally {
setIsExporting(false); setIsExporting(false);
} }
}, [profile, format, getSelectedCookies, handleClose]); }, [profile, format, getSelectedCookies, handleClose, t]);
const toggleDomain = useCallback( const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => { (domain: string, cookies: UnifiedCookie[]) => {
@@ -385,7 +392,7 @@ export function CookieManagementDialog({
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Cookie Management</DialogTitle> <DialogTitle>{t("cookies.management.title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<Tabs <Tabs
@@ -394,15 +401,19 @@ export function CookieManagementDialog({
className="w-full" className="w-full"
> >
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="import">Import</TabsTrigger> <TabsTrigger value="import">
<TabsTrigger value="export">Export</TabsTrigger> {t("cookies.management.tabImport")}
</TabsTrigger>
<TabsTrigger value="export">
{t("cookies.management.tabExport")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="import" className="space-y-4 mt-4"> <TabsContent value="import" className="space-y-4 mt-4">
{!fileContent && ( {!fileContent && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Import cookies from a Netscape or JSON format file. {t("cookies.management.importDescription")}
</p> </p>
<div <div
role="button" role="button"
@@ -420,9 +431,11 @@ export function CookieManagementDialog({
> >
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" /> <LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Click to choose a cookie file {t("cookies.management.dropPrompt")}
<br /> <br />
<span className="text-xs">(.txt, .cookies, or .json)</span> <span className="text-xs">
{t("cookies.management.fileFormats")}
</span>
</p> </p>
<input <input
id="cookie-file-input" id="cookie-file-input"
@@ -445,20 +458,22 @@ export function CookieManagementDialog({
<div> <div>
<div className="font-medium">{fileName}</div> <div className="font-medium">{fileName}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{cookieCount} cookies found {t("cookies.management.cookiesFound", {
count: cookieCount,
})}
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={resetImportState}> <RippleButton variant="outline" onClick={resetImportState}>
Back {t("cookies.management.backButton")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isImporting} isLoading={isImporting}
onClick={() => void handleImport()} onClick={() => void handleImport()}
disabled={cookieCount === 0} disabled={cookieCount === 0}
> >
Import {t("cookies.management.importButton")}
</LoadingButton> </LoadingButton>
</div> </div>
</div> </div>
@@ -468,17 +483,23 @@ export function CookieManagementDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="p-4 rounded-lg bg-success/10"> <div className="p-4 rounded-lg bg-success/10">
<div className="font-medium text-success"> <div className="font-medium text-success">
Successfully imported {importResult.cookies_imported}{" "} {t("cookies.management.importedSuccess", {
cookies ({importResult.cookies_replaced} replaced) imported: importResult.cookies_imported,
replaced: importResult.cookies_replaced,
})}
</div> </div>
{importResult.errors.length > 0 && ( {importResult.errors.length > 0 && (
<div className="mt-2 text-sm text-muted-foreground"> <div className="mt-2 text-sm text-muted-foreground">
{importResult.errors.length} line(s) skipped {t("cookies.management.linesSkipped", {
count: importResult.errors.length,
})}
</div> </div>
)} )}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<RippleButton onClick={handleClose}>Done</RippleButton> <RippleButton onClick={handleClose}>
{t("cookies.management.doneButton")}
</RippleButton>
</div> </div>
</div> </div>
)} )}
@@ -486,7 +507,7 @@ export function CookieManagementDialog({
<TabsContent value="export" className="space-y-3 mt-4"> <TabsContent value="export" className="space-y-3 mt-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Format</Label> <Label>{t("cookies.export.formatLabel")}</Label>
<Select <Select
value={format} value={format}
onValueChange={(v) => { onValueChange={(v) => {
@@ -497,8 +518,12 @@ export function CookieManagementDialog({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="json">JSON</SelectItem> <SelectItem value="json">
<SelectItem value="netscape">Netscape TXT</SelectItem> {t("cookies.export.json")}
</SelectItem>
<SelectItem value="netscape">
{t("cookies.export.netscape")}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -506,11 +531,13 @@ export function CookieManagementDialog({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label> <Label>
Cookies{" "} {t("cookies.management.cookiesLabel")}{" "}
{exportCookieData && ( {exportCookieData && (
<span className="text-muted-foreground font-normal"> <span className="text-muted-foreground font-normal">
({selectedExportCount} of {exportCookieData.total_count}{" "} {t("cookies.management.selectionStatus", {
selected) selected: selectedExportCount,
total: exportCookieData.total_count,
})}
</span> </span>
)} )}
</Label> </Label>
@@ -521,8 +548,8 @@ export function CookieManagementDialog({
onClick={toggleSelectAll} onClick={toggleSelectAll}
> >
{selectedExportCount === exportCookieData.total_count {selectedExportCount === exportCookieData.total_count
? "Deselect all" ? t("cookies.management.deselectAll")
: "Select all"} : t("cookies.management.selectAll")}
</button> </button>
)} )}
</div> </div>
@@ -533,7 +560,7 @@ export function CookieManagementDialog({
</div> </div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? ( ) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md"> <div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
No cookies found in this profile {t("cookies.management.noCookies")}
</div> </div>
) : ( ) : (
<ScrollArea className="h-[200px] border rounded-md"> <ScrollArea className="h-[200px] border rounded-md">
@@ -556,14 +583,14 @@ export function CookieManagementDialog({
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isExporting} isLoading={isExporting}
onClick={() => void handleExport()} onClick={() => void handleExport()}
disabled={selectedExportCount === 0} disabled={selectedExportCount === 0}
> >
Export {t("cookies.management.exportButton")}
</LoadingButton> </LoadingButton>
</div> </div>
</TabsContent> </TabsContent>
+11 -11
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { import {
@@ -28,6 +29,7 @@ export function CreateGroupDialog({
onClose, onClose,
onGroupCreated, onGroupCreated,
}: CreateGroupDialogProps) { }: CreateGroupDialogProps) {
const { t } = useTranslation();
const [groupName, setGroupName] = useState(""); const [groupName, setGroupName] = useState("");
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -42,20 +44,20 @@ export function CreateGroupDialog({
name: groupName.trim(), name: groupName.trim(),
}); });
toast.success("Group created successfully"); toast.success(t("groups.createSuccess"));
onGroupCreated(newGroup); onGroupCreated(newGroup);
setGroupName(""); setGroupName("");
onClose(); onClose();
} catch (err) { } catch (err) {
console.error("Failed to create group:", err); console.error("Failed to create group:", err);
const errorMessage = const errorMessage =
err instanceof Error ? err.message : "Failed to create group"; err instanceof Error ? err.message : t("groups.createFailed");
setError(errorMessage); setError(errorMessage);
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
}, [groupName, onGroupCreated, onClose]); }, [groupName, onGroupCreated, onClose, t]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setGroupName(""); setGroupName("");
@@ -67,18 +69,16 @@ export function CreateGroupDialog({
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Create New Group</DialogTitle> <DialogTitle>{t("groups.createTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>{t("groups.createDescription")}</DialogDescription>
Create a new group to organize your browser profiles.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="group-name">Group Name</Label> <Label htmlFor="group-name">{t("groups.form.name")}</Label>
<Input <Input
id="group-name" id="group-name"
placeholder="Enter group name..." placeholder={t("groups.form.namePlaceholder")}
value={groupName} value={groupName}
onChange={(e) => { onChange={(e) => {
setGroupName(e.target.value); setGroupName(e.target.value);
@@ -105,14 +105,14 @@ export function CreateGroupDialog({
onClick={handleClose} onClick={handleClose}
disabled={isCreating} disabled={isCreating}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isCreating} isLoading={isCreating}
onClick={() => void handleCreate()} onClick={() => void handleCreate()}
disabled={!groupName.trim()} disabled={!groupName.trim()}
> >
Create {t("common.buttons.create")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+256 -120
View File
@@ -86,6 +86,7 @@ interface CreateProfileDialogProps {
ephemeral?: boolean; ephemeral?: boolean;
dnsBlocklist?: string; dnsBlocklist?: string;
launchHook?: string; launchHook?: string;
password?: string;
}) => Promise<void>; }) => Promise<void>;
selectedGroupId?: string; selectedGroupId?: string;
crossOsUnlocked?: boolean; crossOsUnlocked?: boolean;
@@ -170,6 +171,11 @@ export function CreateProfileDialog({
const [showProxyForm, setShowProxyForm] = useState(false); const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [ephemeral, setEphemeral] = 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] = const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
useState<string>(); useState<string>();
const [extensionGroups, setExtensionGroups] = useState< const [extensionGroups, setExtensionGroups] = useState<
@@ -370,12 +376,30 @@ export function CreateProfileDialog({
const handleCreate = async () => { const handleCreate = async () => {
if (!profileName.trim()) return; 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); setIsCreating(true);
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false; const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId; const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId = const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined; isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
: undefined;
try { try {
if (activeTab === "anti-detect") { if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected // Anti-detect browser - check if Wayfern or Camoufox is selected
@@ -403,6 +427,7 @@ export function CreateProfileDialog({
ephemeral, ephemeral,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined, launchHook: launchHook.trim() || undefined,
password: passwordToSet,
}); });
} else { } else {
// Default to Camoufox // Default to Camoufox
@@ -430,6 +455,7 @@ export function CreateProfileDialog({
ephemeral, ephemeral,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined, launchHook: launchHook.trim() || undefined,
password: passwordToSet,
}); });
} }
} else { } else {
@@ -455,6 +481,7 @@ export function CreateProfileDialog({
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined, launchHook: launchHook.trim() || undefined,
password: passwordToSet,
}); });
} }
@@ -488,6 +515,10 @@ export function CreateProfileDialog({
os: getCurrentOS() as WayfernOS, // Reset to current OS os: getCurrentOS() as WayfernOS, // Reset to current OS
}); });
setEphemeral(false); setEphemeral(false);
setEnablePassword(false);
setPassword("");
setPasswordConfirm("");
setPasswordError(null);
onClose(); onClose();
}; };
@@ -537,7 +568,7 @@ export function CreateProfileDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-full max-h-[90vh] flex flex-col"> <DialogContent className="max-w-md max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle> <DialogTitle>
{currentStep === "browser-selection" {currentStep === "browser-selection"
@@ -625,10 +656,10 @@ export function CreateProfileDialog({
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
Regular Browsers {t("createProfile.regular.title")}
</h3> </h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Choose from supported regular browsers {t("createProfile.regular.description")}
</p> </p>
</div> </div>
@@ -655,7 +686,7 @@ export function CreateProfileDialog({
{browser.label} {browser.label}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Regular Browser {t("createProfile.regular.badge")}
</div> </div>
</div> </div>
</Button> </Button>
@@ -672,7 +703,9 @@ export function CreateProfileDialog({
<div className="space-y-6"> <div className="space-y-6">
{/* Profile Name */} {/* Profile Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label> <Label htmlFor="profile-name">
{t("createProfile.profileName")}
</Label>
<Input <Input
id="profile-name" id="profile-name"
value={profileName} value={profileName}
@@ -688,7 +721,9 @@ export function CreateProfileDialog({
void handleCreate(); void handleCreate();
} }
}} }}
placeholder="Enter profile name" placeholder={t(
"createProfile.profileNamePlaceholder",
)}
/> />
</div> </div>
@@ -705,15 +740,74 @@ export function CreateProfileDialog({
<Label htmlFor="ephemeral" className="font-medium"> <Label htmlFor="ephemeral" className="font-medium">
{t("profiles.ephemeral")} {t("profiles.ephemeral")}
</Label> </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> </div>
<p className="text-sm text-muted-foreground ml-6"> <p className="text-sm text-muted-foreground ml-6">
{t("profiles.ephemeralDescription")} {t("profiles.ephemeralDescription")}
</p> </p>
</div> </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" ? ( {selectedBrowser === "wayfern" ? (
// Wayfern Configuration // Wayfern Configuration
<div className="space-y-6"> <div className="space-y-6">
@@ -722,7 +816,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center p-3 rounded-md border"> <div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" /> <div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Fetching available versions... {t("createProfile.version.fetching")}
</p> </p>
</div> </div>
)} )}
@@ -739,7 +833,7 @@ export function CreateProfileDialog({
size="sm" size="sm"
variant="outline" variant="outline"
> >
Retry {t("common.buttons.retry")}
</RippleButton> </RippleButton>
</div> </div>
)} )}
@@ -748,8 +842,9 @@ export function CreateProfileDialog({
!getBestAvailableVersion("wayfern") && ( !getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10"> <div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning"> <p className="text-sm text-warning">
Wayfern is not available on your platform {t("createProfile.platformUnavailable", {
yet. browser: "Wayfern",
})}
</p> </p>
</div> </div>
)} )}
@@ -760,11 +855,12 @@ export function CreateProfileDialog({
getBestAvailableVersion("wayfern") && ( getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border"> <div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{(() => { {t("createProfile.version.needsDownload", {
const bestVersion = browser: "Wayfern",
getBestAvailableVersion("wayfern"); version:
return `Wayfern version (${bestVersion?.version}) needs to be downloaded`; getBestAvailableVersion("wayfern")
})()} ?.version,
})}
</p> </p>
<LoadingButton <LoadingButton
onClick={() => { onClick={() => {
@@ -779,8 +875,8 @@ export function CreateProfileDialog({
)} )}
> >
{isBrowserCurrentlyDownloading("wayfern") {isBrowserCurrentlyDownloading("wayfern")
? "Downloading..." ? t("common.buttons.downloading")
: "Download"} : t("common.buttons.download")}
</LoadingButton> </LoadingButton>
</div> </div>
)} )}
@@ -789,20 +885,22 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading("wayfern") && !isBrowserCurrentlyDownloading("wayfern") &&
isBrowserVersionAvailable("wayfern") && ( isBrowserVersionAvailable("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground"> <div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => { {" "}
const bestVersion = {t("createProfile.version.available", {
getBestAvailableVersion("wayfern"); browser: "Wayfern",
return `✓ Wayfern version (${bestVersion?.version}) is available`; version:
})()} getBestAvailableVersion("wayfern")
?.version,
})}
</div> </div>
)} )}
{isBrowserCurrentlyDownloading("wayfern") && ( {isBrowserCurrentlyDownloading("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground"> <div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => { {t("createProfile.version.downloading", {
const bestVersion = browser: "Wayfern",
getBestAvailableVersion("wayfern"); version:
return `Downloading Wayfern version (${bestVersion?.version})...`; getBestAvailableVersion("wayfern")?.version,
})()} })}
</div> </div>
)} )}
@@ -826,7 +924,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center p-3 rounded-md border"> <div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" /> <div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Fetching available versions... {t("createProfile.version.fetching")}
</p> </p>
</div> </div>
)} )}
@@ -843,7 +941,7 @@ export function CreateProfileDialog({
size="sm" size="sm"
variant="outline" variant="outline"
> >
Retry {t("common.buttons.retry")}
</RippleButton> </RippleButton>
</div> </div>
)} )}
@@ -852,8 +950,9 @@ export function CreateProfileDialog({
!getBestAvailableVersion("camoufox") && ( !getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10"> <div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning"> <p className="text-sm text-warning">
Camoufox is not available on your platform {t("createProfile.platformUnavailable", {
yet. browser: "Camoufox",
})}
</p> </p>
</div> </div>
)} )}
@@ -864,11 +963,12 @@ export function CreateProfileDialog({
getBestAvailableVersion("camoufox") && ( getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border"> <div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{(() => { {t("createProfile.version.needsDownload", {
const bestVersion = browser: "Camoufox",
getBestAvailableVersion("camoufox"); version:
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`; getBestAvailableVersion("camoufox")
})()} ?.version,
})}
</p> </p>
<LoadingButton <LoadingButton
onClick={() => { onClick={() => {
@@ -883,8 +983,8 @@ export function CreateProfileDialog({
)} )}
> >
{isBrowserCurrentlyDownloading("camoufox") {isBrowserCurrentlyDownloading("camoufox")
? "Downloading..." ? t("common.buttons.downloading")
: "Download"} : t("common.buttons.download")}
</LoadingButton> </LoadingButton>
</div> </div>
)} )}
@@ -893,20 +993,23 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading("camoufox") && !isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && ( isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground"> <div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => { {" "}
const bestVersion = {t("createProfile.version.available", {
getBestAvailableVersion("camoufox"); browser: "Camoufox",
return `✓ Camoufox version (${bestVersion?.version}) is available`; version:
})()} getBestAvailableVersion("camoufox")
?.version,
})}
</div> </div>
)} )}
{isBrowserCurrentlyDownloading("camoufox") && ( {isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground"> <div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => { {t("createProfile.version.downloading", {
const bestVersion = browser: "Camoufox",
getBestAvailableVersion("camoufox"); version:
return `Downloading Camoufox version (${bestVersion?.version})...`; getBestAvailableVersion("camoufox")
})()} ?.version,
})}
</div> </div>
)} )}
@@ -940,7 +1043,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" /> <div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Fetching available versions... {t("createProfile.version.fetching")}
</p> </p>
</div> </div>
)} )}
@@ -971,13 +1074,15 @@ export function CreateProfileDialog({
getBestAvailableVersion(selectedBrowser) && ( getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{(() => { {t(
const bestVersion = "createProfile.version.latestNeedsDownload",
getBestAvailableVersion( {
selectedBrowser, version:
); getBestAvailableVersion(
return `Latest version (${bestVersion?.version}) needs to be downloaded`; selectedBrowser,
})()} )?.version,
},
)}
</p> </p>
<LoadingButton <LoadingButton
onClick={() => { onClick={() => {
@@ -992,7 +1097,7 @@ export function CreateProfileDialog({
selectedBrowser, selectedBrowser,
)} )}
> >
Download {t("common.buttons.download")}
</LoadingButton> </LoadingButton>
</div> </div>
)} )}
@@ -1005,26 +1110,31 @@ export function CreateProfileDialog({
selectedBrowser, selectedBrowser,
) && ( ) && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{(() => { {" "}
const bestVersion = {t(
getBestAvailableVersion( "createProfile.version.latestAvailable",
selectedBrowser, {
); version:
return `✓ Latest version (${bestVersion?.version}) is available`; getBestAvailableVersion(
})()} selectedBrowser,
)?.version,
},
)}
</div> </div>
)} )}
{isBrowserCurrentlyDownloading( {isBrowserCurrentlyDownloading(
selectedBrowser, selectedBrowser,
) && ( ) && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{(() => { {t(
const bestVersion = "createProfile.version.latestDownloading",
getBestAvailableVersion( {
selectedBrowser, version:
); getBestAvailableVersion(
return `Downloading version (${bestVersion?.version})...`; selectedBrowser,
})()} )?.version,
},
)}
</div> </div>
)} )}
</div> </div>
@@ -1035,7 +1145,7 @@ export function CreateProfileDialog({
{/* Proxy / VPN Selection - Always visible */} {/* Proxy / VPN Selection - Always visible */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label>Proxy / VPN</Label> <Label>{t("createProfile.proxy.title")}</Label>
<RippleButton <RippleButton
size="sm" size="sm"
variant="outline" variant="outline"
@@ -1044,7 +1154,8 @@ export function CreateProfileDialog({
}} }}
className="px-2 h-7 text-xs" className="px-2 h-7 text-xs"
> >
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy <GoPlus className="mr-1 w-3 h-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton> </RippleButton>
</div> </div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? ( {storedProxies.length > 0 || vpnConfigs.length > 0 ? (
@@ -1061,7 +1172,7 @@ export function CreateProfileDialog({
> >
{(() => { {(() => {
if (!selectedProxyId) if (!selectedProxyId)
return "No proxy / VPN"; return t("createProfile.proxy.noProxy");
if (selectedProxyId.startsWith("vpn-")) { if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find( const vpn = vpnConfigs.find(
(v) => (v) =>
@@ -1069,12 +1180,15 @@ export function CreateProfileDialog({
); );
return vpn return vpn
? `WG — ${vpn.name}` ? `WG — ${vpn.name}`
: "No proxy / VPN"; : t("createProfile.proxy.noProxy");
} }
const proxy = storedProxies.find( const proxy = storedProxies.find(
(p) => p.id === selectedProxyId, (p) => p.id === selectedProxyId,
); );
return proxy?.name ?? "No proxy / VPN"; return (
proxy?.name ??
t("createProfile.proxy.noProxy")
);
})()} })()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -1084,10 +1198,14 @@ export function CreateProfileDialog({
sideOffset={8} sideOffset={8}
> >
<Command> <Command>
<CommandInput placeholder="Search proxies or VPNs..." /> <CommandInput
placeholder={t(
"createProfile.proxy.search",
)}
/>
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
No proxies or VPNs found. {t("createProfile.proxy.notFound")}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
@@ -1105,7 +1223,7 @@ export function CreateProfileDialog({
: "opacity-0", : "opacity-0",
)} )}
/> />
None {t("common.labels.none")}
</CommandItem> </CommandItem>
{storedProxies.map((proxy) => ( {storedProxies.map((proxy) => (
<CommandItem <CommandItem
@@ -1167,8 +1285,7 @@ export function CreateProfileDialog({
</Popover> </Popover>
) : ( ) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground"> <div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies or VPNs available. Add one to route {t("createProfile.proxy.noProxiesAvailable")}
this profile's traffic.
</div> </div>
)} )}
</div> </div>
@@ -1265,7 +1382,9 @@ export function CreateProfileDialog({
<div className="space-y-6"> <div className="space-y-6">
{/* Profile Name */} {/* Profile Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label> <Label htmlFor="profile-name">
{t("createProfile.profileName")}
</Label>
<Input <Input
id="profile-name" id="profile-name"
value={profileName} value={profileName}
@@ -1281,7 +1400,9 @@ export function CreateProfileDialog({
void handleCreate(); void handleCreate();
} }
}} }}
placeholder="Enter profile name" placeholder={t(
"createProfile.profileNamePlaceholder",
)}
/> />
</div> </div>
@@ -1310,7 +1431,7 @@ export function CreateProfileDialog({
size="sm" size="sm"
variant="outline" variant="outline"
> >
Retry {t("common.buttons.retry")}
</RippleButton> </RippleButton>
</div> </div>
)} )}
@@ -1323,13 +1444,15 @@ export function CreateProfileDialog({
getBestAvailableVersion(selectedBrowser) && ( getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{(() => { {t(
const bestVersion = "createProfile.version.latestNeedsDownload",
getBestAvailableVersion( {
selectedBrowser, version:
); getBestAvailableVersion(
return `Latest version (${bestVersion?.version}) needs to be downloaded`; selectedBrowser,
})()} )?.version,
},
)}
</p> </p>
<LoadingButton <LoadingButton
onClick={() => { onClick={() => {
@@ -1344,7 +1467,7 @@ export function CreateProfileDialog({
selectedBrowser, selectedBrowser,
)} )}
> >
Download {t("common.buttons.download")}
</LoadingButton> </LoadingButton>
</div> </div>
)} )}
@@ -1355,24 +1478,30 @@ export function CreateProfileDialog({
) && ) &&
isBrowserVersionAvailable(selectedBrowser) && ( isBrowserVersionAvailable(selectedBrowser) && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{(() => { {" "}
const bestVersion = {t(
getBestAvailableVersion( "createProfile.version.latestAvailable",
selectedBrowser, {
); version:
return `✓ Latest version (${bestVersion?.version}) is available`; getBestAvailableVersion(
})()} selectedBrowser,
)?.version,
},
)}
</div> </div>
)} )}
{isBrowserCurrentlyDownloading( {isBrowserCurrentlyDownloading(
selectedBrowser, selectedBrowser,
) && ( ) && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{(() => { {t(
const bestVersion = "createProfile.version.latestDownloading",
getBestAvailableVersion(selectedBrowser); {
return `Downloading version (${bestVersion?.version})...`; version:
})()} getBestAvailableVersion(selectedBrowser)
?.version,
},
)}
</div> </div>
)} )}
</div> </div>
@@ -1382,7 +1511,7 @@ export function CreateProfileDialog({
{/* Proxy / VPN Selection - Always visible */} {/* Proxy / VPN Selection - Always visible */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label>Proxy / VPN</Label> <Label>{t("createProfile.proxy.title")}</Label>
<RippleButton <RippleButton
size="sm" size="sm"
variant="outline" variant="outline"
@@ -1391,7 +1520,8 @@ export function CreateProfileDialog({
}} }}
className="px-2 h-7 text-xs" className="px-2 h-7 text-xs"
> >
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy <GoPlus className="mr-1 w-3 h-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton> </RippleButton>
</div> </div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? ( {storedProxies.length > 0 || vpnConfigs.length > 0 ? (
@@ -1408,7 +1538,7 @@ export function CreateProfileDialog({
> >
{(() => { {(() => {
if (!selectedProxyId) if (!selectedProxyId)
return "No proxy / VPN"; return t("createProfile.proxy.noProxy");
if (selectedProxyId.startsWith("vpn-")) { if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find( const vpn = vpnConfigs.find(
(v) => (v) =>
@@ -1416,12 +1546,15 @@ export function CreateProfileDialog({
); );
return vpn return vpn
? `WG — ${vpn.name}` ? `WG — ${vpn.name}`
: "No proxy / VPN"; : t("createProfile.proxy.noProxy");
} }
const proxy = storedProxies.find( const proxy = storedProxies.find(
(p) => p.id === selectedProxyId, (p) => p.id === selectedProxyId,
); );
return proxy?.name ?? "No proxy / VPN"; return (
proxy?.name ??
t("createProfile.proxy.noProxy")
);
})()} })()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -1431,10 +1564,14 @@ export function CreateProfileDialog({
sideOffset={8} sideOffset={8}
> >
<Command> <Command>
<CommandInput placeholder="Search proxies or VPNs..." /> <CommandInput
placeholder={t(
"createProfile.proxy.search",
)}
/>
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
No proxies or VPNs found. {t("createProfile.proxy.notFound")}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
@@ -1452,7 +1589,7 @@ export function CreateProfileDialog({
: "opacity-0", : "opacity-0",
)} )}
/> />
None {t("common.labels.none")}
</CommandItem> </CommandItem>
{storedProxies.map((proxy) => ( {storedProxies.map((proxy) => (
<CommandItem <CommandItem
@@ -1514,8 +1651,7 @@ export function CreateProfileDialog({
</Popover> </Popover>
) : ( ) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground"> <div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies or VPNs available. Add one to route {t("createProfile.proxy.noProxiesAvailable")}
this profile's traffic.
</div> </div>
)} )}
</div> </div>
@@ -1549,19 +1685,19 @@ export function CreateProfileDialog({
{currentStep === "browser-config" ? ( {currentStep === "browser-config" ? (
<> <>
<RippleButton variant="outline" onClick={handleBack}> <RippleButton variant="outline" onClick={handleBack}>
Back {t("common.buttons.back")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
onClick={handleCreate} onClick={handleCreate}
isLoading={isCreating} isLoading={isCreating}
disabled={isCreateDisabled} disabled={isCreateDisabled}
> >
Create {t("common.buttons.create")}
</LoadingButton> </LoadingButton>
</> </>
) : ( ) : (
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
)} )}
</DialogFooter> </DialogFooter>
+9 -5
View File
@@ -49,6 +49,7 @@
*/ */
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */ /** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
import { useTranslation } from "react-i18next";
import { import {
LuCheckCheck, LuCheckCheck,
LuDownload, LuDownload,
@@ -214,6 +215,7 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
} }
export function UnifiedToast(props: ToastProps) { export function UnifiedToast(props: ToastProps) {
const { t } = useTranslation();
const { title, description, type, action, onCancel } = props; const { title, description, type, action, onCancel } = props;
const stage = "stage" in props ? props.stage : undefined; const stage = "stage" in props ? props.stage : undefined;
const progress = "progress" in props ? props.progress : undefined; const progress = "progress" in props ? props.progress : undefined;
@@ -231,7 +233,7 @@ export function UnifiedToast(props: ToastProps) {
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0" className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label="Cancel" aria-label={t("common.buttons.cancel")}
> >
<LuX className="w-3 h-3" /> <LuX className="w-3 h-3" />
</button> </button>
@@ -292,7 +294,9 @@ export function UnifiedToast(props: ToastProps) {
"completed_files" in progress && ( "completed_files" in progress && (
<div className="mt-1"> <div className="mt-1">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "} {progress.phase === "uploading"
? t("appUpdate.toast.uploading")
: t("appUpdate.toast.downloading")}{" "}
{progress.completed_files}/{progress.total_files} files {progress.completed_files}/{progress.total_files} files
{" \u2022 "} {" \u2022 "}
{formatBytesCompact(progress.completed_bytes)} /{" "} {formatBytesCompact(progress.completed_bytes)} /{" "}
@@ -347,17 +351,17 @@ export function UnifiedToast(props: ToastProps) {
<> <>
{stage === "extracting" && ( {stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Extracting browser files... Please do not close the app. {t("browserDownload.toast.extracting")}
</p> </p>
)} )}
{stage === "verifying" && ( {stage === "verifying" && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Verifying browser files... {t("browserDownload.toast.verifying")}
</p> </p>
)} )}
{stage === "downloading (twilight rolling release)" && ( {stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Downloading rolling release build... {t("browserDownload.toast.downloadingRolling")}
</p> </p>
)} )}
</> </>
+7 -3
View File
@@ -4,6 +4,7 @@ import type { Table } from "@tanstack/react-table";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import * as React from "react"; import * as React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import { useTranslation } from "react-i18next";
import { LuX } from "react-icons/lu"; import { LuX } from "react-icons/lu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -134,6 +135,7 @@ interface DataTableActionBarSelectionProps<TData> {
function DataTableActionBarSelection<TData>({ function DataTableActionBarSelection<TData>({
table, table,
}: DataTableActionBarSelectionProps<TData>) { }: DataTableActionBarSelectionProps<TData>) {
const { t } = useTranslation();
const onClearSelection = React.useCallback(() => { const onClearSelection = React.useCallback(() => {
table.toggleAllRowsSelected(false); table.toggleAllRowsSelected(false);
}, [table]); }, [table]);
@@ -141,7 +143,9 @@ function DataTableActionBarSelection<TData>({
return ( return (
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5"> <div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
<span className="whitespace-nowrap text-xs"> <span className="whitespace-nowrap text-xs">
{table.getFilteredSelectedRowModel().rows.length} selected {t("dataTableActionBar.selected", {
count: table.getFilteredSelectedRowModel().rows.length,
})}
</span> </span>
<div className="mr-1 ml-2 h-4 w-px bg-border" /> <div className="mr-1 ml-2 h-4 w-px bg-border" />
<Tooltip> <Tooltip>
@@ -159,9 +163,9 @@ function DataTableActionBarSelection<TData>({
sideOffset={10} sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden" className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
> >
<p>Clear selection</p> <p>{t("dataTableActionBar.clearSelection")}</p>
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs"> <kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
<abbr title="Escape" className="no-underline"> <abbr title={t("common.keys.escape")} className="no-underline">
Esc Esc
</abbr> </abbr>
</kbd> </kbd>
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslation } from "react-i18next";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -29,11 +30,12 @@ export function DeleteConfirmationDialog({
onConfirm, onConfirm,
title, title,
description, description,
confirmButtonText = "Delete", confirmButtonText,
isLoading = false, isLoading = false,
profileIds, profileIds,
profiles = [], profiles = [],
}: DeleteConfirmationDialogProps) { }: DeleteConfirmationDialogProps) {
const { t } = useTranslation();
const handleConfirm = async () => { const handleConfirm = async () => {
await onConfirm(); await onConfirm();
}; };
@@ -47,7 +49,7 @@ export function DeleteConfirmationDialog({
{profileIds && profileIds.length > 0 && ( {profileIds && profileIds.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<p className="text-sm font-medium mb-2"> <p className="text-sm font-medium mb-2">
Profiles to be deleted: {t("deleteDialog.profilesToDelete")}
</p> </p>
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto"> <div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
<ul className="space-y-1"> <ul className="space-y-1">
@@ -71,14 +73,14 @@ export function DeleteConfirmationDialog({
onClick={onClose} onClick={onClose}
disabled={isLoading} disabled={isLoading}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
variant="destructive" variant="destructive"
onClick={() => void handleConfirm()} onClick={() => void handleConfirm()}
isLoading={isLoading} isLoading={isLoading}
> >
{confirmButtonText} {confirmButtonText ?? t("common.buttons.delete")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+20 -20
View File
@@ -56,11 +56,13 @@ export function DeleteGroupDialog({
setAssociatedProfiles(groupProfiles); setAssociatedProfiles(groupProfiles);
} catch (err) { } catch (err) {
console.error("Failed to load associated profiles:", err); console.error("Failed to load associated profiles:", err);
setError(err instanceof Error ? err.message : "Failed to load profiles"); setError(
err instanceof Error ? err.message : t("groups.loadProfilesFailed"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [group]); }, [group, t]);
useEffect(() => { useEffect(() => {
if (isOpen && group) { if (isOpen && group) {
@@ -90,19 +92,19 @@ export function DeleteGroupDialog({
// Delete the group // Delete the group
await invoke("delete_profile_group", { groupId: group.id }); await invoke("delete_profile_group", { groupId: group.id });
toast.success("Group deleted successfully"); toast.success(t("groups.deleteSuccess"));
onGroupDeleted(); onGroupDeleted();
onClose(); onClose();
} catch (err) { } catch (err) {
console.error("Failed to delete group:", err); console.error("Failed to delete group:", err);
const errorMessage = const errorMessage =
err instanceof Error ? err.message : "Failed to delete group"; err instanceof Error ? err.message : t("groups.deleteFailed");
setError(errorMessage); setError(errorMessage);
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]); }, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose, t]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setError(null); setError(null);
@@ -115,17 +117,14 @@ export function DeleteGroupDialog({
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Group</DialogTitle> <DialogTitle>{t("groups.deleteTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>{t("groups.deleteDescription")}</DialogDescription>
This action cannot be undone. This will permanently delete the group
"{group?.name}".
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{isLoading ? ( {isLoading ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Loading associated profiles... {t("groups.loadingProfiles")}
</div> </div>
) : ( ) : (
<> <>
@@ -133,7 +132,9 @@ export function DeleteGroupDialog({
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
Associated Profiles ({associatedProfiles.length}) {t("groups.associatedProfiles", {
count: associatedProfiles.length,
})}
</Label> </Label>
<ScrollArea className="h-32 w-full border rounded-md p-3"> <ScrollArea className="h-32 w-full border rounded-md p-3">
<div className="space-y-1"> <div className="space-y-1">
@@ -147,7 +148,7 @@ export function DeleteGroupDialog({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Label>What should happen to these profiles?</Label> <Label>{t("groups.whatToDoWithProfiles")}</Label>
<RadioGroup <RadioGroup
value={deleteAction} value={deleteAction}
onValueChange={(value) => { onValueChange={(value) => {
@@ -166,7 +167,7 @@ export function DeleteGroupDialog({
htmlFor="delete" htmlFor="delete"
className="text-sm text-destructive" className="text-sm text-destructive"
> >
Delete profiles along with the group {t("groups.deleteAlongWithGroup")}
</Label> </Label>
</div> </div>
</RadioGroup> </RadioGroup>
@@ -176,7 +177,7 @@ export function DeleteGroupDialog({
{associatedProfiles.length === 0 && !isLoading && ( {associatedProfiles.length === 0 && !isLoading && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
This group has no associated profiles. {t("groups.noAssociatedProfiles")}
</div> </div>
)} )}
</> </>
@@ -195,7 +196,7 @@ export function DeleteGroupDialog({
onClick={handleClose} onClick={handleClose}
disabled={isDeleting} disabled={isDeleting}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
variant="destructive" variant="destructive"
@@ -203,10 +204,9 @@ export function DeleteGroupDialog({
onClick={() => void handleDelete()} onClick={() => void handleDelete()}
disabled={isLoading} disabled={isLoading}
> >
Delete Group {deleteAction === "delete" && associatedProfiles.length > 0
{deleteAction === "delete" && ? t("groups.deleteGroupAndProfiles")
associatedProfiles.length > 0 && : t("groups.deleteGroup")}
" & Profiles"}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -0,0 +1,119 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface DeviceCodeVerifyDialogProps {
isOpen: boolean;
onClose: (loginOccurred?: boolean) => void;
}
/**
* Dedicated dialog for pasting and verifying the cloud device-link code.
* Opens after the user clicks "Login" in the sync config dialog so the
* verify step is a focused step on its own and so it doesn't visually
* stack with other dialogs (e.g. the profile selector triggered by a
* deep link) sharing the same view.
*/
export function DeviceCodeVerifyDialog({
isOpen,
onClose,
}: DeviceCodeVerifyDialogProps) {
const { t } = useTranslation();
const { exchangeDeviceCode } = useCloudAuth();
const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
// Reset the field when the dialog reopens so a stale code from a
// previous attempt doesn't auto-populate.
useEffect(() => {
if (isOpen) {
setLinkCode("");
}
}, [isOpen]);
const handleVerify = async () => {
const trimmed = linkCode.trim();
if (!trimmed) return;
setIsVerifying(true);
try {
await exchangeDeviceCode(trimmed);
showSuccessToast(t("sync.cloud.loginSuccess"));
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
onClose(true);
} catch (error) {
console.error("Device-code exchange failed:", error);
showErrorToast(String(error));
} finally {
setIsVerifying(false);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose(false);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.cloud.verifyAndLogin")}</DialogTitle>
<DialogDescription>
{t("sync.cloud.deviceLinkInstructions")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="device-link-code">
{t("sync.cloud.linkCodeLabel")}
</Label>
<Input
id="device-link-code"
placeholder={t("sync.cloud.linkCodePlaceholder")}
value={linkCode}
onChange={(e) => {
setLinkCode(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && linkCode.trim()) {
void handleVerify();
}
}}
autoComplete="off"
spellCheck={false}
autoFocus
/>
<LoadingButton
onClick={() => void handleVerify()}
isLoading={isVerifying}
disabled={!linkCode.trim()}
className="w-full"
>
{isVerifying
? t("sync.cloud.loggingIn")
: t("sync.cloud.verifyAndLogin")}
</LoadingButton>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+11 -11
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { import {
@@ -30,6 +31,7 @@ export function EditGroupDialog({
group, group,
onGroupUpdated, onGroupUpdated,
}: EditGroupDialogProps) { }: EditGroupDialogProps) {
const { t } = useTranslation();
const [groupName, setGroupName] = useState(""); const [groupName, setGroupName] = useState("");
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -54,19 +56,19 @@ export function EditGroupDialog({
name: groupName.trim(), name: groupName.trim(),
}); });
toast.success("Group updated successfully"); toast.success(t("groups.updateSuccess"));
onGroupUpdated(updatedGroup); onGroupUpdated(updatedGroup);
onClose(); onClose();
} catch (err) { } catch (err) {
console.error("Failed to update group:", err); console.error("Failed to update group:", err);
const errorMessage = const errorMessage =
err instanceof Error ? err.message : "Failed to update group"; err instanceof Error ? err.message : t("groups.updateFailed");
setError(errorMessage); setError(errorMessage);
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
}, [group, groupName, onGroupUpdated, onClose]); }, [group, groupName, onGroupUpdated, onClose, t]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setError(null); setError(null);
@@ -77,18 +79,16 @@ export function EditGroupDialog({
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Group</DialogTitle> <DialogTitle>{t("groups.editTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>{t("groups.editDescription")}</DialogDescription>
Update the name of the group "{group?.name}".
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="group-name">Group Name</Label> <Label htmlFor="group-name">{t("groups.form.name")}</Label>
<Input <Input
id="group-name" id="group-name"
placeholder="Enter group name..." placeholder={t("groups.form.namePlaceholder")}
value={groupName} value={groupName}
onChange={(e) => { onChange={(e) => {
setGroupName(e.target.value); setGroupName(e.target.value);
@@ -115,14 +115,14 @@ export function EditGroupDialog({
onClick={handleClose} onClick={handleClose}
disabled={isUpdating} disabled={isUpdating}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isUpdating} isLoading={isUpdating}
onClick={() => void handleUpdate()} onClick={() => void handleUpdate()}
disabled={!groupName.trim() || groupName === group?.name} disabled={!groupName.trim() || groupName === group?.name}
> >
Update Group {t("groups.edit")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -55,12 +55,12 @@ export function ExtensionGroupAssignmentDialog({
} catch (err) { } catch (err) {
console.error("Failed to load extension groups:", err); console.error("Failed to load extension groups:", err);
setError( setError(
err instanceof Error ? err.message : "Failed to load extension groups", err instanceof Error ? err.message : t("extensions.loadGroupsFailed"),
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [t]);
const handleAssign = useCallback(async () => { const handleAssign = useCallback(async () => {
setIsAssigning(true); setIsAssigning(true);
@@ -79,7 +79,7 @@ export function ExtensionGroupAssignmentDialog({
} catch (err) { } catch (err) {
console.error("Failed to assign extension group:", err); console.error("Failed to assign extension group:", err);
const errorMessage = const errorMessage =
err instanceof Error ? err.message : "Failed to assign extension group"; err instanceof Error ? err.message : t("extensions.assignGroupFailed");
setError(errorMessage); setError(errorMessage);
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
+208 -193
View File
@@ -50,36 +50,43 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot( function getSyncStatusDot(
item: { sync_enabled?: boolean; last_sync?: number }, item: { sync_enabled?: boolean; last_sync?: number },
liveStatus: SyncStatus | undefined, liveStatus: SyncStatus | undefined,
t: (key: string, options?: Record<string, unknown>) => string,
): { color: string; tooltip: string; animate: boolean } { ): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled"); const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
switch (status) { switch (status) {
case "syncing": case "syncing":
return { color: "bg-warning", tooltip: "Syncing...", animate: true }; return {
color: "bg-warning",
tooltip: t("profileTable.syncTooltipSyncing"),
animate: true,
};
case "synced": case "synced":
return { return {
color: "bg-success", color: "bg-success",
tooltip: item.last_sync tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}` ? t("profileTable.syncTooltipSyncedAt", {
: "Synced", time: new Date(item.last_sync * 1000).toLocaleString(),
})
: t("profileTable.syncTooltipSynced"),
animate: false, animate: false,
}; };
case "waiting": case "waiting":
return { return {
color: "bg-warning", color: "bg-warning",
tooltip: "Waiting to sync", tooltip: t("profileTable.syncTooltipWaiting"),
animate: false, animate: false,
}; };
case "error": case "error":
return { return {
color: "bg-destructive", color: "bg-destructive",
tooltip: "Sync error", tooltip: t("profileTable.syncTooltipError"),
animate: false, animate: false,
}; };
default: default:
return { return {
color: "bg-muted-foreground", color: "bg-muted-foreground",
tooltip: "Not synced", tooltip: t("profileTable.syncTooltipNotSynced"),
animate: false, animate: false,
}; };
} }
@@ -674,6 +681,7 @@ export function ExtensionManagementDialog({
const syncDot = getSyncStatusDot( const syncDot = getSyncStatusDot(
ext, ext,
extSyncStatus[ext.id], extSyncStatus[ext.id],
t,
); );
return ( return (
<div <div
@@ -840,6 +848,7 @@ export function ExtensionManagementDialog({
const groupSyncDot = getSyncStatusDot( const groupSyncDot = getSyncStatusDot(
group, group,
extSyncStatus[group.id], extSyncStatus[group.id],
t,
); );
return ( return (
@@ -995,7 +1004,7 @@ export function ExtensionManagementDialog({
} }
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("extensions.editGroup")}</DialogTitle> <DialogTitle>{t("extensions.editGroup")}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -1003,87 +1012,89 @@ export function ExtensionManagementDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
<div className="space-y-2"> <div className="space-y-4">
<Label>{t("common.labels.name")}</Label>
<Input
value={editGroupName}
onChange={(e) => {
setEditGroupName(e.target.value);
}}
placeholder={t("extensions.groupNamePlaceholder")}
/>
</div>
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("extensions.addToGroup")}</Label> <Label>{t("common.labels.name")}</Label>
<Select <Input
value="" value={editGroupName}
onValueChange={(extId) => { onChange={(e) => {
setEditGroupExtensionIds((prev) => [...prev, extId]); setEditGroupName(e.target.value);
}} }}
> placeholder={t("extensions.groupNamePlaceholder")}
<SelectTrigger> />
<SelectValue placeholder={t("extensions.addToGroup")} />
</SelectTrigger>
<SelectContent>
{extensions
.filter((e) => !editGroupExtensionIds.includes(e.id))
.map((ext) => (
<SelectItem key={ext.id} value={ext.id}>
<div className="flex items-center gap-2">
{renderExtensionIcon(ext, "sm")}
{ext.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
)}
<div className="space-y-2"> {extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
<Label>{t("extensions.groupExtensions")}</Label> .length > 0 && (
{editGroupExtensionIds.length === 0 ? ( <div className="space-y-2">
<div className="text-sm text-muted-foreground py-2"> <Label>{t("extensions.addToGroup")}</Label>
{t("extensions.noExtensionsInGroup")} <Select
</div> value=""
) : ( onValueChange={(extId) => {
<div className="space-y-1 max-h-[200px] overflow-y-auto"> setEditGroupExtensionIds((prev) => [...prev, extId]);
{editGroupExtensionIds.map((extId) => { }}
const ext = extensions.find((e) => e.id === extId); >
if (!ext) return null; <SelectTrigger>
return ( <SelectValue placeholder={t("extensions.addToGroup")} />
<div </SelectTrigger>
key={extId} <SelectContent>
className="flex items-center gap-2 rounded-md border px-2 py-1.5" {extensions
> .filter((e) => !editGroupExtensionIds.includes(e.id))
{renderExtensionIcon(ext, "sm")} .map((ext) => (
<span className="text-sm flex-1 truncate min-w-0"> <SelectItem key={ext.id} value={ext.id}>
{ext.name} <div className="flex items-center gap-2">
</span> {renderExtensionIcon(ext, "sm")}
{renderCompatIcons(ext.browser_compatibility)} {ext.name}
<Button </div>
variant="ghost" </SelectItem>
size="sm" ))}
className="h-6 w-6 p-0 shrink-0" </SelectContent>
onClick={() => { </Select>
setEditGroupExtensionIds((prev) =>
prev.filter((id) => id !== extId),
);
}}
>
<LuTrash2 className="w-3 h-3" />
</Button>
</div>
);
})}
</div> </div>
)} )}
<div className="space-y-2">
<Label>{t("extensions.groupExtensions")}</Label>
{editGroupExtensionIds.length === 0 ? (
<div className="text-sm text-muted-foreground py-2">
{t("extensions.noExtensionsInGroup")}
</div>
) : (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{editGroupExtensionIds.map((extId) => {
const ext = extensions.find((e) => e.id === extId);
if (!ext) return null;
return (
<div
key={extId}
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
>
{renderExtensionIcon(ext, "sm")}
<span className="text-sm flex-1 truncate min-w-0">
{ext.name}
</span>
{renderCompatIcons(ext.browser_compatibility)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => {
setEditGroupExtensionIds((prev) =>
prev.filter((id) => id !== extId),
);
}}
>
<LuTrash2 className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div> </div>
</div> </ScrollArea>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -1117,7 +1128,7 @@ export function ExtensionManagementDialog({
} }
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("extensions.editExtension")}</DialogTitle> <DialogTitle>{t("extensions.editExtension")}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -1125,123 +1136,127 @@ export function ExtensionManagementDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{editingExtension && ( <ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
<div className="space-y-4"> {editingExtension && (
<div className="space-y-2"> <div className="space-y-4">
<Label>{t("common.labels.name")}</Label> <div className="space-y-2">
<Input <Label>{t("common.labels.name")}</Label>
value={editExtensionName} <Input
onChange={(e) => { value={editExtensionName}
setEditExtensionName(e.target.value); onChange={(e) => {
}} setEditExtensionName(e.target.value);
placeholder={t("extensions.namePlaceholder")} }}
onKeyDown={(e) => { placeholder={t("extensions.namePlaceholder")}
if (e.key === "Enter") void handleUpdateExtension(); onKeyDown={(e) => {
}} if (e.key === "Enter") void handleUpdateExtension();
/> }}
</div> />
</div>
{/* Metadata from manifest.json */} {/* Metadata from manifest.json */}
<div className="rounded-md border p-3 space-y-2"> <div className="rounded-md border p-3 space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide"> <Label className="text-xs text-muted-foreground uppercase tracking-wide">
{t("extensions.metadata")} {t("extensions.metadata")}
</Label> </Label>
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm"> <div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
{editingExtension.version && ( {editingExtension.version && (
<> <>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("extensions.version")} {t("extensions.version")}
</span>
<span>{editingExtension.version}</span>
</>
)}
{editingExtension.author && (
<>
<span className="text-muted-foreground">
{t("extensions.author")}
</span>
<span>{editingExtension.author}</span>
</>
)}
{editingExtension.description && (
<>
<span className="text-muted-foreground">
{t("common.labels.description")}
</span>
<span className="line-clamp-3">
{editingExtension.description}
</span>
</>
)}
<span className="text-muted-foreground">
{t("extensions.compatibility.label")}
</span>
<div className="flex items-center gap-1">
{renderCompatIcons(editingExtension.browser_compatibility)}
</div>
<span className="text-muted-foreground">
{t("common.labels.type")}
</span>
<span>.{editingExtension.file_type}</span>
{editingExtension.homepage_url && (
<>
<span className="text-muted-foreground">
{t("extensions.homepage")}
</span>
<a
href={editingExtension.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1 truncate"
>
<span className="truncate">
{editingExtension.homepage_url}
</span> </span>
<LuExternalLink className="w-3 h-3 shrink-0" /> <span>{editingExtension.version}</span>
</a> </>
</> )}
)} {editingExtension.author && (
{!editingExtension.version && <>
!editingExtension.author && <span className="text-muted-foreground">
!editingExtension.description && {t("extensions.author")}
!editingExtension.homepage_url && ( </span>
<span className="col-span-2 text-muted-foreground text-xs"> <span>{editingExtension.author}</span>
{t("extensions.noMetadata")} </>
)}
{editingExtension.description && (
<>
<span className="text-muted-foreground">
{t("common.labels.description")}
</span>
<span className="line-clamp-3">
{editingExtension.description}
</span>
</>
)}
<span className="text-muted-foreground">
{t("extensions.compatibility.label")}
</span>
<div className="flex items-center gap-1">
{renderCompatIcons(
editingExtension.browser_compatibility,
)}
</div>
<span className="text-muted-foreground">
{t("common.labels.type")}
</span>
<span>.{editingExtension.file_type}</span>
{editingExtension.homepage_url && (
<>
<span className="text-muted-foreground">
{t("extensions.homepage")}
</span>
<a
href={editingExtension.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1 truncate"
>
<span className="truncate">
{editingExtension.homepage_url}
</span>
<LuExternalLink className="w-3 h-3 shrink-0" />
</a>
</>
)}
{!editingExtension.version &&
!editingExtension.author &&
!editingExtension.description &&
!editingExtension.homepage_url && (
<span className="col-span-2 text-muted-foreground text-xs">
{t("extensions.noMetadata")}
</span>
)}
</div>
</div>
{/* Re-upload */}
<div className="space-y-2">
<Label>{t("extensions.reupload")}</Label>
<div className="flex gap-2 items-center">
<RippleButton
size="sm"
variant="outline"
onClick={() =>
document.getElementById("ext-edit-file-input")?.click()
}
>
<LuUpload className="w-3 h-3 mr-1" />
{t("extensions.selectFile")}
</RippleButton>
<input
id="ext-edit-file-input"
type="file"
accept=".xpi,.crx,.zip"
className="hidden"
onChange={handleEditFileSelect}
/>
{pendingUpdateFile && (
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
{pendingUpdateFile.name}
</span> </span>
)} )}
</div>
</div> </div>
</div> </div>
)}
{/* Re-upload */} </ScrollArea>
<div className="space-y-2">
<Label>{t("extensions.reupload")}</Label>
<div className="flex gap-2 items-center">
<RippleButton
size="sm"
variant="outline"
onClick={() =>
document.getElementById("ext-edit-file-input")?.click()
}
>
<LuUpload className="w-3 h-3 mr-1" />
{t("extensions.selectFile")}
</RippleButton>
<input
id="ext-edit-file-input"
type="file"
accept=".xpi,.crx,.zip"
className="hidden"
onChange={handleEditFileSelect}
/>
{pendingUpdateFile && (
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
{pendingUpdateFile.name}
</span>
)}
</div>
</div>
</div>
)}
<DialogFooter> <DialogFooter>
<Button <Button
+25 -13
View File
@@ -57,11 +57,13 @@ export function GroupAssignmentDialog({
setGroups(groupList); setGroups(groupList);
} catch (err) { } catch (err) {
console.error("Failed to load groups:", err); console.error("Failed to load groups:", err);
setError(err instanceof Error ? err.message : "Failed to load groups"); setError(
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [t]);
const handleAssign = useCallback(async () => { const handleAssign = useCallback(async () => {
setIsAssigning(true); setIsAssigning(true);
@@ -73,7 +75,8 @@ export function GroupAssignmentDialog({
}); });
const groupName = selectedGroupId const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group" ? groups.find((g) => g.id === selectedGroupId)?.name ||
t("groups.unknownGroup")
: t("groups.defaultGroup"); : t("groups.defaultGroup");
toast.success( toast.success(
@@ -89,7 +92,7 @@ export function GroupAssignmentDialog({
const errorMessage = const errorMessage =
err instanceof Error err instanceof Error
? err.message ? err.message
: "Failed to assign profiles to group"; : t("groupAssignment.failedFallback");
setError(errorMessage); setError(errorMessage);
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@@ -116,15 +119,21 @@ export function GroupAssignmentDialog({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Assign to Group</DialogTitle> <DialogTitle>{t("groupAssignment.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Assign {selectedProfiles.length} selected profile(s) to a group. {selectedProfiles.length === 1
? t("groupAssignment.description_one", {
count: selectedProfiles.length,
})
: t("groupAssignment.description_other", {
count: selectedProfiles.length,
})}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Selected Profiles:</Label> <Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto"> <div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => { {selectedProfiles.map((profileId) => {
@@ -145,7 +154,9 @@ export function GroupAssignmentDialog({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label htmlFor="group-select">Assign to Group:</Label> <Label htmlFor="group-select">
{t("groupAssignment.assignGroupLabel")}
</Label>
<RippleButton <RippleButton
size="sm" size="sm"
variant="outline" variant="outline"
@@ -154,12 +165,13 @@ export function GroupAssignmentDialog({
setCreateDialogOpen(true); setCreateDialogOpen(true);
}} }}
> >
<GoPlus className="mr-1 w-3 h-3" /> Create Group <GoPlus className="mr-1 w-3 h-3" />{" "}
{t("groupManagement.createGroup")}
</RippleButton> </RippleButton>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Loading groups... {t("groupManagement.loading")}
</div> </div>
) : ( ) : (
<Select <Select
@@ -169,7 +181,7 @@ export function GroupAssignmentDialog({
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a group" /> <SelectValue placeholder={t("groupAssignment.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="default"> <SelectItem value="default">
@@ -198,14 +210,14 @@ export function GroupAssignmentDialog({
onClick={onClose} onClick={onClose}
disabled={isAssigning} disabled={isAssigning}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isAssigning} isLoading={isAssigning}
onClick={() => void handleAssign()} onClick={() => void handleAssign()}
disabled={isLoading} disabled={isLoading}
> >
Assign {t("groupAssignment.assignButton")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+2 -2
View File
@@ -139,7 +139,7 @@ export function GroupBadges({
return ( return (
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs"> <div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
Loading groups... {t("groups.loading")}
</div> </div>
</div> </div>
); );
@@ -156,7 +156,7 @@ export function GroupBadges({
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
role="region" role="region"
aria-label="Profile groups" aria-label={t("groups.profileGroupsAriaLabel")}
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
onScroll={checkScrollPosition} onScroll={checkScrollPosition}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
+52 -25
View File
@@ -44,37 +44,46 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot( function getSyncStatusDot(
group: GroupWithCount, group: GroupWithCount,
liveStatus: SyncStatus | undefined, liveStatus: SyncStatus | undefined,
t: (key: string, options?: Record<string, unknown>) => string,
errorMessage?: string, errorMessage?: string,
): { color: string; tooltip: string; animate: boolean } { ): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled"); const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
switch (status) { switch (status) {
case "syncing": case "syncing":
return { color: "bg-warning", tooltip: "Syncing...", animate: true }; return {
color: "bg-warning",
tooltip: t("syncTooltips.syncing"),
animate: true,
};
case "synced": case "synced":
return { return {
color: "bg-success", color: "bg-success",
tooltip: group.last_sync tooltip: group.last_sync
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}` ? t("syncTooltips.syncedAt", {
: "Synced", time: new Date(group.last_sync * 1000).toLocaleString(),
})
: t("syncTooltips.synced"),
animate: false, animate: false,
}; };
case "waiting": case "waiting":
return { return {
color: "bg-warning", color: "bg-warning",
tooltip: "Waiting to sync", tooltip: t("syncTooltips.waiting"),
animate: false, animate: false,
}; };
case "error": case "error":
return { return {
color: "bg-destructive", color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error", tooltip: errorMessage
? t("syncTooltips.errorWith", { error: errorMessage })
: t("syncTooltips.error"),
animate: false, animate: false,
}; };
default: default:
return { return {
color: "bg-muted-foreground", color: "bg-muted-foreground",
tooltip: "Not synced", tooltip: t("syncTooltips.notSynced"),
animate: false, animate: false,
}; };
} }
@@ -165,11 +174,13 @@ export function GroupManagementDialog({
setGroupInUse(inUse); setGroupInUse(inUse);
} catch (err) { } catch (err) {
console.error("Failed to load groups:", err); console.error("Failed to load groups:", err);
setError(err instanceof Error ? err.message : "Failed to load groups"); setError(
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [t]);
const handleGroupCreated = useCallback( const handleGroupCreated = useCallback(
(_newGroup: ProfileGroup) => { (_newGroup: ProfileGroup) => {
@@ -210,18 +221,24 @@ export function GroupManagementDialog({
groupId: group.id, groupId: group.id,
enabled: !group.sync_enabled, enabled: !group.sync_enabled,
}); });
showSuccessToast(group.sync_enabled ? "Sync disabled" : "Sync enabled"); showSuccessToast(
group.sync_enabled
? t("proxies.management.syncDisabled")
: t("proxies.management.syncEnabled"),
);
await loadGroups(); await loadGroups();
} catch (error) { } catch (error) {
console.error("Failed to toggle sync:", error); console.error("Failed to toggle sync:", error);
showErrorToast( showErrorToast(
error instanceof Error ? error.message : "Failed to update sync", error instanceof Error
? error.message
: t("proxies.management.updateSyncFailed"),
); );
} finally { } finally {
setIsTogglingSync((prev) => ({ ...prev, [group.id]: false })); setIsTogglingSync((prev) => ({ ...prev, [group.id]: false }));
} }
}, },
[loadGroups], [loadGroups, t],
); );
useEffect(() => { useEffect(() => {
@@ -244,7 +261,7 @@ export function GroupManagementDialog({
<div className="space-y-4"> <div className="space-y-4">
{/* Create new group button */} {/* Create new group button */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label>Groups</Label> <Label>{t("groupManagement.groupsLabel")}</Label>
<RippleButton <RippleButton
size="sm" size="sm"
onClick={() => { onClick={() => {
@@ -253,7 +270,7 @@ export function GroupManagementDialog({
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
<GoPlus className="w-4 h-4" /> <GoPlus className="w-4 h-4" />
Create {t("proxies.management.create")}
</RippleButton> </RippleButton>
</div> </div>
@@ -266,7 +283,7 @@ export function GroupManagementDialog({
{/* Groups list */} {/* Groups list */}
{isLoading ? ( {isLoading ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("common.loading")} {t("common.buttons.loading")}
</div> </div>
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@@ -278,10 +295,16 @@ export function GroupManagementDialog({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-20">Profiles</TableHead> <TableHead className="w-20">
<TableHead className="w-24">Sync</TableHead> {t("groupManagement.profilesCol")}
<TableHead className="w-24">Actions</TableHead> </TableHead>
<TableHead className="w-24">
{t("proxies.management.syncCol")}
</TableHead>
<TableHead className="w-24">
{t("common.labels.actions")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -289,6 +312,7 @@ export function GroupManagementDialog({
const syncDot = getSyncStatusDot( const syncDot = getSyncStatusDot(
group, group,
groupSyncStatus[group.id], groupSyncStatus[group.id],
t,
groupSyncErrors[group.id], groupSyncErrors[group.id],
); );
return ( return (
@@ -332,14 +356,13 @@ export function GroupManagementDialog({
<TooltipContent> <TooltipContent>
{groupInUse[group.id] ? ( {groupInUse[group.id] ? (
<p> <p>
Sync cannot be disabled while this group {t("groupManagement.syncCannotDisable")}
is used by synced profiles
</p> </p>
) : ( ) : (
<p> <p>
{group.sync_enabled {group.sync_enabled
? "Disable sync" ? t("proxies.management.disableSync")
: "Enable sync"} : t("proxies.management.enableSync")}
</p> </p>
)} )}
</TooltipContent> </TooltipContent>
@@ -360,7 +383,9 @@ export function GroupManagementDialog({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Edit group</p> <p>
{t("groupManagement.editGroupTooltip")}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
@@ -376,7 +401,9 @@ export function GroupManagementDialog({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Delete group</p> <p>
{t("groupManagement.deleteGroupTooltip")}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -393,7 +420,7 @@ export function GroupManagementDialog({
<DialogFooter> <DialogFooter>
<RippleButton variant="outline" onClick={onClose}> <RippleButton variant="outline" onClick={onClose}>
Close {t("common.buttons.close")}
</RippleButton> </RippleButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+1 -1
View File
@@ -272,7 +272,7 @@ const HomeHeader = ({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="flex gap-2 items-center h-[36px]" className="flex gap-2 items-center h-[36px] border-foreground/20 hover:text-foreground"
> >
<GoKebabHorizontal className="w-4 h-4" /> <GoKebabHorizontal className="w-4 h-4" />
</Button> </Button>
+68 -44
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaFolder } from "react-icons/fa"; import { FaFolder } from "react-icons/fa";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
@@ -49,6 +50,7 @@ export function ImportProfileDialog({
onClose, onClose,
crossOsUnlocked, crossOsUnlocked,
}: ImportProfileDialogProps) { }: ImportProfileDialogProps) {
const { t } = useTranslation();
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>( const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[], [],
); );
@@ -103,11 +105,11 @@ export function ImportProfileDialog({
} }
} catch (error) { } catch (error) {
console.error("Failed to detect existing profiles:", error); console.error("Failed to detect existing profiles:", error);
toast.error("Failed to detect existing browser profiles"); toast.error(t("importProfile.detectFailed"));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [t]);
const selectedProfile = detectedProfiles.find( const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile, (p) => p.path === selectedDetectedProfile,
@@ -118,7 +120,7 @@ export function ImportProfileDialog({
const selected = await open({ const selected = await open({
directory: true, directory: true,
multiple: false, multiple: false,
title: "Select Browser Profile Folder", title: t("importProfile.selectFolderTitle"),
}); });
if (selected && typeof selected === "string") { if (selected && typeof selected === "string") {
@@ -126,7 +128,7 @@ export function ImportProfileDialog({
} }
} catch (error) { } catch (error) {
console.error("Failed to open folder dialog:", error); console.error("Failed to open folder dialog:", error);
toast.error("Failed to open folder dialog"); toast.error(t("importProfile.folderDialogFailed"));
} }
}; };
@@ -137,14 +139,14 @@ export function ImportProfileDialog({
if (importMode === "auto-detect") { if (importMode === "auto-detect") {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) { if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name"); toast.error(t("importProfile.selectAndName"));
return; return;
} }
const profile = detectedProfiles.find( const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile, (p) => p.path === selectedDetectedProfile,
); );
if (!profile) { if (!profile) {
toast.error("Selected profile not found"); toast.error(t("importProfile.profileNotFound"));
return; return;
} }
sourcePath = profile.path; sourcePath = profile.path;
@@ -156,7 +158,7 @@ export function ImportProfileDialog({
!manualProfilePath.trim() || !manualProfilePath.trim() ||
!manualProfileName.trim() !manualProfileName.trim()
) { ) {
toast.error("Please fill in all fields"); toast.error(t("importProfile.fillFields"));
return; return;
} }
sourcePath = manualProfilePath.trim(); sourcePath = manualProfilePath.trim();
@@ -180,7 +182,9 @@ export function ImportProfileDialog({
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null, wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
}); });
toast.success(`Successfully imported profile "${newProfileName}"`); toast.success(
t("importProfile.importedSuccess", { name: newProfileName }),
);
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to import profile:", error); console.error("Failed to import profile:", error);
@@ -190,13 +194,13 @@ export function ImportProfileDialog({
if (errorMessage.includes("No downloaded versions found")) { if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(browserType); const browserDisplayName = getBrowserDisplayName(browserType);
toast.error( toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`, t("importProfile.notInstalled", { browser: browserDisplayName }),
{ {
duration: 8000, duration: 8000,
}, },
); );
} else { } else {
toast.error(`Failed to import profile: ${errorMessage}`); toast.error(t("importProfile.importFailed", { error: errorMessage }));
} }
} finally { } finally {
setIsImporting(false); setIsImporting(false);
@@ -214,6 +218,7 @@ export function ImportProfileDialog({
wayfernConfig, wayfernConfig,
onClose, onClose,
selectedProfile, selectedProfile,
t,
]); ]);
const handleClose = () => { const handleClose = () => {
@@ -290,7 +295,7 @@ export function ImportProfileDialog({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col"> <DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle>Import Browser Profile</DialogTitle> <DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="overflow-y-auto flex-1 space-y-6 min-h-0"> <div className="overflow-y-auto flex-1 space-y-6 min-h-0">
@@ -305,7 +310,7 @@ export function ImportProfileDialog({
className="flex-1" className="flex-1"
disabled={isLoading} disabled={isLoading}
> >
Auto-Detect {t("importProfile.autoDetect")}
</RippleButton> </RippleButton>
<RippleButton <RippleButton
variant={importMode === "manual" ? "default" : "outline"} variant={importMode === "manual" ? "default" : "outline"}
@@ -315,30 +320,29 @@ export function ImportProfileDialog({
className="flex-1" className="flex-1"
disabled={isLoading} disabled={isLoading}
> >
Manual Import {t("importProfile.manualImport")}
</RippleButton> </RippleButton>
</div> </div>
{importMode === "auto-detect" && ( {importMode === "auto-detect" && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
Detected Browser Profiles {t("importProfile.detectedProfilesTitle")}
</h3> </h3>
{isLoading ? ( {isLoading ? (
<div className="py-8 text-center"> <div className="py-8 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Scanning for browser profiles... {t("importProfile.scanning")}
</p> </p>
</div> </div>
) : detectedProfiles.length === 0 ? ( ) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center"> <div className="py-8 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
No browser profiles found on your system. {t("importProfile.noneFound")}
</p> </p>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in {t("importProfile.noneFoundHint")}
custom locations.
</p> </p>
</div> </div>
) : ( ) : (
@@ -348,7 +352,7 @@ export function ImportProfileDialog({
htmlFor="detected-profile-select" htmlFor="detected-profile-select"
className="mb-2" className="mb-2"
> >
Select Profile: {t("importProfile.selectProfile")}
</Label> </Label>
<Select <Select
value={selectedDetectedProfile ?? undefined} value={selectedDetectedProfile ?? undefined}
@@ -357,7 +361,11 @@ export function ImportProfileDialog({
}} }}
> >
<SelectTrigger id="detected-profile-select"> <SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" /> <SelectValue
placeholder={t(
"importProfile.selectProfilePlaceholder",
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{detectedProfiles.map((profile) => { {detectedProfiles.map((profile) => {
@@ -395,11 +403,15 @@ export function ImportProfileDialog({
{selectedProfile && ( {selectedProfile && (
<div className="p-3 rounded-lg bg-muted"> <div className="p-3 rounded-lg bg-muted">
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">Path:</span>{" "} <span className="font-medium">
{t("importProfile.pathLabel")}
</span>{" "}
{selectedProfile.path} {selectedProfile.path}
</p> </p>
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">Browser:</span>{" "} <span className="font-medium">
{t("importProfile.browserLabel")}
</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)} {getBrowserDisplayName(selectedProfile.browser)}
</p> </p>
</div> </div>
@@ -407,7 +419,7 @@ export function ImportProfileDialog({
<div> <div>
<Label htmlFor="auto-profile-name" className="mb-2"> <Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name: {t("importProfile.newProfileName")}
</Label> </Label>
<Input <Input
id="auto-profile-name" id="auto-profile-name"
@@ -415,7 +427,9 @@ export function ImportProfileDialog({
onChange={(e) => { onChange={(e) => {
setAutoDetectProfileName(e.target.value); setAutoDetectProfileName(e.target.value);
}} }}
placeholder="Enter a name for the imported profile" placeholder={t(
"importProfile.newProfileNamePlaceholder",
)}
/> />
</div> </div>
</div> </div>
@@ -425,12 +439,14 @@ export function ImportProfileDialog({
{importMode === "manual" && ( {importMode === "manual" && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3> <h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="manual-browser-select" className="mb-2"> <Label htmlFor="manual-browser-select" className="mb-2">
Browser Type: {t("importProfile.browserType")}
</Label> </Label>
<Select <Select
value={manualBrowserType ?? undefined} value={manualBrowserType ?? undefined}
@@ -443,8 +459,8 @@ export function ImportProfileDialog({
<SelectValue <SelectValue
placeholder={ placeholder={
isLoadingSupport isLoadingSupport
? "Loading browsers..." ? t("importProfile.loadingBrowsers")
: "Select browser type" : t("importProfile.selectBrowserType")
} }
/> />
</SelectTrigger> </SelectTrigger>
@@ -468,7 +484,7 @@ export function ImportProfileDialog({
<div> <div>
<Label htmlFor="manual-profile-path" className="mb-2"> <Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path: {t("importProfile.profileFolderPath")}
</Label> </Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
@@ -477,19 +493,21 @@ export function ImportProfileDialog({
onChange={(e) => { onChange={(e) => {
setManualProfilePath(e.target.value); setManualProfilePath(e.target.value);
}} }}
placeholder="Enter the full path to the profile folder" placeholder={t(
"importProfile.profileFolderPlaceholder",
)}
/> />
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => void handleBrowseFolder()} onClick={() => void handleBrowseFolder()}
title="Browse for folder" title={t("importProfile.browseFolderTitle")}
> >
<FaFolder className="w-4 h-4" /> <FaFolder className="w-4 h-4" />
</Button> </Button>
</div> </div>
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
Example paths: {t("importProfile.examplePaths")}
<br /> <br />
macOS: ~/Library/Application macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default Support/Firefox/Profiles/xxx.default
@@ -502,7 +520,7 @@ export function ImportProfileDialog({
<div> <div>
<Label htmlFor="manual-profile-name" className="mb-2"> <Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name: {t("importProfile.newProfileName")}
</Label> </Label>
<Input <Input
id="manual-profile-name" id="manual-profile-name"
@@ -510,7 +528,9 @@ export function ImportProfileDialog({
onChange={(e) => { onChange={(e) => {
setManualProfileName(e.target.value); setManualProfileName(e.target.value);
}} }}
placeholder="Enter a name for the imported profile" placeholder={t(
"importProfile.newProfileNamePlaceholder",
)}
/> />
</div> </div>
</div> </div>
@@ -523,14 +543,16 @@ export function ImportProfileDialog({
<div className="space-y-4"> <div className="space-y-4">
<Alert> <Alert>
<AlertDescription> <AlertDescription>
This profile will be imported as a{" "} {t("importProfile.importedAs", {
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "} browser: getBrowserDisplayName(currentMappedBrowser),
profile. })}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div> <div>
<Label className="mb-2">Proxy (Optional)</Label> <Label className="mb-2">
{t("importProfile.proxyOptional")}
</Label>
<Select <Select
value={selectedProxyId ?? "none"} value={selectedProxyId ?? "none"}
onValueChange={(value) => { onValueChange={(value) => {
@@ -538,10 +560,12 @@ export function ImportProfileDialog({
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No proxy" /> <SelectValue placeholder={t("importProfile.noProxy")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No proxy</SelectItem> <SelectItem value="none">
{t("importProfile.noProxy")}
</SelectItem>
{storedProxies.map((proxy) => ( {storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}> <SelectItem key={proxy.id} value={proxy.id}>
{proxy.name} {proxy.name}
@@ -580,7 +604,7 @@ export function ImportProfileDialog({
{currentStep === "select" ? ( {currentStep === "select" ? (
<> <>
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<RippleButton <RippleButton
disabled={!canProceedToNext} disabled={!canProceedToNext}
@@ -588,7 +612,7 @@ export function ImportProfileDialog({
setCurrentStep("configure"); setCurrentStep("configure");
}} }}
> >
Next {t("importProfile.nextButton")}
</RippleButton> </RippleButton>
</> </>
) : ( ) : (
@@ -599,7 +623,7 @@ export function ImportProfileDialog({
setCurrentStep("select"); setCurrentStep("select");
}} }}
> >
Back {t("common.buttons.back")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isImporting} isLoading={isImporting}
@@ -607,7 +631,7 @@ export function ImportProfileDialog({
void handleImport(); void handleImport();
}} }}
> >
Import {t("importProfile.importButton")}
</LoadingButton> </LoadingButton>
</> </>
)} )}
+44 -28
View File
@@ -148,7 +148,7 @@ export function IntegrationsDialog({
settings: { ...settings, api_enabled: true }, settings: { ...settings, api_enabled: true },
}); });
setSettings(next); setSettings(next);
showSuccessToast(`API server started on port ${port}`); showSuccessToast(t("integrations.apiStarted", { port }));
} else { } else {
await invoke("stop_api_server"); await invoke("stop_api_server");
setApiServerPort(null); setApiServerPort(null);
@@ -156,12 +156,13 @@ export function IntegrationsDialog({
settings: { ...settings, api_enabled: false, api_token: null }, settings: { ...settings, api_enabled: false, api_token: null },
}); });
setSettings(next); setSettings(next);
showSuccessToast("API server stopped"); showSuccessToast(t("integrations.apiStopped"));
} }
} catch (e) { } catch (e) {
console.error("Failed to toggle API:", e); console.error("Failed to toggle API:", e);
showErrorToast("Failed to toggle API server", { showErrorToast(t("integrations.apiToggleFailed"), {
description: e instanceof Error ? e.message : "Unknown error", description:
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
}); });
} finally { } finally {
setIsApiStarting(false); setIsApiStarting(false);
@@ -178,7 +179,7 @@ export function IntegrationsDialog({
}); });
setSettings(next); setSettings(next);
void loadMcpConfig(); void loadMcpConfig();
showSuccessToast(`MCP server started on port ${port}`); showSuccessToast(t("integrations.mcpStarted", { port }));
} else { } else {
await invoke("stop_mcp_server"); await invoke("stop_mcp_server");
const next = await invoke<AppSettings>("save_app_settings", { const next = await invoke<AppSettings>("save_app_settings", {
@@ -186,12 +187,13 @@ export function IntegrationsDialog({
}); });
setSettings(next); setSettings(next);
setMcpConfig(null); setMcpConfig(null);
showSuccessToast("MCP server stopped"); showSuccessToast(t("integrations.mcpStopped"));
} }
} catch (e) { } catch (e) {
console.error("Failed to toggle MCP server:", e); console.error("Failed to toggle MCP server:", e);
showErrorToast("Failed to toggle MCP server", { showErrorToast(t("integrations.mcpToggleFailed"), {
description: e instanceof Error ? e.message : "Unknown error", description:
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
}); });
} finally { } finally {
setIsMcpStarting(false); setIsMcpStarting(false);
@@ -207,14 +209,14 @@ export function IntegrationsDialog({
> >
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col"> <DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle>Integrations</DialogTitle> <DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="overflow-y-auto flex-1 min-h-0"> <div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full"> <Tabs defaultValue="api" className="w-full">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="api">Local API</TabsTrigger> <TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
<TabsTrigger value="mcp">MCP (AI Assistants)</TabsTrigger> <TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="api" className="space-y-4 mt-4"> <TabsContent value="api" className="space-y-4 mt-4">
@@ -230,10 +232,10 @@ export function IntegrationsDialog({
htmlFor="api-enabled" htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
Enable Local API Server {t("integrations.apiEnableLabel")}
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Allow managing profiles, groups, and proxies via REST API. {t("integrations.apiEnableDescription")}
</p> </p>
</div> </div>
</div> </div>
@@ -241,7 +243,9 @@ export function IntegrationsDialog({
{settings.api_enabled && ( {settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40"> <div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Port</Label> <Label className="text-sm font-medium">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
size="sm" size="sm"
@@ -251,8 +255,10 @@ export function IntegrationsDialog({
onClick={async () => { onClick={async () => {
const port = settings.api_port; const port = settings.api_port;
if (port < 1 || port > 65535) { if (port < 1 || port > 65535) {
showErrorToast("Invalid port", { showErrorToast(t("integrations.apiInvalidPort"), {
description: "Port must be between 1 and 65535", description: t(
"integrations.apiInvalidPortDescription",
),
}); });
return; return;
} }
@@ -270,20 +276,28 @@ export function IntegrationsDialog({
); );
setApiServerPort(actualPort); setApiServerPort(actualPort);
if (actualPort !== port) { if (actualPort !== port) {
showErrorToast(`Port ${port} is already in use`, { showErrorToast(
description: `Server started on fallback port ${actualPort}`, t("integrations.apiPortInUse", { port }),
}); {
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else { } else {
showSuccessToast( showSuccessToast(
`API server running on port ${actualPort}`, t("integrations.apiRunning", {
port: actualPort,
}),
); );
} }
} catch (e) { } catch (e) {
showErrorToast("Failed to start API server", { showErrorToast(t("integrations.apiStartFailed"), {
description: description:
e instanceof Error e instanceof Error
? e.message ? e.message
: "Unknown error", : t("integrations.apiUnknownError"),
}); });
} finally { } finally {
setIsApiStarting(false); setIsApiStarting(false);
@@ -315,7 +329,7 @@ export function IntegrationsDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
Authentication Token {t("integrations.apiTokenLabel")}
</Label> </Label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -343,11 +357,13 @@ export function IntegrationsDialog({
</div> </div>
<CopyToClipboard <CopyToClipboard
text={settings.api_token ?? ""} text={settings.api_token ?? ""}
successMessage="Token copied" successMessage={t("integrations.tokenCopied")}
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Include in Authorization header: Bearer {"<token>"} {t("integrations.apiTokenHint", {
tokenSlot: "<token>",
})}
</p> </p>
</div> </div>
</div> </div>
@@ -367,13 +383,13 @@ export function IntegrationsDialog({
htmlFor="mcp-enabled" htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
Enable MCP Server (Model Context Protocol) {t("integrations.mcpEnableLabel")}
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Allow AI assistants like Claude Desktop to control browsers. {t("integrations.mcpEnableDescription")}
{!termsAccepted && ( {!termsAccepted && (
<span className="ml-1 text-warning"> <span className="ml-1 text-warning">
(Accept Wayfern terms in Settings first) {t("integrations.mcpAcceptTermsFirst")}
</span> </span>
)} )}
</p> </p>
+15 -11
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -22,6 +23,7 @@ export function LaunchOnLoginDialog({
isOpen, isOpen,
onClose, onClose,
}: LaunchOnLoginDialogProps) { }: LaunchOnLoginDialogProps) {
const { t } = useTranslation();
const [isEnabling, setIsEnabling] = useState(false); const [isEnabling, setIsEnabling] = useState(false);
const [isDeclining, setIsDeclining] = useState(false); const [isDeclining, setIsDeclining] = useState(false);
@@ -29,18 +31,18 @@ export function LaunchOnLoginDialog({
setIsEnabling(true); setIsEnabling(true);
try { try {
await invoke("enable_launch_on_login"); await invoke("enable_launch_on_login");
showSuccessToast("Launch on login enabled"); showSuccessToast(t("launchOnLogin.enableSuccess"));
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to enable launch on login:", error); console.error("Failed to enable launch on login:", error);
showErrorToast("Failed to enable launch on login", { showErrorToast(t("launchOnLogin.enableFailed"), {
description: description:
error instanceof Error ? error.message : "Please try again", error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
}); });
} finally { } finally {
setIsEnabling(false); setIsEnabling(false);
} }
}, [onClose]); }, [onClose, t]);
const handleDecline = useCallback(async () => { const handleDecline = useCallback(async () => {
setIsDeclining(true); setIsDeclining(true);
@@ -49,14 +51,14 @@ export function LaunchOnLoginDialog({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to decline launch on login:", error); console.error("Failed to decline launch on login:", error);
showErrorToast("Failed to save preference", { showErrorToast(t("launchOnLogin.declineFailed"), {
description: description:
error instanceof Error ? error.message : "Please try again", error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
}); });
} finally { } finally {
setIsDeclining(false); setIsDeclining(false);
} }
}, [onClose]); }, [onClose, t]);
return ( return (
<Dialog open={isOpen}> <Dialog open={isOpen}>
@@ -73,11 +75,11 @@ export function LaunchOnLoginDialog({
}} }}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>Enable Launch on Login?</DialogTitle> <DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Running in the background helps keep your proxies and browsers alive. {t("launchOnLogin.description")}
</p> </p>
<DialogFooter className="flex-row justify-between sm:justify-between"> <DialogFooter className="flex-row justify-between sm:justify-between">
@@ -86,14 +88,16 @@ export function LaunchOnLoginDialog({
onClick={handleDecline} onClick={handleDecline}
disabled={isEnabling || isDeclining} disabled={isEnabling || isDeclining}
> >
{isDeclining ? "..." : "Don't Ask Again"} {isDeclining
? t("launchOnLogin.declining")
: t("launchOnLogin.declineButton")}
</Button> </Button>
<LoadingButton <LoadingButton
onClick={handleEnable} onClick={handleEnable}
isLoading={isEnabling} isLoading={isEnabling}
disabled={isDeclining} disabled={isDeclining}
> >
Enable {t("launchOnLogin.enableButton")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+38 -31
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox"; import { Combobox } from "@/components/ui/combobox";
@@ -29,6 +30,7 @@ export function LocationProxyDialog({
isOpen, isOpen,
onClose, onClose,
}: LocationProxyDialogProps) { }: LocationProxyDialogProps) {
const { t } = useTranslation();
const [countries, setCountries] = useState<LocationItem[]>([]); const [countries, setCountries] = useState<LocationItem[]>([]);
const [regions, setRegions] = useState<LocationItem[]>([]); const [regions, setRegions] = useState<LocationItem[]>([]);
const [cities, setCities] = useState<LocationItem[]>([]); const [cities, setCities] = useState<LocationItem[]>([]);
@@ -68,12 +70,12 @@ export function LocationProxyDialog({
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to fetch countries:", err); console.error("Failed to fetch countries:", err);
toast.error("Failed to load countries"); toast.error(t("locationProxy.loadFailed"));
}) })
.finally(() => { .finally(() => {
setIsLoadingCountries(false); setIsLoadingCountries(false);
}); });
}, [isOpen]); }, [isOpen, t]);
// Fetch regions when country changes // Fetch regions when country changes
useEffect(() => { useEffect(() => {
@@ -188,13 +190,13 @@ export function LocationProxyDialog({
city: selectedCity || null, city: selectedCity || null,
isp: selectedIsp || null, isp: selectedIsp || null,
}); });
toast.success("Location proxy created"); toast.success(t("locationProxy.createSuccess"));
await emit("stored-proxies-changed"); await emit("stored-proxies-changed");
handleClose(); handleClose();
} catch (error) { } catch (error) {
console.error("Failed to create location proxy:", error); console.error("Failed to create location proxy:", error);
toast.error( toast.error(
typeof error === "string" ? error : "Failed to create location proxy", typeof error === "string" ? error : t("locationProxy.createFailed"),
); );
} finally { } finally {
setIsCreating(false); setIsCreating(false);
@@ -206,6 +208,7 @@ export function LocationProxyDialog({
selectedIsp, selectedIsp,
proxyName, proxyName,
handleClose, handleClose,
t,
]); ]);
const countryOptions = countries.map((c) => ({ const countryOptions = countries.map((c) => ({
@@ -224,9 +227,9 @@ export function LocationProxyDialog({
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Location Proxy</DialogTitle> <DialogTitle>{t("locationProxy.titleCreate")}</DialogTitle>
<DialogDescription> <DialogDescription>
Create a geo-targeted proxy with a 24-hour sticky session {t("locationProxy.descriptionCreate")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -234,7 +237,7 @@ export function LocationProxyDialog({
{/* Country - always visible */} {/* Country - always visible */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
Country (required) {t("locationProxy.countryLabel")}
{isLoadingCountries && <LoadingSpinner />} {isLoadingCountries && <LoadingSpinner />}
</Label> </Label>
<Combobox <Combobox
@@ -242,9 +245,11 @@ export function LocationProxyDialog({
value={selectedCountry} value={selectedCountry}
onValueChange={setSelectedCountry} onValueChange={setSelectedCountry}
placeholder={ placeholder={
isLoadingCountries ? "Loading countries..." : "Select country" isLoadingCountries
? t("locationProxy.loadingCountries")
: t("locationProxy.selectCountryPh")
} }
searchPlaceholder="Search countries..." searchPlaceholder={t("locationProxy.searchCountries")}
disabled={isLoadingCountries} disabled={isLoadingCountries}
/> />
</div> </div>
@@ -252,7 +257,7 @@ export function LocationProxyDialog({
{/* Region - always visible, disabled until country is selected */} {/* Region - always visible, disabled until country is selected */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
Region (optional) {t("locationProxy.regionLabel")}
{isLoadingRegions && <LoadingSpinner />} {isLoadingRegions && <LoadingSpinner />}
</Label> </Label>
<Combobox <Combobox
@@ -261,14 +266,14 @@ export function LocationProxyDialog({
onValueChange={setSelectedRegion} onValueChange={setSelectedRegion}
placeholder={ placeholder={
!selectedCountry !selectedCountry
? "Select a country first" ? t("locationProxy.selectCountryFirst")
: isLoadingRegions : isLoadingRegions
? "Loading regions..." ? t("locationProxy.loadingRegions")
: regionOptions.length === 0 : regionOptions.length === 0
? "No regions available" ? t("locationProxy.noRegions")
: "Select region" : t("locationProxy.selectRegion")
} }
searchPlaceholder="Search regions..." searchPlaceholder={t("locationProxy.searchRegions")}
disabled={!selectedCountry || isLoadingRegions} disabled={!selectedCountry || isLoadingRegions}
/> />
</div> </div>
@@ -276,7 +281,7 @@ export function LocationProxyDialog({
{/* City - always visible, disabled until country is selected */} {/* City - always visible, disabled until country is selected */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
City (optional) {t("locationProxy.cityLabel")}
{isLoadingCities && <LoadingSpinner />} {isLoadingCities && <LoadingSpinner />}
</Label> </Label>
<Combobox <Combobox
@@ -285,14 +290,14 @@ export function LocationProxyDialog({
onValueChange={setSelectedCity} onValueChange={setSelectedCity}
placeholder={ placeholder={
!selectedCountry !selectedCountry
? "Select a country first" ? t("locationProxy.selectCountryFirst")
: isLoadingCities : isLoadingCities
? "Loading cities..." ? t("locationProxy.loadingCities")
: cityOptions.length === 0 : cityOptions.length === 0
? "No cities available" ? t("locationProxy.noCities")
: "Select city" : t("locationProxy.selectCity")
} }
searchPlaceholder="Search cities..." searchPlaceholder={t("locationProxy.searchCities")}
disabled={!selectedCountry || isLoadingCities} disabled={!selectedCountry || isLoadingCities}
/> />
</div> </div>
@@ -300,7 +305,7 @@ export function LocationProxyDialog({
{/* ISP - always visible, disabled until country is selected */} {/* ISP - always visible, disabled until country is selected */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
ISP (optional) {t("locationProxy.ispLabel")}
{isLoadingIsps && <LoadingSpinner />} {isLoadingIsps && <LoadingSpinner />}
</Label> </Label>
<Combobox <Combobox
@@ -309,40 +314,42 @@ export function LocationProxyDialog({
onValueChange={setSelectedIsp} onValueChange={setSelectedIsp}
placeholder={ placeholder={
!selectedCountry !selectedCountry
? "Select a country first" ? t("locationProxy.selectCountryFirst")
: isLoadingIsps : isLoadingIsps
? "Loading ISPs..." ? t("locationProxy.loadingIsps")
: ispOptions.length === 0 : ispOptions.length === 0
? "No ISPs available" ? t("locationProxy.noIsps")
: "Select ISP" : t("locationProxy.selectIsp")
} }
searchPlaceholder="Search ISPs..." searchPlaceholder={t("locationProxy.searchIsps")}
disabled={!selectedCountry || isLoadingIsps} disabled={!selectedCountry || isLoadingIsps}
/> />
</div> </div>
{/* Name */} {/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Name</Label> <Label>{t("locationProxy.nameLabel")}</Label>
<Input <Input
value={proxyName} value={proxyName}
onChange={(e) => { onChange={(e) => {
setProxyName(e.target.value); setProxyName(e.target.value);
}} }}
placeholder="Proxy name" placeholder={t("locationProxy.namePlaceholder")}
/> />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose}>
Cancel {t("common.buttons.cancel")}
</Button> </Button>
<RippleButton <RippleButton
onClick={handleCreate} onClick={handleCreate}
disabled={!selectedCountry || !proxyName.trim() || isCreating} disabled={!selectedCountry || !proxyName.trim() || isCreating}
> >
{isCreating ? "Creating..." : "Create"} {isCreating
? t("locationProxy.creatingButton")
: t("locationProxy.createButton")}
</RippleButton> </RippleButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+107 -32
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs"; import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { import {
@@ -20,7 +21,14 @@ interface PermissionDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
permissionType: PermissionType; permissionType: PermissionType;
onPermissionGranted?: () => void; /**
* Fired when the displayed permission becomes granted. The just-granted
* type is passed through so the parent can act optimistically its own
* usePermissions instance polls on a 5 s cadence and would otherwise be
* stale right after the macOS system prompt is accepted, leaving the
* dialog open in a confusing state.
*/
onPermissionGranted?: (justGranted: PermissionType) => void;
} }
export function PermissionDialog({ export function PermissionDialog({
@@ -29,7 +37,9 @@ export function PermissionDialog({
permissionType, permissionType,
onPermissionGranted, onPermissionGranted,
}: PermissionDialogProps) { }: PermissionDialogProps) {
const { t } = useTranslation();
const [isRequesting, setIsRequesting] = useState(false); const [isRequesting, setIsRequesting] = useState(false);
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
const [isMacOS, setIsMacOS] = useState(false); const [isMacOS, setIsMacOS] = useState(false);
const { const {
requestPermission, requestPermission,
@@ -55,12 +65,68 @@ export function PermissionDialog({
? isMicrophoneAccessGranted ? isMicrophoneAccessGranted
: isCameraAccessGranted; : isCameraAccessGranted;
// Auto-close dialog when permission is granted // Mirror the latest permission state into a ref so the deferred timeout
// callback can read it without being recreated on every state change.
const isCurrentPermissionGrantedRef = useRef(isCurrentPermissionGranted);
useEffect(() => { useEffect(() => {
if (isCurrentPermissionGranted && isOpen) { isCurrentPermissionGrantedRef.current = isCurrentPermissionGranted;
onPermissionGranted?.(); }, [isCurrentPermissionGranted]);
// When the permission becomes granted, fire a success toast and let the
// parent decide what to do next (progress to the other permission, or close).
// We deliberately do NOT keep the dialog around to show a "Done" state —
// the toast is the confirmation, and the dialog closes immediately.
// Use a ref to ensure we only fire the toast once per grant transition.
const grantedToastFiredForRef = useRef<PermissionType | null>(null);
useEffect(() => {
if (!isOpen) {
grantedToastFiredForRef.current = null;
return;
} }
}, [isCurrentPermissionGranted, isOpen, onPermissionGranted]); if (
isCurrentPermissionGranted &&
grantedToastFiredForRef.current !== permissionType
) {
grantedToastFiredForRef.current = permissionType;
showSuccessToast(
permissionType === "microphone"
? t("permissionDialog.grantedToastMicrophone")
: t("permissionDialog.grantedToastCamera"),
);
onPermissionGranted?.(permissionType);
}
}, [
isCurrentPermissionGranted,
isOpen,
onPermissionGranted,
permissionType,
t,
]);
// Pending-grant timeout: triggered after the user clicks "Grant Access"
// to give the macOS permission state a few seconds to propagate to our poll.
const waitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// If permission becomes granted during the wait window, end the wait early.
useEffect(() => {
if (isWaitingForGrant && isCurrentPermissionGranted) {
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
waitTimeoutRef.current = null;
}
setIsWaitingForGrant(false);
}
}, [isWaitingForGrant, isCurrentPermissionGranted]);
// Clear any pending timeout on unmount.
useEffect(() => {
return () => {
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
waitTimeoutRef.current = null;
}
};
}, []);
const getPermissionIcon = (type: PermissionType) => { const getPermissionIcon = (type: PermissionType) => {
switch (type) { switch (type) {
@@ -74,18 +140,18 @@ export function PermissionDialog({
const getPermissionTitle = (type: PermissionType) => { const getPermissionTitle = (type: PermissionType) => {
switch (type) { switch (type) {
case "microphone": case "microphone":
return "Microphone Access Required"; return t("permissionDialog.titleMicrophone");
case "camera": case "camera":
return "Camera Access Required"; return t("permissionDialog.titleCamera");
} }
}; };
const getPermissionDescription = (type: PermissionType) => { const getPermissionDescription = (type: PermissionType) => {
switch (type) { switch (type) {
case "microphone": case "microphone":
return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually."; return t("permissionDialog.descMicrophone");
case "camera": case "camera":
return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually."; return t("permissionDialog.descCamera");
} }
}; };
@@ -93,15 +159,28 @@ export function PermissionDialog({
setIsRequesting(true); setIsRequesting(true);
try { try {
await requestPermission(permissionType); await requestPermission(permissionType);
showSuccessToast( // The macOS permission poll runs every 5 s, so the new state can take
`${getPermissionTitle(permissionType).replace( // a moment to surface. Keep the grant button in its busy state for
" Required", // that window so the user has clear feedback, and notify them if the
"", // grant still hasn't landed by the end.
)} permission requested`, setIsWaitingForGrant(true);
); if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
}
waitTimeoutRef.current = setTimeout(() => {
waitTimeoutRef.current = null;
setIsWaitingForGrant(false);
if (!isCurrentPermissionGrantedRef.current) {
showErrorToast(
permissionType === "microphone"
? t("permissionDialog.stillNotGrantedMicrophone")
: t("permissionDialog.stillNotGrantedCamera"),
);
}
}, 5000);
} catch (error) { } catch (error) {
console.error("Failed to request permission:", error); console.error("Failed to request permission:", error);
showErrorToast("Failed to request permission"); showErrorToast(t("permissionDialog.requestFailed"));
} finally { } finally {
setIsRequesting(false); setIsRequesting(false);
} }
@@ -128,33 +207,29 @@ export function PermissionDialog({
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{isCurrentPermissionGranted && (
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-sm text-success">
Permission granted! Browsers launched from Donut Browser can
now access your {permissionType}.
</p>
</div>
)}
{!isCurrentPermissionGranted && ( {!isCurrentPermissionGranted && (
<div className="p-3 bg-warning/10 rounded-lg"> <div className="p-3 bg-warning/10 rounded-lg">
<p className="text-sm text-warning"> <p className="text-sm text-warning">
Permission not granted. Click the button below to request {permissionType === "microphone"
access to your {permissionType}. ? t("permissionDialog.notGrantedMicrophone")
: t("permissionDialog.notGrantedCamera")}
</p> </p>
</div> </div>
)} )}
</div> </div>
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
<RippleButton variant="outline" onClick={onClose}> <RippleButton
{isCurrentPermissionGranted ? "Done" : "Cancel"} variant="outline"
onClick={onClose}
className="min-w-24"
>
{t("permissionDialog.cancelButton")}
</RippleButton> </RippleButton>
{!isCurrentPermissionGranted && ( {!isCurrentPermissionGranted && (
<LoadingButton <LoadingButton
isLoading={isRequesting} isLoading={isRequesting || isWaitingForGrant}
onClick={() => { onClick={() => {
handleRequestPermission().catch((err: unknown) => { handleRequestPermission().catch((err: unknown) => {
console.error(err); console.error(err);
@@ -162,7 +237,7 @@ export function PermissionDialog({
}} }}
className="min-w-24" className="min-w-24"
> >
Grant Access {t("permissionDialog.grantAccessButton")}
</LoadingButton> </LoadingButton>
)} )}
</DialogFooter> </DialogFooter>
+104 -45
View File
@@ -236,6 +236,7 @@ function getProfileSyncStatusDot(
| "error" | "error"
| "disabled" | "disabled"
| undefined, | undefined,
t: (key: string, options?: Record<string, unknown>) => string,
errorMessage?: string, errorMessage?: string,
): SyncStatusDot | null { ): SyncStatusDot | null {
const encrypted = profile.sync_mode === "Encrypted"; const encrypted = profile.sync_mode === "Encrypted";
@@ -249,14 +250,14 @@ function getProfileSyncStatusDot(
case "syncing": case "syncing":
return { return {
color: "bg-warning", color: "bg-warning",
tooltip: "Syncing...", tooltip: t("profileTable.syncTooltipSyncing"),
animate: true, animate: true,
encrypted, encrypted,
}; };
case "waiting": case "waiting":
return { return {
color: "bg-warning", color: "bg-warning",
tooltip: "Close the profile to sync", tooltip: t("profileTable.syncTooltipCloseToSync"),
animate: false, animate: false,
encrypted, encrypted,
}; };
@@ -264,15 +265,19 @@ function getProfileSyncStatusDot(
return { return {
color: "bg-success", color: "bg-success",
tooltip: profile.last_sync tooltip: profile.last_sync
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}` ? t("profileTable.syncTooltipSyncedAt", {
: "Synced", time: new Date(profile.last_sync * 1000).toLocaleString(),
})
: t("profileTable.syncTooltipSynced"),
animate: false, animate: false,
encrypted, encrypted,
}; };
case "error": case "error":
return { return {
color: "bg-destructive", color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error", tooltip: errorMessage
? t("profileTable.syncTooltipErrorWith", { error: errorMessage })
: t("profileTable.syncTooltipError"),
animate: false, animate: false,
encrypted, encrypted,
}; };
@@ -280,7 +285,9 @@ function getProfileSyncStatusDot(
if (profile.last_sync) { if (profile.last_sync) {
return { return {
color: "bg-muted-foreground", color: "bg-muted-foreground",
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`, tooltip: t("profileTable.syncTooltipDisabledWithLast", {
time: formatRelativeTime(profile.last_sync),
}),
animate: false, animate: false,
encrypted: false, encrypted: false,
}; };
@@ -313,6 +320,7 @@ const TagsCell = React.memo<{
setOpenTagsEditorFor, setOpenTagsEditorFor,
setTagsOverrides, setTagsOverrides,
}) => { }) => {
const { t: translate } = useTranslation();
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id) const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id)
? tagsOverrides[profile.id] ? tagsOverrides[profile.id]
: (profile.tags ?? []); : (profile.tags ?? []);
@@ -475,7 +483,9 @@ const TagsCell = React.memo<{
</Badge> </Badge>
))} ))}
{effectiveTags.length === 0 && ( {effectiveTags.length === 0 && (
<span className="text-muted-foreground">No tags</span> <span className="text-muted-foreground">
{translate("profileTable.noTags")}
</span>
)} )}
{hiddenCount > 0 && ( {hiddenCount > 0 && (
<Badge variant="outline" className="px-2 py-0 text-xs"> <Badge variant="outline" className="px-2 py-0 text-xs">
@@ -526,7 +536,11 @@ const TagsCell = React.memo<{
onChange={(opts) => void handleChange(opts)} onChange={(opts) => void handleChange(opts)}
creatable creatable
selectFirstItem={false} selectFirstItem={false}
placeholder={effectiveTags.length === 0 ? "Add tags" : ""} placeholder={
effectiveTags.length === 0
? translate("profileTable.addTagsPlaceholder")
: ""
}
className={cn( className={cn(
"bg-transparent border-0! focus-within:ring-0!", "bg-transparent border-0! focus-within:ring-0!",
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!", "[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
@@ -630,6 +644,7 @@ const NoteCell = React.memo<{
setOpenNoteEditorFor, setOpenNoteEditorFor,
setNoteOverrides, setNoteOverrides,
}) => { }) => {
const { t } = useTranslation();
const effectiveNote: string | null = Object.hasOwn( const effectiveNote: string | null = Object.hasOwn(
noteOverrides, noteOverrides,
profile.id, profile.id,
@@ -745,14 +760,14 @@ const NoteCell = React.memo<{
!effectiveNote && "text-muted-foreground", !effectiveNote && "text-muted-foreground",
)} )}
> >
{effectiveNote ? trimmedNote : "No Note"} {effectiveNote ? trimmedNote : t("profiles.note.empty")}
</span> </span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
{showTooltip && ( {showTooltip && (
<TooltipContent className="max-w-[320px]"> <TooltipContent className="max-w-[320px]">
<p className="whitespace-pre-wrap wrap-break-word"> <p className="whitespace-pre-wrap wrap-break-word">
{effectiveNote ?? "No Note"} {effectiveNote ?? t("profiles.note.empty")}
</p> </p>
</TooltipContent> </TooltipContent>
)} )}
@@ -789,7 +804,7 @@ const NoteCell = React.memo<{
void onNoteChange(noteValue); void onNoteChange(noteValue);
setOpenNoteEditorFor(null); setOpenNoteEditorFor(null);
}} }}
placeholder="Add a note..." placeholder={t("profiles.note.placeholder")}
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0" className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
style={{ style={{
overflow: "auto", overflow: "auto",
@@ -839,6 +854,9 @@ interface ProfilesDataTableProps {
} }
| undefined; | undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void; onLaunchWithSync?: (profile: BrowserProfile) => void;
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
} }
export function ProfilesDataTable({ export function ProfilesDataTable({
@@ -868,6 +886,9 @@ export function ProfilesDataTable({
syncUnlocked = false, syncUnlocked = false,
getProfileSyncInfo, getProfileSyncInfo,
onLaunchWithSync, onLaunchWithSync,
onSetPassword,
onChangePassword,
onRemovePassword,
}: ProfilesDataTableProps) { }: ProfilesDataTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -892,6 +913,13 @@ export function ProfilesDataTable({
} }
setRowSelection(newSelection); setRowSelection(newSelection);
prevSelectedProfilesRef.current = selectedProfiles; prevSelectedProfilesRef.current = selectedProfiles;
// When the parent clears the selection (e.g. after a bulk action like
// delete / move-to-group), collapse the checkbox column back to icons.
// Otherwise the row checkboxes stay visible and only revert after the
// user clicks one — which the per-checkbox handler resets.
if (selectedProfiles.length === 0) {
setShowCheckboxes(false);
}
} }
}, [selectedProfiles]); }, [selectedProfiles]);
@@ -1334,12 +1362,14 @@ export function ProfilesDataTable({
setRenameError(null); setRenameError(null);
} catch (error) { } catch (error) {
setRenameError( setRenameError(
error instanceof Error ? error.message : "Failed to rename profile", error instanceof Error
? error.message
: t("errors.renameProfileFailed", { error: String(error) }),
); );
} finally { } finally {
setIsRenamingSaving(false); setIsRenamingSaving(false);
} }
}, [profileToRename, newProfileName, onRenameProfile]); }, [profileToRename, newProfileName, onRenameProfile, t]);
// Cancel inline rename on outside click // Cancel inline rename on outside click
React.useEffect(() => { React.useEffect(() => {
@@ -1661,7 +1691,7 @@ export function ProfilesDataTable({
onCheckedChange={(value) => { onCheckedChange={(value) => {
meta.handleToggleAll(!!value); meta.handleToggleAll(!!value);
}} }}
aria-label="Select all" aria-label={t("common.aria.selectAll")}
className="cursor-pointer" className="cursor-pointer"
/> />
</span> </span>
@@ -1671,7 +1701,9 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta; const meta = table.options.meta as TableMeta;
const profile = row.original; const profile = row.original;
const browser = profile.browser; const browser = profile.browser;
const IconComponent = getProfileIcon(profile); const IconComponent = profile.password_protected
? LuLock
: getProfileIcon(profile);
const isCrossOs = isCrossOsProfile(profile); const isCrossOs = isCrossOsProfile(profile);
const isSelected = meta.isProfileSelected(profile.id); const isSelected = meta.isProfileSelected(profile.id);
@@ -1707,7 +1739,7 @@ export function ProfilesDataTable({
onClick={() => { onClick={() => {
meta.handleIconClick(profile.id); meta.handleIconClick(profile.id);
}} }}
aria-label="Select profile" aria-label={t("common.aria.selectProfile")}
> >
<span className="w-4 h-4 group"> <span className="w-4 h-4 group">
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" /> <OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
@@ -1745,7 +1777,7 @@ export function ProfilesDataTable({
onCheckedChange={(value) => { onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value); meta.handleCheckboxChange(profile.id, !!value);
}} }}
aria-label="Select row" aria-label={t("common.aria.selectRow")}
className="w-4 h-4" className="w-4 h-4"
/> />
</span> </span>
@@ -1793,7 +1825,7 @@ export function ProfilesDataTable({
onCheckedChange={(value) => { onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value); meta.handleCheckboxChange(profile.id, !!value);
}} }}
aria-label="Select row" aria-label={t("common.aria.selectRow")}
className="w-4 h-4" className="w-4 h-4"
/> />
</span> </span>
@@ -1814,7 +1846,7 @@ export function ProfilesDataTable({
onClick={() => { onClick={() => {
meta.handleIconClick(profile.id); meta.handleIconClick(profile.id);
}} }}
aria-label="Select profile" aria-label={t("common.aria.selectProfile")}
> >
<span className="w-4 h-4 group"> <span className="w-4 h-4 group">
{IconComponent && ( {IconComponent && (
@@ -1833,6 +1865,7 @@ export function ProfilesDataTable({
}, },
{ {
id: "actions", id: "actions",
size: 100,
cell: ({ row, table }) => { cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta; const meta = table.options.meta as TableMeta;
const profile = row.original; const profile = row.original;
@@ -1951,7 +1984,7 @@ export function ProfilesDataTable({
size="sm" size="sm"
disabled={!canLaunch || isLaunching || isStopping} disabled={!canLaunch || isLaunching || isStopping}
className={cn( className={cn(
"min-w-[70px] h-7", "min-w-[80px] h-7 px-3",
!canLaunch && "opacity-50 cursor-not-allowed", !canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer", canLaunch && "cursor-pointer",
isFollower && "border-accent", isFollower && "border-accent",
@@ -1967,9 +2000,9 @@ export function ProfilesDataTable({
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" /> <div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
</div> </div>
) : isRunning ? ( ) : isRunning ? (
"Stop" meta.t("profiles.actions.stop")
) : ( ) : (
"Launch" meta.t("profiles.actions.launch")
)} )}
</RippleButton> </RippleButton>
</span> </span>
@@ -1986,7 +2019,9 @@ export function ProfilesDataTable({
}, },
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { size: 130,
header: ({ column, table }) => {
const meta = table.options.meta as TableMeta;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
@@ -1995,7 +2030,7 @@ export function ProfilesDataTable({
}} }}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer" className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
> >
Name {meta.t("common.labels.name")}
{column.getIsSorted() === "asc" ? ( {column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 w-4 h-4" /> <LuChevronUp className="ml-2 w-4 h-4" />
) : column.getIsSorted() === "desc" ? ( ) : column.getIsSorted() === "desc" ? (
@@ -2124,7 +2159,11 @@ export function ProfilesDataTable({
}, },
{ {
id: "tags", id: "tags",
header: "Tags", size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.tagsHeader");
},
cell: ({ row, table }) => { cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta; const meta = table.options.meta as TableMeta;
const profile = row.original; const profile = row.original;
@@ -2153,7 +2192,11 @@ export function ProfilesDataTable({
}, },
{ {
id: "note", id: "note",
header: "Note", size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.noteHeader");
},
cell: ({ row, table }) => { cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta; const meta = table.options.meta as TableMeta;
const profile = row.original; const profile = row.original;
@@ -2180,7 +2223,11 @@ export function ProfilesDataTable({
}, },
{ {
id: "proxy", id: "proxy",
header: "Proxy / VPN", size: 130,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.proxy");
},
cell: ({ row, table }) => { cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta; const meta = table.options.meta as TableMeta;
const profile = row.original; const profile = row.original;
@@ -2218,7 +2265,7 @@ export function ProfilesDataTable({
? effectiveVpn.name ? effectiveVpn.name
: effectiveProxy : effectiveProxy
? effectiveProxy.name ? effectiveProxy.name
: "Not Selected"; : meta.t("profiles.table.notSelected");
const vpnBadge = effectiveVpn ? "WG" : null; const vpnBadge = effectiveVpn ? "WG" : null;
const tooltipText = hasAssignment ? displayName : null; const tooltipText = hasAssignment ? displayName : null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id; const isSelectorOpen = meta.openProxySelectorFor === profile.id;
@@ -2299,8 +2346,8 @@ export function ProfilesDataTable({
<CommandInput <CommandInput
placeholder={ placeholder={
meta.canCreateLocationProxy meta.canCreateLocationProxy
? "Search proxies, VPNs, or countries..." ? t("createProfile.proxy.searchWithCountries")
: "Search proxies or VPNs..." : t("createProfile.proxy.search")
} }
onFocus={() => { onFocus={() => {
if (meta.canCreateLocationProxy) if (meta.canCreateLocationProxy)
@@ -2308,7 +2355,9 @@ export function ProfilesDataTable({
}} }}
/> />
<CommandList> <CommandList>
<CommandEmpty>No proxies or VPNs found.</CommandEmpty> <CommandEmpty>
{t("createProfile.proxy.notFound")}
</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
value="__none__" value="__none__"
@@ -2324,7 +2373,7 @@ export function ProfilesDataTable({
: "opacity-0", : "opacity-0",
)} )}
/> />
None {t("common.labels.none")}
</CommandItem> </CommandItem>
{meta.storedProxies {meta.storedProxies
.filter( .filter(
@@ -2357,7 +2406,7 @@ export function ProfilesDataTable({
))} ))}
</CommandGroup> </CommandGroup>
{meta.vpnConfigs.length > 0 && ( {meta.vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs"> <CommandGroup heading={t("profileTable.vpnsHeading")}>
{meta.vpnConfigs.map((vpn) => ( {meta.vpnConfigs.map((vpn) => (
<CommandItem <CommandItem
key={vpn.id} key={vpn.id}
@@ -2390,7 +2439,9 @@ export function ProfilesDataTable({
)} )}
{meta.canCreateLocationProxy && {meta.canCreateLocationProxy &&
meta.countries.length > 0 && ( meta.countries.length > 0 && (
<CommandGroup heading="Create by country"> <CommandGroup
heading={t("profileTable.createByCountryHeading")}
>
{meta.countries {meta.countries
.filter( .filter(
(c) => (c) =>
@@ -2466,6 +2517,7 @@ export function ProfilesDataTable({
const dot = getProfileSyncStatusDot( const dot = getProfileSyncStatusDot(
profile, profile,
liveStatus, liveStatus,
meta.t,
syncEntry?.error, syncEntry?.error,
); );
if (!dot) return null; if (!dot) return null;
@@ -2507,7 +2559,9 @@ export function ProfilesDataTable({
setProfileForInfoDialog(profile); setProfileForInfoDialog(profile);
}} }}
> >
<span className="sr-only">Profile info</span> <span className="sr-only">
{t("profiles.aria.profileInfo")}
</span>
<LuInfo className="w-4 h-4" /> <LuInfo className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -2551,7 +2605,7 @@ export function ProfilesDataTable({
platform === "macos" ? "h-[340px]" : "h-[280px]", platform === "macos" ? "h-[340px]" : "h-[280px]",
)} )}
> >
<Table className="overflow-visible"> <Table className="overflow-visible table-fixed">
<TableHeader className="overflow-visible"> <TableHeader className="overflow-visible">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="overflow-visible"> <TableRow key={headerGroup.id} className="overflow-visible">
@@ -2626,7 +2680,7 @@ export function ProfilesDataTable({
colSpan={columns.length} colSpan={columns.length}
className="h-24 text-center" className="h-24 text-center"
> >
No profiles found. {t("profiles.table.empty")}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -2639,9 +2693,11 @@ export function ProfilesDataTable({
setProfileToDelete(null); setProfileToDelete(null);
}} }}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Delete Profile" title={t("profiles.delete.title")}
description={`This action cannot be undone. This will permanently delete the profile "${profileToDelete?.name}" and all its associated data.`} description={t("profiles.delete.description", {
confirmButtonText="Delete Profile" profileName: profileToDelete?.name ?? "",
})}
confirmButtonText={t("profiles.delete.confirmButton")}
isLoading={isDeleting} isLoading={isDeleting}
/> />
{profileForInfoDialog && {profileForInfoDialog &&
@@ -2684,6 +2740,9 @@ export function ProfilesDataTable({
}} }}
onCloneProfile={onCloneProfile} onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync} onLaunchWithSync={onLaunchWithSync}
onSetPassword={onSetPassword}
onChangePassword={onChangePassword}
onRemovePassword={onRemovePassword}
onDeleteProfile={(profile) => { onDeleteProfile={(profile) => {
setProfileForInfoDialog(null); setProfileForInfoDialog(null);
setProfileToDelete(profile); setProfileToDelete(profile);
@@ -2700,7 +2759,7 @@ export function ProfilesDataTable({
<DataTableActionBarSelection table={table} /> <DataTableActionBarSelection table={table} />
{onBulkGroupAssignment && ( {onBulkGroupAssignment && (
<DataTableActionBarAction <DataTableActionBarAction
tooltip="Assign to Group" tooltip={t("profiles.actionBar.assignToGroup")}
onClick={onBulkGroupAssignment} onClick={onBulkGroupAssignment}
size="icon" size="icon"
> >
@@ -2709,7 +2768,7 @@ export function ProfilesDataTable({
)} )}
{onBulkProxyAssignment && ( {onBulkProxyAssignment && (
<DataTableActionBarAction <DataTableActionBarAction
tooltip="Assign Proxy" tooltip={t("profiles.actionBar.assignProxy")}
onClick={onBulkProxyAssignment} onClick={onBulkProxyAssignment}
size="icon" size="icon"
> >
@@ -2718,7 +2777,7 @@ export function ProfilesDataTable({
)} )}
{onBulkExtensionGroupAssignment && ( {onBulkExtensionGroupAssignment && (
<DataTableActionBarAction <DataTableActionBarAction
tooltip="Assign Extension Group" tooltip={t("profiles.actionBar.assignExtensionGroup")}
onClick={onBulkExtensionGroupAssignment} onClick={onBulkExtensionGroupAssignment}
size="icon" size="icon"
> >
@@ -2727,7 +2786,7 @@ export function ProfilesDataTable({
)} )}
{onBulkCopyCookies && ( {onBulkCopyCookies && (
<DataTableActionBarAction <DataTableActionBarAction
tooltip="Copy Cookies" tooltip={t("profiles.actionBar.copyCookies")}
onClick={onBulkCopyCookies} onClick={onBulkCopyCookies}
size="icon" size="icon"
> >
@@ -2736,7 +2795,7 @@ export function ProfilesDataTable({
)} )}
{onBulkDelete && ( {onBulkDelete && (
<DataTableActionBarAction <DataTableActionBarAction
tooltip="Delete" tooltip={t("common.buttons.delete")}
onClick={onBulkDelete} onClick={onBulkDelete}
size="icon" size="icon"
variant="destructive" variant="destructive"
+49
View File
@@ -13,7 +13,10 @@ import {
LuFingerprint, LuFingerprint,
LuGlobe, LuGlobe,
LuGroup, LuGroup,
LuKey,
LuLink, LuLink,
LuLock,
LuLockOpen,
LuPlus, LuPlus,
LuPuzzle, LuPuzzle,
LuRefreshCw, LuRefreshCw,
@@ -71,6 +74,9 @@ interface ProfileInfoDialogProps {
onCloneProfile?: (profile: BrowserProfile) => void; onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void; onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void; onLaunchWithSync?: (profile: BrowserProfile) => void;
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean; crossOsUnlocked?: boolean;
isRunning?: boolean; isRunning?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
@@ -119,6 +125,9 @@ export function ProfileInfoDialog({
onCloneProfile, onCloneProfile,
onDeleteProfile, onDeleteProfile,
onLaunchWithSync, onLaunchWithSync,
onSetPassword,
onChangePassword,
onRemovePassword,
crossOsUnlocked = false, crossOsUnlocked = false,
isRunning = false, isRunning = false,
isDisabled = false, isDisabled = false,
@@ -354,6 +363,40 @@ export function ProfileInfoDialog({
}, },
hidden: !onOpenLaunchHook, 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" />, icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"), label: t("profiles.actions.delete"),
@@ -417,6 +460,12 @@ export function ProfileInfoDialog({
{t("profiles.ephemeralBadge")} {t("profiles.ephemeralBadge")}
</Badge> </Badge>
)} )}
{profile.password_protected && (
<Badge variant="outline" className="text-xs gap-1">
<LuLock className="w-3 h-3" />
{t("profiles.passwordProtectedBadge")}
</Badge>
)}
{showCrossOs && ( {showCrossOs && (
<Badge variant="outline" className="text-xs gap-1"> <Badge variant="outline" className="text-xs gap-1">
<OSIcon <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>
);
}
+31 -20
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@@ -47,8 +48,17 @@ export function ProfileSelectorDialog({
runningProfiles: externalRunningProfiles, runningProfiles: externalRunningProfiles,
isUpdating, isUpdating,
}: ProfileSelectorDialogProps) { }: ProfileSelectorDialogProps) {
const { t } = useTranslation();
// Use the centralized profile events hook // Use the centralized profile events hook
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents(); const { profiles: rawProfiles, runningProfiles: hookRunningProfiles } =
useProfileEvents();
const profiles = useMemo(
() =>
[...rawProfiles].sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
),
[rawProfiles],
);
// Use external runningProfiles if provided, otherwise use hook's runningProfiles // Use external runningProfiles if provided, otherwise use hook's runningProfiles
const runningProfiles = externalRunningProfiles ?? hookRunningProfiles; const runningProfiles = externalRunningProfiles ?? hookRunningProfiles;
@@ -146,11 +156,7 @@ export function ProfileSelectorDialog({
if (runningAvailableProfile) { if (runningAvailableProfile) {
setSelectedProfile(runningAvailableProfile.name); setSelectedProfile(runningAvailableProfile.name);
} else { } else {
// Sort profiles by name and select first setSelectedProfile(profiles[0].name);
const sortedProfiles = [...profiles].sort((a, b) =>
a.name.localeCompare(b.name),
);
setSelectedProfile(sortedProfiles[0].name);
} }
} }
}, [isOpen, profiles, selectedProfile, runningProfiles]); }, [isOpen, profiles, selectedProfile, runningProfiles]);
@@ -159,17 +165,19 @@ export function ProfileSelectorDialog({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Choose Profile</DialogTitle> <DialogTitle>{t("profileSelector.chooseProfileTitle")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
{url && ( {url && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label className="text-sm font-medium">Opening URL:</Label> <Label className="text-sm font-medium">
{t("profileSelector.openingUrl")}
</Label>
<CopyToClipboard <CopyToClipboard
text={url} text={url}
successMessage="URL copied to clipboard!" successMessage={t("profileSelector.urlCopied")}
/> />
</div> </div>
<div className="p-2 text-sm break-all rounded bg-muted"> <div className="p-2 text-sm break-all rounded bg-muted">
@@ -179,15 +187,16 @@ export function ProfileSelectorDialog({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-select">Select Profile:</Label> <Label htmlFor="profile-select">
{t("profileSelector.selectProfileLabel")}
</Label>
{profiles.length === 0 ? ( {profiles.length === 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
No profiles available. Please create a profile first. {t("profileSelector.noneAvailableShort")}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Close this dialog and create a profile from the main window to {t("profileSelector.noneAvailableLong")}
get started.
</div> </div>
</div> </div>
) : ( ) : (
@@ -196,7 +205,9 @@ export function ProfileSelectorDialog({
onValueChange={setSelectedProfile} onValueChange={setSelectedProfile}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Choose a profile" /> <SelectValue
placeholder={t("profileSelector.chooseAProfile")}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{profiles.map((profile) => { {profiles.map((profile) => {
@@ -241,12 +252,12 @@ export function ProfileSelectorDialog({
</Badge> </Badge>
{hasProxy(profile) && ( {hasProxy(profile) && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
Proxy {t("profileSelector.badgeProxy")}
</Badge> </Badge>
)} )}
{isRunning && ( {isRunning && (
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
Running {t("profileSelector.badgeRunning")}
</Badge> </Badge>
)} )}
{!canUseForLinks && ( {!canUseForLinks && (
@@ -254,7 +265,7 @@ export function ProfileSelectorDialog({
variant="destructive" variant="destructive"
className="text-xs" className="text-xs"
> >
Unavailable {t("profileSelector.badgeUnavailable")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -275,7 +286,7 @@ export function ProfileSelectorDialog({
<DialogFooter> <DialogFooter>
<RippleButton variant="outline" onClick={handleCancel}> <RippleButton variant="outline" onClick={handleCancel}>
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -289,7 +300,7 @@ export function ProfileSelectorDialog({
!canOpenWithSelectedProfile() !canOpenWithSelectedProfile()
} }
> >
Open {t("profileSelector.openButton")}
</LoadingButton> </LoadingButton>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
+14 -31
View File
@@ -166,7 +166,7 @@ export function ProfileSyncDialog({
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]); }, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
const formatLastSync = (timestamp?: number) => { const formatLastSync = (timestamp?: number) => {
if (!timestamp) return t("common.labels.never", "Never"); if (!timestamp) return t("common.labels.never");
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
return date.toLocaleString(); return date.toLocaleString();
}; };
@@ -177,7 +177,7 @@ export function ProfileSyncDialog({
<Dialog open={isOpen} onOpenChange={handleOpenChange}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle> <DialogTitle>{t("sync.mode.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("sync.mode.description", { {t("sync.mode.description", {
name: profile.name, name: profile.name,
@@ -194,9 +194,7 @@ export function ProfileSyncDialog({
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
{!hasConfig && ( {!hasConfig && (
<div className="p-3 text-sm rounded-md bg-muted"> <div className="p-3 text-sm rounded-md bg-muted">
<p className="mb-2"> <p className="mb-2">{t("sync.mode.notConfigured")}</p>
{t("sync.mode.notConfigured", "Sync service not configured.")}
</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -205,7 +203,7 @@ export function ProfileSyncDialog({
onClose(); onClose();
}} }}
> >
{t("sync.mode.configureService", "Configure Sync Service")} {t("sync.mode.configureService")}
</Button> </Button>
</div> </div>
)} )}
@@ -222,13 +220,10 @@ export function ProfileSyncDialog({
<RadioGroupItem value="Disabled" id="sync-disabled" /> <RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer"> <Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium"> <span className="font-medium">
{t("sync.mode.disabled", "Disabled")} {t("sync.mode.disabled")}
</span> </span>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t("sync.mode.disabledDescription")}
"sync.mode.disabledDescription",
"No sync for this profile",
)}
</p> </p>
</Label> </Label>
</div> </div>
@@ -237,13 +232,10 @@ export function ProfileSyncDialog({
<RadioGroupItem value="Regular" id="sync-regular" /> <RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer"> <Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium"> <span className="font-medium">
{t("sync.mode.regular", "Regular Sync")} {t("sync.mode.regular")}
</span> </span>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t("sync.mode.regularDescription")}
"sync.mode.regularDescription",
"Fast sync, unencrypted",
)}
</p> </p>
</Label> </Label>
</div> </div>
@@ -263,18 +255,12 @@ export function ProfileSyncDialog({
} }
> >
<span className="font-medium"> <span className="font-medium">
{t("sync.mode.encrypted", "E2E Encrypted Sync")} {t("sync.mode.encrypted")}
</span> </span>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{canUseEncryption {canUseEncryption
? t( ? t("sync.mode.encryptedDescription")
"sync.mode.encryptedDescription", : t("settings.encryption.requiresProOrOwner")}
"Encrypted before upload. Server never sees plaintext data.",
)
: t(
"settings.encryption.requiresProOrOwner",
"Profile encryption is available for Pro users and team owners.",
)}
</p> </p>
</Label> </Label>
</div> </div>
@@ -284,15 +270,12 @@ export function ProfileSyncDialog({
!hasE2ePassword && !hasE2ePassword &&
userChangedMode && ( userChangedMode && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive"> <div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t( {t("sync.mode.noPasswordWarning")}
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label> <Label>{t("sync.mode.lastSynced")}</Label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Badge variant="outline"> <Badge variant="outline">
{formatLastSync(profile.last_sync)} {formatLastSync(profile.last_sync)}
@@ -319,7 +302,7 @@ export function ProfileSyncDialog({
</Button> </Button>
{hasConfig && isSyncEnabled(profile) && ( {hasConfig && isSyncEnabled(profile) && (
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}> <LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
{t("sync.mode.syncNow", "Sync Now")} {t("sync.mode.syncNow")}
</LoadingButton> </LoadingButton>
)} )}
</DialogFooter> </DialogFooter>
+33 -16
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu"; import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
@@ -53,6 +54,7 @@ export function ProxyAssignmentDialog({
storedProxies = [], storedProxies = [],
vpnConfigs = [], vpnConfigs = [],
}: ProxyAssignmentDialogProps) { }: ProxyAssignmentDialogProps) {
const { t } = useTranslation();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">( const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
"none", "none",
@@ -84,7 +86,7 @@ export function ProxyAssignmentDialog({
}); });
if (validProfiles.length === 0) { if (validProfiles.length === 0) {
setError("No valid profiles selected."); setError(t("proxyAssignment.noValidProfiles"));
setIsAssigning(false); setIsAssigning(false);
return; return;
} }
@@ -111,7 +113,7 @@ export function ProxyAssignmentDialog({
const errorMessage = const errorMessage =
err instanceof Error err instanceof Error
? err.message ? err.message
: "Failed to assign proxy/VPN to profiles"; : t("proxyAssignment.failedFallback");
setError(errorMessage); setError(errorMessage);
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@@ -124,6 +126,7 @@ export function ProxyAssignmentDialog({
profiles, profiles,
onAssignmentComplete, onAssignmentComplete,
onClose, onClose,
t,
]); ]);
useEffect(() => { useEffect(() => {
@@ -138,16 +141,21 @@ export function ProxyAssignmentDialog({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Assign Proxy / VPN</DialogTitle> <DialogTitle>{t("proxyAssignment.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Assign a proxy or VPN to {selectedProfiles.length} selected {selectedProfiles.length === 1
profile(s). ? t("proxyAssignment.description_one", {
count: selectedProfiles.length,
})
: t("proxyAssignment.description_other", {
count: selectedProfiles.length,
})}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Selected Profiles:</Label> <Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto"> <div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => { {selectedProfiles.map((profileId) => {
@@ -166,7 +174,9 @@ export function ProxyAssignmentDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label> <Label htmlFor="proxy-vpn-select">
{t("proxyAssignment.assignProxyVpnLabel")}
</Label>
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}> <Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -176,24 +186,29 @@ export function ProxyAssignmentDialog({
className="w-full justify-between font-normal" className="w-full justify-between font-normal"
> >
{(() => { {(() => {
if (selectionType === "none") return "None"; if (selectionType === "none")
return t("proxyAssignment.noneOption");
if (selectionType === "vpn") { if (selectionType === "vpn") {
const vpn = vpnConfigs.find((v) => v.id === selectedId); const vpn = vpnConfigs.find((v) => v.id === selectedId);
return vpn ? `WG — ${vpn.name}` : "None"; return vpn
? `WG — ${vpn.name}`
: t("proxyAssignment.noneOption");
} }
const proxy = storedProxies.find( const proxy = storedProxies.find(
(p) => p.id === selectedId, (p) => p.id === selectedId,
); );
return proxy ? proxy.name : "None"; return proxy ? proxy.name : t("proxyAssignment.noneOption");
})()} })()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[240px] p-0" sideOffset={8}> <PopoverContent className="w-[240px] p-0" sideOffset={8}>
<Command> <Command>
<CommandInput placeholder="Search proxies or VPNs..." /> <CommandInput
placeholder={t("proxyAssignment.searchPlaceholder")}
/>
<CommandList> <CommandList>
<CommandEmpty>No proxies or VPNs found.</CommandEmpty> <CommandEmpty>{t("proxyAssignment.notFound")}</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
value="__none__" value="__none__"
@@ -210,7 +225,7 @@ export function ProxyAssignmentDialog({
: "opacity-0", : "opacity-0",
)} )}
/> />
None {t("proxyAssignment.noneOption")}
</CommandItem> </CommandItem>
{storedProxies {storedProxies
.filter( .filter(
@@ -240,7 +255,9 @@ export function ProxyAssignmentDialog({
))} ))}
</CommandGroup> </CommandGroup>
{vpnConfigs.length > 0 && ( {vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs"> <CommandGroup
heading={t("proxyAssignment.vpnGroupHeading")}
>
{vpnConfigs.map((vpn) => ( {vpnConfigs.map((vpn) => (
<CommandItem <CommandItem
key={vpn.id} key={vpn.id}
@@ -288,13 +305,13 @@ export function ProxyAssignmentDialog({
onClick={onClose} onClick={onClose}
disabled={isAssigning} disabled={isAssigning}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isAssigning} isLoading={isAssigning}
onClick={() => void handleAssign()} onClick={() => void handleAssign()}
> >
Assign {t("proxyAssignment.assignButton")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+19 -10
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { FiCheck } from "react-icons/fi"; import { FiCheck } from "react-icons/fi";
import { toast } from "sonner"; import { toast } from "sonner";
import { FlagIcon } from "@/components/flag-icon"; import { FlagIcon } from "@/components/flag-icon";
@@ -35,6 +36,7 @@ export function ProxyCheckButton({
disabled = false, disabled = false,
setCheckingProfileId, setCheckingProfileId,
}: ProxyCheckButtonProps) { }: ProxyCheckButtonProps) {
const { t } = useTranslation();
const [localResult, setLocalResult] = React.useState< const [localResult, setLocalResult] = React.useState<
ProxyCheckResult | undefined ProxyCheckResult | undefined
>(cachedResult); >(cachedResult);
@@ -60,11 +62,13 @@ export function ProxyCheckButton({
if (result.city) locationParts.push(result.city); if (result.city) locationParts.push(result.city);
if (result.country) locationParts.push(result.country); if (result.country) locationParts.push(result.country);
const location = const location =
locationParts.length > 0 ? locationParts.join(", ") : "Unknown"; locationParts.length > 0
? locationParts.join(", ")
: t("proxyCheck.unknownLocation");
toast.success( toast.success(
<div className="flex flex-col"> <div className="flex flex-col">
Your proxy location is: {t("proxyCheck.locationToast")}
<div className="flex items-center whitespace-nowrap"> <div className="flex items-center whitespace-nowrap">
{location} {location}
{result.country_code && ( {result.country_code && (
@@ -79,7 +83,7 @@ export function ProxyCheckButton({
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
toast.error(`Proxy check failed: ${errorMessage}`); toast.error(t("proxyCheck.failed", { error: errorMessage }));
// Save failed check result // Save failed check result
const failedResult: ProxyCheckResult = { const failedResult: ProxyCheckResult = {
@@ -102,6 +106,7 @@ export function ProxyCheckButton({
onCheckComplete, onCheckComplete,
onCheckFailed, onCheckFailed,
setCheckingProfileId, setCheckingProfileId,
t,
]); ]);
const isCurrentlyChecking = checkingProfileId === profileId; const isCurrentlyChecking = checkingProfileId === profileId;
@@ -133,7 +138,7 @@ export function ProxyCheckButton({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{isCurrentlyChecking ? ( {isCurrentlyChecking ? (
<p>Checking proxy...</p> <p>{t("proxyCheck.tooltipChecking")}</p>
) : result?.is_valid ? ( ) : result?.is_valid ? (
<div className="space-y-1"> <div className="space-y-1">
<p className="flex items-center gap-1"> <p className="flex items-center gap-1">
@@ -141,24 +146,28 @@ export function ProxyCheckButton({
<FlagIcon countryCode={result.country_code} /> <FlagIcon countryCode={result.country_code} />
)} )}
{[result.city, result.country].filter(Boolean).join(", ") || {[result.city, result.country].filter(Boolean).join(", ") ||
"Unknown"} t("proxyCheck.unknownLocation")}
</p> </p>
<p className="text-xs text-primary-foreground/70"> <p className="text-xs text-primary-foreground/70">
IP: {result.ip} {t("proxyCheck.tooltipIp", { ip: result.ip })}
</p> </p>
<p className="text-xs text-primary-foreground/70"> <p className="text-xs text-primary-foreground/70">
Checked {formatRelativeTime(result.timestamp)} {t("proxyCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
</p> </p>
</div> </div>
) : result && !result.is_valid ? ( ) : result && !result.is_valid ? (
<div> <div>
<p>Proxy check failed</p> <p>{t("proxyCheck.tooltipFailedTitle")}</p>
<p className="text-xs text-primary-foreground/70"> <p className="text-xs text-primary-foreground/70">
Failed {formatRelativeTime(result.timestamp)} {t("proxyCheck.tooltipFailed", {
time: formatRelativeTime(result.timestamp),
})}
</p> </p>
</div> </div>
) : ( ) : (
<p>Check proxy validity</p> <p>{t("proxyCheck.tooltipDefault")}</p>
)} )}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
+22 -18
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuCopy, LuDownload } from "react-icons/lu"; import { LuCheck, LuCopy, LuDownload } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -23,6 +24,7 @@ interface ProxyExportDialogProps {
} }
export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) { export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
const { t } = useTranslation();
const [format, setFormat] = useState<"json" | "txt">("json"); const [format, setFormat] = useState<"json" | "txt">("json");
const [exportContent, setExportContent] = useState<string>(""); const [exportContent, setExportContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -35,12 +37,12 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
setExportContent(content); setExportContent(content);
} catch (error) { } catch (error) {
console.error("Failed to export proxies:", error); console.error("Failed to export proxies:", error);
toast.error("Failed to export proxies"); toast.error(t("proxies.exportDialog.failed"));
setExportContent(""); setExportContent("");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [format]); }, [format, t]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -52,15 +54,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
try { try {
await navigator.clipboard.writeText(exportContent); await navigator.clipboard.writeText(exportContent);
setCopied(true); setCopied(true);
toast.success("Copied to clipboard"); toast.success(t("toasts.success.copied"));
setTimeout(() => { setTimeout(() => {
setCopied(false); setCopied(false);
}, 2000); }, 2000);
} catch (error) { } catch (error) {
console.error("Failed to copy to clipboard:", error); console.error("Failed to copy to clipboard:", error);
toast.error("Failed to copy to clipboard"); toast.error(t("toasts.error.copyFailed"));
} }
}, [exportContent]); }, [exportContent, t]);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const filename = format === "json" ? "proxies.json" : "proxies.txt"; const filename = format === "json" ? "proxies.json" : "proxies.txt";
@@ -76,8 +78,8 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success(`Downloaded ${filename}`); toast.success(t("proxies.exportDialog.downloaded", { filename }));
}, [format, exportContent]); }, [format, exportContent, t]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setFormat("json"); setFormat("json");
@@ -90,15 +92,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Export Proxies</DialogTitle> <DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Export your proxy configurations to a file {t("proxies.exportDialog.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Export Format</Label> <Label>{t("proxies.exportDialog.format")}</Label>
<RadioGroup <RadioGroup
value={format} value={format}
onValueChange={(value) => { onValueChange={(value) => {
@@ -109,24 +111,24 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="json" id="format-json" /> <RadioGroupItem value="json" id="format-json" />
<Label htmlFor="format-json" className="cursor-pointer"> <Label htmlFor="format-json" className="cursor-pointer">
JSON {t("proxies.exportDialog.json")}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="txt" id="format-txt" /> <RadioGroupItem value="txt" id="format-txt" />
<Label htmlFor="format-txt" className="cursor-pointer"> <Label htmlFor="format-txt" className="cursor-pointer">
TXT (URL format) {t("proxies.exportDialog.txt")}
</Label> </Label>
</div> </div>
</RadioGroup> </RadioGroup>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Preview</Label> <Label>{t("proxies.exportDialog.preview")}</Label>
<ScrollArea className="h-[200px] border rounded-md bg-muted/30"> <ScrollArea className="h-[200px] border rounded-md bg-muted/30">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground"> <div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
Loading... {t("common.buttons.loading")}
</div> </div>
) : exportContent ? ( ) : exportContent ? (
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all"> <pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
@@ -134,7 +136,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
</pre> </pre>
) : ( ) : (
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground"> <div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
No proxies to export {t("proxies.exportDialog.noProxies")}
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
@@ -143,7 +145,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
<DialogFooter className="flex-col sm:flex-row gap-2"> <DialogFooter className="flex-col sm:flex-row gap-2">
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
Close {t("common.buttons.close")}
</RippleButton> </RippleButton>
<RippleButton <RippleButton
variant="outline" variant="outline"
@@ -156,7 +158,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
) : ( ) : (
<LuCopy className="w-4 h-4" /> <LuCopy className="w-4 h-4" />
)} )}
{copied ? "Copied" : "Copy"} {copied
? t("proxies.exportDialog.copied")
: t("common.buttons.copy")}
</RippleButton> </RippleButton>
<RippleButton <RippleButton
onClick={handleDownload} onClick={handleDownload}
@@ -164,7 +168,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
<LuDownload className="w-4 h-4" /> <LuDownload className="w-4 h-4" />
Download {t("common.buttons.download")}
</RippleButton> </RippleButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+5 -12
View File
@@ -83,14 +83,12 @@ export function ProxyFormDialog({
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!form.name.trim()) { if (!form.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required")); toast.error(t("proxies.form.nameRequired"));
return; return;
} }
if (!form.host.trim() || !form.port) { if (!form.host.trim() || !form.port) {
toast.error( toast.error(t("proxies.form.hostPortRequired"));
t("proxies.form.hostPortRequired", "Host and port are required"),
);
return; return;
} }
@@ -98,12 +96,7 @@ export function ProxyFormDialog({
form.proxy_type === "ss" && form.proxy_type === "ss" &&
(!form.username.trim() || !form.password.trim()) (!form.username.trim() || !form.password.trim())
) { ) {
toast.error( toast.error(t("proxies.form.ssCipherRequired"));
t(
"proxies.form.ssCipherRequired",
"Cipher and password are required for Shadowsocks",
),
);
return; return;
} }
@@ -136,7 +129,7 @@ export function ProxyFormDialog({
console.error("Failed to save proxy:", error); console.error("Failed to save proxy:", error);
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
toast.error(`Failed to save proxy: ${errorMessage}`); toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -189,7 +182,7 @@ export function ProxyFormDialog({
disabled={isSubmitting} disabled={isSubmitting}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select proxy type" /> <SelectValue placeholder={t("proxies.form.selectType")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{["http", "https", "socks4", "socks5", "ss"].map((type) => ( {["http", "https", "socks4", "socks5", "ss"].map((type) => (
+67 -40
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuUpload } from "react-icons/lu"; import { LuUpload } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
@@ -39,6 +40,7 @@ interface AmbiguousProxy {
} }
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<ImportStep>("dropzone"); const [step, setStep] = useState<ImportStep>("dropzone");
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]); const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
@@ -52,7 +54,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
null, null,
); );
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [namePrefix, setNamePrefix] = useState("Imported"); const [namePrefix, setNamePrefix] = useState(
t("proxies.importDialog.namePrefixDefault"),
);
const os = getCurrentOS(); const os = getCurrentOS();
const modKey = os === "macos" ? "⌘" : "Ctrl"; const modKey = os === "macos" ? "⌘" : "Ctrl";
@@ -65,8 +69,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
setInvalidProxies([]); setInvalidProxies([]);
setImportResult(null); setImportResult(null);
setIsImporting(false); setIsImporting(false);
setNamePrefix("Imported"); setNamePrefix(t("proxies.importDialog.namePrefixDefault"));
}, []); }, [t]);
const processContent = useCallback( const processContent = useCallback(
async (content: string, isJson: boolean, _filename = "") => { async (content: string, isJson: boolean, _filename = "") => {
@@ -116,19 +120,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
} else if (parsed.length > 0) { } else if (parsed.length > 0) {
setStep("preview"); setStep("preview");
} else { } else {
toast.error("No valid proxies found in the file"); toast.error(t("proxies.importDialog.noValidProxies"));
} }
} }
} catch (error) { } catch (error) {
console.error("Failed to process content:", error); console.error("Failed to process content:", error);
toast.error( toast.error(
error instanceof Error ? error.message : "Failed to process file", error instanceof Error
? error.message
: t("proxies.importDialog.fileProcessError"),
); );
} finally { } finally {
setIsImporting(false); setIsImporting(false);
} }
}, },
[], [t],
); );
const handleFileRead = useCallback( const handleFileRead = useCallback(
@@ -140,11 +146,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
void processContent(content, isJson, file.name); void processContent(content, isJson, file.name);
}; };
reader.onerror = () => { reader.onerror = () => {
toast.error("Failed to read file"); toast.error(t("proxies.importDialog.fileReadError"));
}; };
reader.readAsText(file); reader.readAsText(file);
}, },
[processContent], [processContent, t],
); );
const handleDrop = useCallback( const handleDrop = useCallback(
@@ -160,10 +166,10 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
if (validFile) { if (validFile) {
handleFileRead(validFile); handleFileRead(validFile);
} else { } else {
toast.error("Please drop a .json or .txt file"); toast.error(t("proxies.importDialog.wrongFileType"));
} }
}, },
[handleFileRead], [handleFileRead, t],
); );
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
@@ -206,7 +212,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
"import_proxies_from_parsed", "import_proxies_from_parsed",
{ {
parsedProxies, parsedProxies,
namePrefix: namePrefix.trim() || "Imported", namePrefix:
namePrefix.trim() || t("proxies.importDialog.namePrefixDefault"),
}, },
); );
setImportResult(result); setImportResult(result);
@@ -215,12 +222,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
} catch (error) { } catch (error) {
console.error("Failed to import proxies:", error); console.error("Failed to import proxies:", error);
toast.error( toast.error(
error instanceof Error ? error.message : "Failed to import proxies", error instanceof Error
? error.message
: t("proxies.importDialog.failed"),
); );
} finally { } finally {
setIsImporting(false); setIsImporting(false);
} }
}, [parsedProxies, namePrefix]); }, [parsedProxies, namePrefix, t]);
const handleAmbiguousFormatSelect = useCallback( const handleAmbiguousFormatSelect = useCallback(
(index: number, format: string) => { (index: number, format: string) => {
@@ -273,13 +282,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Import Proxies</DialogTitle> <DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{step === "dropzone" && "Import proxies from a JSON or TXT file"} {step === "dropzone" && t("proxies.importDialog.descDropzone")}
{step === "preview" && "Review the proxies to import"} {step === "preview" && t("proxies.importDialog.descPreview")}
{step === "ambiguous" && {step === "ambiguous" && t("proxies.importDialog.descAmbiguous")}
"Some proxies have ambiguous formats. Please select the correct format."} {step === "result" && t("proxies.importDialog.descResult")}
{step === "result" && "Import completed"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -309,9 +317,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
> >
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" /> <LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Drop a proxy config file {t("proxies.importDialog.dropzonePrompt")}
<br /> <br />
<span className="text-xs">(.json, .txt)</span> <span className="text-xs">
{t("proxies.importDialog.dropzoneFormats")}
</span>
</p> </p>
<input <input
id="proxy-file-input" id="proxy-file-input"
@@ -326,7 +336,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
/> />
</div> </div>
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
Paste from clipboard with {modKey}+V {t("proxies.importDialog.pasteHint", { modKey })}
</p> </p>
</div> </div>
)} )}
@@ -334,27 +344,35 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{step === "preview" && ( {step === "preview" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name-prefix">Name Prefix</Label> <Label htmlFor="name-prefix">
{t("proxies.importDialog.namePrefix")}
</Label>
<Input <Input
id="name-prefix" id="name-prefix"
placeholder="Imported" placeholder={t("proxies.importDialog.namePrefixDefault")}
value={namePrefix} value={namePrefix}
onChange={(e) => { onChange={(e) => {
setNamePrefix(e.target.value); setNamePrefix(e.target.value);
}} }}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Proxies will be named &quot;{namePrefix || "Imported"} Proxy {t("proxies.importDialog.namePrefixHint", {
1&quot;, &quot;{namePrefix || "Imported"} Proxy 2&quot;, etc. prefix:
namePrefix || t("proxies.importDialog.namePrefixDefault"),
})}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
Proxies to import ({parsedProxies.length}) {t("proxies.importDialog.proxiesToImport", {
count: parsedProxies.length,
})}
{invalidProxies.length > 0 && ( {invalidProxies.length > 0 && (
<span className="text-muted-foreground ml-2"> <span className="text-muted-foreground ml-2">
({invalidProxies.length} invalid) {t("proxies.importDialog.invalidCount", {
count: invalidProxies.length,
})}
</span> </span>
)} )}
</Label> </Label>
@@ -387,8 +405,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{step === "ambiguous" && ( {step === "ambiguous" && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The following proxies have an ambiguous format. Please select the {t("proxies.importDialog.ambiguousIntro")}
correct interpretation for each.
</p> </p>
<ScrollArea className="h-[250px] border rounded-md"> <ScrollArea className="h-[250px] border rounded-md">
<div className="p-3 space-y-4"> <div className="p-3 space-y-4">
@@ -430,14 +447,18 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="p-4 bg-muted/30 rounded-lg space-y-2"> <div className="p-4 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm">Imported:</span> <span className="text-sm">
{t("proxies.importDialog.imported")}
</span>
<span className="text-sm font-medium text-success"> <span className="text-sm font-medium text-success">
{importResult.imported_count} {importResult.imported_count}
</span> </span>
</div> </div>
{importResult.skipped_count > 0 && ( {importResult.skipped_count > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm">Skipped (duplicates):</span> <span className="text-sm">
{t("proxies.importDialog.skippedDuplicates")}
</span>
<span className="text-sm font-medium text-warning"> <span className="text-sm font-medium text-warning">
{importResult.skipped_count} {importResult.skipped_count}
</span> </span>
@@ -445,7 +466,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
)} )}
{importResult.errors.length > 0 && ( {importResult.errors.length > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm">Errors:</span> <span className="text-sm">
{t("proxies.importDialog.errors")}
</span>
<span className="text-sm font-medium text-destructive"> <span className="text-sm font-medium text-destructive">
{importResult.errors.length} {importResult.errors.length}
</span> </span>
@@ -455,7 +478,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.length > 0 && ( {importResult.errors.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Errors</Label> <Label>{t("proxies.importDialog.errors")}</Label>
<ScrollArea className="h-[100px] border rounded-md"> <ScrollArea className="h-[100px] border rounded-md">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{importResult.errors.map((error, i) => ( {importResult.errors.map((error, i) => (
@@ -476,21 +499,23 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<DialogFooter> <DialogFooter>
{step === "dropzone" && ( {step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
)} )}
{step === "preview" && ( {step === "preview" && (
<> <>
<RippleButton variant="outline" onClick={resetState}> <RippleButton variant="outline" onClick={resetState}>
Back {t("common.buttons.back")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isImporting} isLoading={isImporting}
onClick={() => void handleImport()} onClick={() => void handleImport()}
disabled={parsedProxies.length === 0} disabled={parsedProxies.length === 0}
> >
Import {parsedProxies.length} Proxies {t("proxies.importDialog.importButton", {
count: parsedProxies.length,
})}
</LoadingButton> </LoadingButton>
</> </>
)} )}
@@ -498,19 +523,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{step === "ambiguous" && ( {step === "ambiguous" && (
<> <>
<RippleButton variant="outline" onClick={resetState}> <RippleButton variant="outline" onClick={resetState}>
Back {t("common.buttons.back")}
</RippleButton> </RippleButton>
<RippleButton <RippleButton
onClick={handleResolveAmbiguous} onClick={handleResolveAmbiguous}
disabled={ambiguousProxies.some((p) => !p.selectedFormat)} disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
> >
Continue {t("proxies.importDialog.continueButton")}
</RippleButton> </RippleButton>
</> </>
)} )}
{step === "result" && ( {step === "result" && (
<RippleButton onClick={handleClose}>Done</RippleButton> <RippleButton onClick={handleClose}>
{t("proxies.importDialog.doneButton")}
</RippleButton>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+415 -344
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event"; import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go"; import { GoPlus } from "react-icons/go";
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu"; import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -51,37 +52,46 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot( function getSyncStatusDot(
item: { sync_enabled?: boolean; last_sync?: number }, item: { sync_enabled?: boolean; last_sync?: number },
liveStatus: SyncStatus | undefined, liveStatus: SyncStatus | undefined,
t: (key: string, options?: Record<string, unknown>) => string,
errorMessage?: string, errorMessage?: string,
): { color: string; tooltip: string; animate: boolean } { ): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled"); const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
switch (status) { switch (status) {
case "syncing": case "syncing":
return { color: "bg-warning", tooltip: "Syncing...", animate: true }; return {
color: "bg-warning",
tooltip: t("syncTooltips.syncing"),
animate: true,
};
case "synced": case "synced":
return { return {
color: "bg-success", color: "bg-success",
tooltip: item.last_sync tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}` ? t("syncTooltips.syncedAt", {
: "Synced", time: new Date(item.last_sync * 1000).toLocaleString(),
})
: t("syncTooltips.synced"),
animate: false, animate: false,
}; };
case "waiting": case "waiting":
return { return {
color: "bg-warning", color: "bg-warning",
tooltip: "Waiting to sync", tooltip: t("syncTooltips.waiting"),
animate: false, animate: false,
}; };
case "error": case "error":
return { return {
color: "bg-destructive", color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error", tooltip: errorMessage
? t("syncTooltips.errorWith", { error: errorMessage })
: t("syncTooltips.error"),
animate: false, animate: false,
}; };
default: default:
return { return {
color: "bg-muted-foreground", color: "bg-muted-foreground",
tooltip: "Not synced", tooltip: t("syncTooltips.notSynced"),
animate: false, animate: false,
}; };
} }
@@ -96,6 +106,7 @@ export function ProxyManagementDialog({
isOpen, isOpen,
onClose, onClose,
}: ProxyManagementDialogProps) { }: ProxyManagementDialogProps) {
const { t } = useTranslation();
// Proxy state // Proxy state
const [showProxyForm, setShowProxyForm] = useState(false); const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
@@ -260,16 +271,16 @@ export function ProxyManagementDialog({
setIsDeleting(true); setIsDeleting(true);
try { try {
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id }); await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
toast.success("Proxy deleted successfully"); toast.success(t("proxies.management.deleteSuccess"));
await emit("stored-proxies-changed"); await emit("stored-proxies-changed");
} catch (error) { } catch (error) {
console.error("Failed to delete proxy:", error); console.error("Failed to delete proxy:", error);
toast.error("Failed to delete proxy"); toast.error(t("proxies.management.deleteFailed"));
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setProxyToDelete(null); setProxyToDelete(null);
} }
}, [proxyToDelete]); }, [proxyToDelete, t]);
const handleCreateProxy = useCallback(() => { const handleCreateProxy = useCallback(() => {
setEditingProxy(null); setEditingProxy(null);
@@ -286,24 +297,33 @@ export function ProxyManagementDialog({
setEditingProxy(null); setEditingProxy(null);
}, []); }, []);
const handleToggleSync = useCallback(async (proxy: StoredProxy) => { const handleToggleSync = useCallback(
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true })); async (proxy: StoredProxy) => {
try { setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
await invoke("set_proxy_sync_enabled", { try {
proxyId: proxy.id, await invoke("set_proxy_sync_enabled", {
enabled: !proxy.sync_enabled, proxyId: proxy.id,
}); enabled: !proxy.sync_enabled,
showSuccessToast(proxy.sync_enabled ? "Sync disabled" : "Sync enabled"); });
await emit("stored-proxies-changed"); showSuccessToast(
} catch (error) { proxy.sync_enabled
console.error("Failed to toggle sync:", error); ? t("proxies.management.syncDisabled")
showErrorToast( : t("proxies.management.syncEnabled"),
error instanceof Error ? error.message : "Failed to update sync", );
); await emit("stored-proxies-changed");
} finally { } catch (error) {
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false })); console.error("Failed to toggle sync:", error);
} showErrorToast(
}, []); error instanceof Error
? error.message
: t("proxies.management.updateSyncFailed"),
);
} finally {
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
}
},
[t],
);
// VPN handlers // VPN handlers
const handleDeleteVpn = useCallback((vpn: VpnConfig) => { const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
@@ -315,16 +335,16 @@ export function ProxyManagementDialog({
setIsDeletingVpn(true); setIsDeletingVpn(true);
try { try {
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id }); await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
toast.success("VPN deleted successfully"); toast.success(t("vpns.management.deleteSuccess"));
await emit("vpn-configs-changed"); await emit("vpn-configs-changed");
} catch (error) { } catch (error) {
console.error("Failed to delete VPN:", error); console.error("Failed to delete VPN:", error);
toast.error("Failed to delete VPN"); toast.error(t("vpns.management.deleteFailed"));
} finally { } finally {
setIsDeletingVpn(false); setIsDeletingVpn(false);
setVpnToDelete(null); setVpnToDelete(null);
} }
}, [vpnToDelete]); }, [vpnToDelete, t]);
const handleCreateVpn = useCallback(() => { const handleCreateVpn = useCallback(() => {
setEditingVpn(null); setEditingVpn(null);
@@ -341,33 +361,42 @@ export function ProxyManagementDialog({
setEditingVpn(null); setEditingVpn(null);
}, []); }, []);
const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => { const handleToggleVpnSync = useCallback(
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true })); async (vpn: VpnConfig) => {
try { setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
await invoke("set_vpn_sync_enabled", { try {
vpnId: vpn.id, await invoke("set_vpn_sync_enabled", {
enabled: !vpn.sync_enabled, vpnId: vpn.id,
}); enabled: !vpn.sync_enabled,
showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled"); });
await emit("vpn-configs-changed"); showSuccessToast(
} catch (error) { vpn.sync_enabled
console.error("Failed to toggle VPN sync:", error); ? t("proxies.management.syncDisabled")
showErrorToast( : t("proxies.management.syncEnabled"),
error instanceof Error ? error.message : "Failed to update sync", );
); await emit("vpn-configs-changed");
} finally { } catch (error) {
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false })); console.error("Failed to toggle VPN sync:", error);
} showErrorToast(
}, []); error instanceof Error
? error.message
: t("proxies.management.updateSyncFailed"),
);
} finally {
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
}
},
[t],
);
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col"> <DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>Proxies & VPNs</DialogTitle> <DialogTitle>{t("proxies.management.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Manage your proxy and VPN configurations for reuse across profiles {t("proxies.management.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -375,14 +404,14 @@ export function ProxyManagementDialog({
<Tabs defaultValue="proxies"> <Tabs defaultValue="proxies">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1"> <TabsTrigger value="proxies" className="flex-1">
Proxies {t("proxies.management.tabProxies")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="vpns" className="flex-1"> <TabsTrigger value="vpns" className="flex-1">
VPNs {t("proxies.management.tabVpns")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="proxies"> <TabsContent value="proxies" className="mt-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -395,7 +424,7 @@ export function ProxyManagementDialog({
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
<LuUpload className="w-4 h-4" /> <LuUpload className="w-4 h-4" />
Import {t("common.buttons.import")}
</RippleButton> </RippleButton>
<RippleButton <RippleButton
size="sm" size="sm"
@@ -407,7 +436,7 @@ export function ProxyManagementDialog({
disabled={storedProxies.length === 0} disabled={storedProxies.length === 0}
> >
<LuDownload className="w-4 h-4" /> <LuDownload className="w-4 h-4" />
Export {t("common.buttons.export")}
</RippleButton> </RippleButton>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -417,183 +446,202 @@ export function ProxyManagementDialog({
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
<GoPlus className="w-4 h-4" /> <GoPlus className="w-4 h-4" />
Create {t("proxies.management.create")}
</RippleButton> </RippleButton>
</div> </div>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Loading proxies... {t("proxies.management.loading")}
</div> </div>
) : storedProxies.length === 0 ? ( ) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the {t("proxies.management.noneCreated")}
button above.
</div> </div>
) : ( ) : (
<div className="border rounded-md"> <div className="border rounded-md max-h-[240px] overflow-auto">
<ScrollArea className="h-[240px]"> <Table className="min-w-max">
<Table> <TableHeader>
<TableHeader> <TableRow>
<TableRow> <TableHead>{t("common.labels.name")}</TableHead>
<TableHead>Name</TableHead> <TableHead className="whitespace-nowrap w-px">
<TableHead className="w-20">Usage</TableHead> {t("proxies.management.usage")}
<TableHead className="w-24">Sync</TableHead> </TableHead>
<TableHead className="w-24">Actions</TableHead> <TableHead className="whitespace-nowrap w-px">
</TableRow> {t("proxies.management.syncCol")}
</TableHeader> </TableHead>
<TableBody> <TableHead className="whitespace-nowrap w-px">
{storedProxies.map((proxy) => { {t("common.labels.actions")}
const syncDot = getSyncStatusDot( </TableHead>
proxy, </TableRow>
proxySyncStatus[proxy.id], </TableHeader>
proxySyncErrors[proxy.id], <TableBody>
); {storedProxies.map((proxy) => {
return ( const syncDot = getSyncStatusDot(
<TableRow key={proxy.id}> proxy,
<TableCell className="font-medium"> proxySyncStatus[proxy.id],
<div className="flex items-center gap-2"> t,
<Tooltip> proxySyncErrors[proxy.id],
<TooltipTrigger asChild> );
<div return (
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${ <TableRow key={proxy.id}>
syncDot.animate <TableCell className="font-medium">
? "animate-pulse" <div className="flex items-center gap-2">
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{proxy.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div
<Checkbox className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
checked={proxy.sync_enabled} syncDot.animate
onCheckedChange={() => ? "animate-pulse"
void handleToggleSync(proxy) : ""
} }`}
disabled={ />
isTogglingSync[proxy.id] ||
proxyInUse[proxy.id]
}
/>
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{proxyInUse[proxy.id] ? ( <p>{syncDot.tooltip}</p>
<p>
Sync cannot be disabled while this
proxy is used by synced profiles
</p>
) : (
<p>
{proxy.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TableCell> {proxy.name}
<TableCell> </div>
<div className="flex gap-1"> </TableCell>
<ProxyCheckButton <TableCell>
proxy={proxy} <Badge variant="secondary">
profileId={proxy.id} {proxyUsage[proxy.id] ?? 0}
checkingProfileId={checkingProxyId} </Badge>
cachedResult={ </TableCell>
proxyCheckResults[proxy.id] <TableCell>
} <Tooltip>
setCheckingProfileId={ <TooltipTrigger asChild>
setCheckingProxyId <div className="flex items-center">
} <Checkbox
onCheckComplete={(result) => { checked={proxy.sync_enabled}
setProxyCheckResults((prev) => ({ onCheckedChange={() =>
...prev, void handleToggleSync(proxy)
[proxy.id]: result, }
})); disabled={
}} isTogglingSync[proxy.id] ||
onCheckFailed={(result) => { proxyInUse[proxy.id]
setProxyCheckResults((prev) => ({ }
...prev, />
[proxy.id]: result, </div>
})); </TooltipTrigger>
}} <TooltipContent>
/> {proxyInUse[proxy.id] ? (
<Tooltip> <p>
<TooltipTrigger asChild> {t(
"proxies.management.syncCannotDisable",
)}
</p>
) : (
<p>
{proxy.sync_enabled
? t(
"proxies.management.disableSync",
)
: t(
"proxies.management.enableSync",
)}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProfileId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditProxy(proxy);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("proxies.management.editProxy")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
handleEditProxy(proxy); handleDeleteProxy(proxy);
}} }}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
> >
<LuPencil className="w-4 h-4" /> <LuTrash2 className="w-4 h-4" />
</Button> </Button>
</TooltipTrigger> </span>
<TooltipContent> </TooltipTrigger>
<p>Edit proxy</p> <TooltipContent>
</TooltipContent> {(proxyUsage[proxy.id] ?? 0) > 0 ? (
</Tooltip> <p>
<Tooltip> {(proxyUsage[proxy.id] ?? 0) === 1
<TooltipTrigger asChild> ? t(
<span> "proxies.management.cannotDelete_one",
<Button {
variant="ghost" count: proxyUsage[proxy.id],
size="sm" },
onClick={() => { )
handleDeleteProxy(proxy); : t(
}} "proxies.management.cannotDelete_other",
disabled={ {
(proxyUsage[proxy.id] ?? 0) > 0 count: proxyUsage[proxy.id],
} },
> )}
<LuTrash2 className="w-4 h-4" /> </p>
</Button> ) : (
</span> <p>
</TooltipTrigger> {t(
<TooltipContent> "proxies.management.deleteProxy",
{(proxyUsage[proxy.id] ?? 0) > 0 ? ( )}
<p> </p>
Cannot delete: in use by{" "} )}
{proxyUsage[proxy.id]} profile </TooltipContent>
{proxyUsage[proxy.id] > 1 </Tooltip>
? "s" </div>
: ""} </TableCell>
</p> </TableRow>
) : ( );
<p>Delete proxy</p> })}
)} </TableBody>
</TooltipContent> </Table>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div> </div>
)} )}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="vpns"> <TabsContent value="vpns" className="mt-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -606,7 +654,7 @@ export function ProxyManagementDialog({
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
<LuUpload className="w-4 h-4" /> <LuUpload className="w-4 h-4" />
Import {t("common.buttons.import")}
</RippleButton> </RippleButton>
</div> </div>
<RippleButton <RippleButton
@@ -615,161 +663,180 @@ export function ProxyManagementDialog({
className="flex gap-2 items-center" className="flex gap-2 items-center"
> >
<GoPlus className="w-4 h-4" /> <GoPlus className="w-4 h-4" />
Create {t("proxies.management.create")}
</RippleButton> </RippleButton>
</div> </div>
{isLoadingVpns ? ( {isLoadingVpns ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Loading VPNs... {t("vpns.management.loading")}
</div> </div>
) : vpnConfigs.length === 0 ? ( ) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
No VPN configs created yet. Import or create one using the {t("vpns.management.noneCreated")}
buttons above.
</div> </div>
) : ( ) : (
<div className="border rounded-md"> <div className="border rounded-md max-h-[240px] overflow-auto">
<ScrollArea className="h-[240px]"> <Table className="min-w-max">
<Table> <TableHeader>
<TableHeader> <TableRow>
<TableRow> <TableHead>{t("common.labels.name")}</TableHead>
<TableHead>Name</TableHead> <TableHead className="whitespace-nowrap w-px">
<TableHead className="w-16">Type</TableHead> {t("common.labels.type")}
<TableHead className="w-20">Usage</TableHead> </TableHead>
<TableHead className="w-24">Sync</TableHead> <TableHead className="whitespace-nowrap w-px">
<TableHead className="w-24">Actions</TableHead> {t("proxies.management.usage")}
</TableRow> </TableHead>
</TableHeader> <TableHead className="whitespace-nowrap w-px">
<TableBody> {t("proxies.management.syncCol")}
{vpnConfigs.map((vpn) => { </TableHead>
const syncDot = getSyncStatusDot( <TableHead className="whitespace-nowrap w-px">
vpn, {t("common.labels.actions")}
vpnSyncStatus[vpn.id], </TableHead>
vpnSyncErrors[vpn.id], </TableRow>
); </TableHeader>
return ( <TableBody>
<TableRow key={vpn.id}> {vpnConfigs.map((vpn) => {
<TableCell className="font-medium"> const syncDot = getSyncStatusDot(
<div className="flex items-center gap-2"> vpn,
<Tooltip> vpnSyncStatus[vpn.id],
<TooltipTrigger asChild> t,
<div vpnSyncErrors[vpn.id],
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${ );
syncDot.animate return (
? "animate-pulse" <TableRow key={vpn.id}>
: "" <TableCell className="font-medium">
}`} <div className="flex items-center gap-2">
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">WG</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div
<Checkbox className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
checked={vpn.sync_enabled} syncDot.animate
onCheckedChange={() => ? "animate-pulse"
void handleToggleVpnSync(vpn) : ""
} }`}
disabled={ />
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{vpnInUse[vpn.id] ? ( <p>{syncDot.tooltip}</p>
<p>
Sync cannot be disabled while this
VPN is used by synced profiles
</p>
) : (
<p>
{vpn.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TableCell> {vpn.name}
<TableCell> </div>
<div className="flex gap-1"> </TableCell>
<VpnCheckButton <TableCell>
vpnId={vpn.id} <Badge variant="outline">WG</Badge>
vpnName={vpn.name} </TableCell>
checkingVpnId={checkingVpnId} <TableCell>
setCheckingVpnId={setCheckingVpnId} <Badge variant="secondary">
/> {vpnUsage[vpn.id] ?? 0}
<Tooltip> </Badge>
<TooltipTrigger asChild> </TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
void handleToggleVpnSync(vpn)
}
disabled={
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{vpnInUse[vpn.id] ? (
<p>
{t(
"vpns.management.syncCannotDisable",
)}
</p>
) : (
<p>
{vpn.sync_enabled
? t(
"proxies.management.disableSync",
)
: t(
"proxies.management.enableSync",
)}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditVpn(vpn);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("vpns.management.editVpn")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
handleEditVpn(vpn); handleDeleteVpn(vpn);
}} }}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
}
> >
<LuPencil className="w-4 h-4" /> <LuTrash2 className="w-4 h-4" />
</Button> </Button>
</TooltipTrigger> </span>
<TooltipContent> </TooltipTrigger>
<p>Edit VPN</p> <TooltipContent>
</TooltipContent> {(vpnUsage[vpn.id] ?? 0) > 0 ? (
</Tooltip> <p>
<Tooltip> {(vpnUsage[vpn.id] ?? 0) === 1
<TooltipTrigger asChild> ? t(
<span> "vpns.management.cannotDelete_one",
<Button { count: vpnUsage[vpn.id] },
variant="ghost" )
size="sm" : t(
onClick={() => { "vpns.management.cannotDelete_other",
handleDeleteVpn(vpn); { count: vpnUsage[vpn.id] },
}} )}
disabled={ </p>
(vpnUsage[vpn.id] ?? 0) > 0 ) : (
} <p>
> {t("vpns.management.deleteVpn")}
<LuTrash2 className="w-4 h-4" /> </p>
</Button> )}
</span> </TooltipContent>
</TooltipTrigger> </Tooltip>
<TooltipContent> </div>
{(vpnUsage[vpn.id] ?? 0) > 0 ? ( </TableCell>
<p> </TableRow>
Cannot delete: in use by{" "} );
{vpnUsage[vpn.id]} profile })}
{vpnUsage[vpn.id] > 1 ? "s" : ""} </TableBody>
</p> </Table>
) : (
<p>Delete VPN</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div> </div>
)} )}
</div> </div>
@@ -779,7 +846,7 @@ export function ProxyManagementDialog({
<DialogFooter> <DialogFooter>
<RippleButton variant="outline" onClick={onClose}> <RippleButton variant="outline" onClick={onClose}>
Close {t("common.buttons.close")}
</RippleButton> </RippleButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -796,9 +863,11 @@ export function ProxyManagementDialog({
setProxyToDelete(null); setProxyToDelete(null);
}} }}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
title="Delete Proxy" title={t("proxies.management.deleteTitle")}
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`} description={t("proxies.management.deleteDescription", {
confirmButtonText="Delete" name: proxyToDelete?.name ?? "",
})}
confirmButtonText={t("common.buttons.delete")}
isLoading={isDeleting} isLoading={isDeleting}
/> />
<ProxyImportDialog <ProxyImportDialog
@@ -824,9 +893,11 @@ export function ProxyManagementDialog({
setVpnToDelete(null); setVpnToDelete(null);
}} }}
onConfirm={handleConfirmDeleteVpn} onConfirm={handleConfirmDeleteVpn}
title="Delete VPN" title={t("vpns.management.deleteTitle")}
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`} description={t("vpns.management.deleteDescription", {
confirmButtonText="Delete" name: vpnToDelete?.name ?? "",
})}
confirmButtonText={t("common.buttons.delete")}
isLoading={isDeletingVpn} isLoading={isDeletingVpn}
/> />
<VpnImportDialog <VpnImportDialog
+17 -9
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu"; import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -37,11 +38,14 @@ export function ReleaseTypeSelector({
availableReleaseTypes, availableReleaseTypes,
isDownloading, isDownloading,
onDownload, onDownload,
placeholder = "Select release type...", placeholder,
showDownloadButton = true, showDownloadButton = true,
downloadedVersions = [], downloadedVersions = [],
}: ReleaseTypeSelectorProps) { }: ReleaseTypeSelectorProps) {
const { t } = useTranslation();
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const effectivePlaceholder =
placeholder ?? t("releaseTypeSelector.placeholder");
const releaseOptions = [ const releaseOptions = [
...(availableReleaseTypes.stable ...(availableReleaseTypes.stable
@@ -64,9 +68,9 @@ export function ReleaseTypeSelector({
const selectedDisplayText = selectedReleaseType const selectedDisplayText = selectedReleaseType
? selectedReleaseType === "stable" ? selectedReleaseType === "stable"
? "Stable" ? t("releaseTypeSelector.stable")
: "Nightly" : t("releaseTypeSelector.nightly")
: placeholder; : effectivePlaceholder;
const selectedVersion = const selectedVersion =
selectedReleaseType === "stable" selectedReleaseType === "stable"
@@ -95,7 +99,9 @@ export function ReleaseTypeSelector({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0"> <PopoverContent className="p-0">
<Command> <Command>
<CommandEmpty>No release types available.</CommandEmpty> <CommandEmpty>
{t("releaseTypeSelector.noReleaseTypes")}
</CommandEmpty>
<CommandList> <CommandList>
<CommandGroup> <CommandGroup>
{releaseOptions.map((option) => { {releaseOptions.map((option) => {
@@ -130,7 +136,7 @@ export function ReleaseTypeSelector({
<span className="capitalize">{option.type}</span> <span className="capitalize">{option.type}</span>
{option.type === "nightly" && ( {option.type === "nightly" && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Nightly {t("releaseTypeSelector.nightly")}
</Badge> </Badge>
)} )}
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
@@ -138,7 +144,7 @@ export function ReleaseTypeSelector({
</Badge> </Badge>
{isDownloaded && ( {isDownloaded && (
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
Downloaded {t("releaseTypeSelector.downloaded")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -162,7 +168,7 @@ export function ReleaseTypeSelector({
</Badge> </Badge>
{downloadedVersions.includes(releaseOptions[0].version) && ( {downloadedVersions.includes(releaseOptions[0].version) && (
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
Downloaded {t("releaseTypeSelector.downloaded")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -182,7 +188,9 @@ export function ReleaseTypeSelector({
className="w-full" className="w-full"
> >
<LuDownload className="mr-2 w-4 h-4" /> <LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"} {isDownloading
? t("releaseTypeSelector.downloading")
: t("releaseTypeSelector.downloadBrowser")}
</LoadingButton> </LoadingButton>
)} )}
</div> </div>
+163 -130
View File
@@ -63,6 +63,7 @@ interface AppSettings {
api_port: number; api_port: number;
api_token?: string; api_token?: string;
disable_auto_updates?: boolean; disable_auto_updates?: boolean;
keep_decrypted_profiles_in_ram?: boolean;
} }
interface CustomThemeState { interface CustomThemeState {
@@ -165,34 +166,46 @@ export function SettingsDialog({
} }
}, []); }, []);
const getPermissionDisplayName = useCallback((type: PermissionType) => { const getPermissionDisplayName = useCallback(
switch (type) { (type: PermissionType) => {
case "microphone": switch (type) {
return "Microphone"; case "microphone":
case "camera": return t("settings.permissions.microphone");
return "Camera"; case "camera":
} return t("settings.permissions.camera");
}, []); }
},
[t],
);
const getStatusBadge = useCallback((isGranted: boolean) => { const getStatusBadge = useCallback(
if (isGranted) { (isGranted: boolean) => {
return ( if (isGranted) {
<Badge variant="default" className="text-success-foreground bg-success"> return (
Granted <Badge
</Badge> variant="default"
); className="text-success-foreground bg-success"
} >
return <Badge variant="secondary">Not Granted</Badge>; {t("common.status.granted")}
}, []); </Badge>
);
}
return <Badge variant="secondary">{t("common.status.notGranted")}</Badge>;
},
[t],
);
const getPermissionDescription = useCallback((type: PermissionType) => { const getPermissionDescription = useCallback(
switch (type) { (type: PermissionType) => {
case "microphone": switch (type) {
return "Access to microphone for browser applications"; case "microphone":
case "camera": return t("settings.permissions.microphoneDescription");
return "Access to camera for browser applications"; case "camera":
} return t("settings.permissions.cameraDescription");
}, []); }
},
[t],
);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -332,15 +345,15 @@ export function SettingsDialog({
// Don't show immediate success toast - let the version update progress events handle it // Don't show immediate success toast - let the version update progress events handle it
} catch (error) { } catch (error) {
console.error("Failed to clear cache:", error); console.error("Failed to clear cache:", error);
showErrorToast("Failed to clear cache", { showErrorToast(t("settings.advanced.clearCacheFailed"), {
description: description:
error instanceof Error ? error.message : "Unknown error occurred", error instanceof Error ? error.message : t("common.errors.unknown"),
duration: 4000, duration: 4000,
}); });
} finally { } finally {
setIsClearingCache(false); setIsClearingCache(false);
} }
}, []); }, [t]);
const handleRequestPermission = useCallback( const handleRequestPermission = useCallback(
async (permissionType: PermissionType) => { async (permissionType: PermissionType) => {
@@ -348,7 +361,9 @@ export function SettingsDialog({
try { try {
await requestPermission(permissionType); await requestPermission(permissionType);
showSuccessToast( showSuccessToast(
`${getPermissionDisplayName(permissionType)} access requested`, t("settings.permissions.accessRequested", {
permission: getPermissionDisplayName(permissionType),
}),
); );
} catch (error) { } catch (error) {
console.error("Failed to request permission:", error); console.error("Failed to request permission:", error);
@@ -356,7 +371,7 @@ export function SettingsDialog({
setRequestingPermission(null); setRequestingPermission(null);
} }
}, },
[getPermissionDisplayName, requestPermission], [getPermissionDisplayName, requestPermission, t],
); );
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
@@ -394,7 +409,12 @@ export function SettingsDialog({
// Update settings with any generated tokens // Update settings with any generated tokens
setSettings(savedSettings); setSettings(savedSettings);
settingsToSave = savedSettings; settingsToSave = savedSettings;
setTheme(settings.theme === "custom" ? "dark" : settings.theme); // Pass the actual theme value through. Calling setTheme("dark") here
// when the user is on "custom" pushes the provider state to "dark",
// which triggers its clear-custom-vars effect and wipes the CSS
// variables we set just below — that's the bug where saving a custom
// theme made it disappear until the app was restarted.
setTheme(settings.theme);
// Apply or clear custom variables only on Save // Apply or clear custom variables only on Save
if (settings.theme === "custom") { if (settings.theme === "custom") {
@@ -525,7 +545,7 @@ export function SettingsDialog({
checkDefaultBrowserStatus().catch((err: unknown) => { checkDefaultBrowserStatus().catch((err: unknown) => {
console.error(err); console.error(err);
}); });
}, 500); // Check every 500ms }, 2000);
// Cleanup interval on component unmount or dialog close // Cleanup interval on component unmount or dialog close
return () => { return () => {
@@ -592,11 +612,13 @@ export function SettingsDialog({
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0"> <div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
{/* Appearance Section */} {/* Appearance Section */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium">Appearance</Label> <Label className="text-base font-medium">
{t("settings.appearance.title")}
</Label>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="theme-select" className="text-sm"> <Label htmlFor="theme-select" className="text-sm">
Theme {t("settings.appearance.theme")}
</Label> </Label>
<Select <Select
value={settings.theme} value={settings.theme}
@@ -614,20 +636,29 @@ export function SettingsDialog({
}} }}
> >
<SelectTrigger id="theme-select"> <SelectTrigger id="theme-select">
<SelectValue placeholder="Select theme" /> <SelectValue
placeholder={t("settings.appearance.selectTheme")}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="light">Light</SelectItem> <SelectItem value="light">
<SelectItem value="dark">Dark</SelectItem> {t("settings.appearance.light")}
<SelectItem value="system">System</SelectItem> </SelectItem>
<SelectItem value="custom">Custom</SelectItem> <SelectItem value="dark">
{t("settings.appearance.dark")}
</SelectItem>
<SelectItem value="system">
{t("settings.appearance.system")}
</SelectItem>
<SelectItem value="custom">
{t("common.labels.custom")}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Choose your preferred theme or follow your system settings. {t("settings.appearance.themeDescription")}
Custom theme changes are applied only when you save.
</p> </p>
{settings.theme === "custom" && ( {settings.theme === "custom" && (
@@ -637,7 +668,7 @@ export function SettingsDialog({
htmlFor="theme-preset-select" htmlFor="theme-preset-select"
className="text-sm font-medium" className="text-sm font-medium"
> >
Theme Preset {t("settings.appearance.themePreset")}
</Label> </Label>
<Select <Select
value={customThemeState.selectedThemeId ?? "custom"} value={customThemeState.selectedThemeId ?? "custom"}
@@ -659,7 +690,11 @@ export function SettingsDialog({
}} }}
> >
<SelectTrigger id="theme-preset-select"> <SelectTrigger id="theme-preset-select">
<SelectValue placeholder="Select a theme preset" /> <SelectValue
placeholder={t(
"settings.appearance.selectThemePreset",
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{THEMES.map((theme) => ( {THEMES.map((theme) => (
@@ -667,12 +702,16 @@ export function SettingsDialog({
{theme.name} {theme.name}
</SelectItem> </SelectItem>
))} ))}
<SelectItem value="custom">Your Own</SelectItem> <SelectItem value="custom">
{t("settings.appearance.yourOwn")}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="text-sm font-medium">Custom Colors</div> <div className="text-sm font-medium">
{t("settings.appearance.customColors")}
</div>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
{THEME_VARIABLES.map(({ key, label }) => { {THEME_VARIABLES.map(({ key, label }) => {
const colorValue = const colorValue =
@@ -744,11 +783,13 @@ export function SettingsDialog({
{/* Language Section */} {/* Language Section */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium">Language</Label> <Label className="text-base font-medium">
{t("settings.language.title")}
</Label>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="language-select" className="text-sm"> <Label htmlFor="language-select" className="text-sm">
Interface Language {t("settings.language.interface")}
</Label> </Label>
<Select <Select
value={selectedLanguage ?? "system"} value={selectedLanguage ?? "system"}
@@ -758,10 +799,14 @@ export function SettingsDialog({
disabled={isLanguageLoading} disabled={isLanguageLoading}
> >
<SelectTrigger id="language-select"> <SelectTrigger id="language-select">
<SelectValue placeholder="Select language" /> <SelectValue
placeholder={t("settings.language.selectLanguage")}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="system">System Default</SelectItem> <SelectItem value="system">
{t("settings.language.systemDefault")}
</SelectItem>
{supportedLanguages.map((lang) => ( {supportedLanguages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}> <SelectItem key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name}) {lang.nativeName} ({lang.name})
@@ -772,7 +817,7 @@ export function SettingsDialog({
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Choose your preferred language for the application interface. {t("settings.language.description")}
</p> </p>
</div> </div>
@@ -781,10 +826,12 @@ export function SettingsDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
Default Browser {t("settings.defaultBrowser.title")}
</Label> </Label>
<Badge variant={isDefaultBrowser ? "default" : "secondary"}> <Badge variant={isDefaultBrowser ? "default" : "secondary"}>
{isDefaultBrowser ? "Active" : "Inactive"} {isDefaultBrowser
? t("common.status.active")
: t("common.status.inactive")}
</Badge> </Badge>
</div> </div>
@@ -800,13 +847,12 @@ export function SettingsDialog({
className="w-full" className="w-full"
> >
{isDefaultBrowser {isDefaultBrowser
? "Already Default Browser" ? t("settings.defaultBrowser.alreadyDefault")
: "Set as Default Browser"} : t("settings.defaultBrowser.setAsDefault")}
</LoadingButton> </LoadingButton>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
When set as default, Donut Browser will handle web links and {t("settings.defaultBrowser.description")}
allow you to choose which profile to use.
</p> </p>
</div> </div>
)} )}
@@ -815,12 +861,12 @@ export function SettingsDialog({
{isMacOS && ( {isMacOS && (
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
System Permissions {t("settings.permissions.title")}
</Label> </Label>
{isLoadingPermissions ? ( {isLoadingPermissions ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Loading permissions... {t("settings.permissions.loading")}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -878,17 +924,18 @@ export function SettingsDialog({
{/* Integrations Section */} {/* Integrations Section */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium">Integrations</Label> <Label className="text-base font-medium">
{t("settings.integrations.title")}
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Configure Local API and MCP (Model Context Protocol) for {t("settings.integrations.description")}
integrating with external tools and AI assistants.
</p> </p>
<RippleButton <RippleButton
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={onIntegrationsOpen} onClick={onIntegrationsOpen}
> >
Open Integrations Settings {t("integrations.openSettings")}
</RippleButton> </RippleButton>
</div> </div>
@@ -912,33 +959,24 @@ export function SettingsDialog({
{/* Sync Encryption Section */} {/* Sync Encryption Section */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
{t("settings.encryption.title", "Sync Encryption")} {t("settings.encryption.title")}
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t("settings.encryption.description")}
"settings.encryption.description",
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
)}
</p> </p>
{!canUseEncryption ? ( {!canUseEncryption ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t("settings.encryption.requiresProOrOwner")}
"settings.encryption.requiresProOrOwner",
"Profile encryption is available for Pro users and team owners.",
)}
</p> </p>
) : hasE2ePassword ? ( ) : hasE2ePassword ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="default"> <Badge variant="default">
{t("settings.encryption.passwordSet", "Active")} {t("settings.encryption.passwordSet")}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{t( {t("settings.encryption.passwordSetDescription")}
"settings.encryption.passwordSetDescription",
"E2E encryption password is set",
)}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -952,10 +990,7 @@ export function SettingsDialog({
setE2eError(""); setE2eError("");
}} }}
> >
{t( {t("settings.encryption.changePassword")}
"settings.encryption.changePassword",
"Change Password",
)}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@@ -964,21 +999,13 @@ export function SettingsDialog({
try { try {
await invoke("delete_e2e_password"); await invoke("delete_e2e_password");
setHasE2ePassword(false); setHasE2ePassword(false);
showSuccessToast( showSuccessToast(t("settings.encryption.removed"));
t(
"settings.encryption.removed",
"Encryption password removed",
),
);
} catch (error) { } catch (error) {
showErrorToast(String(error)); showErrorToast(String(error));
} }
}} }}
> >
{t( {t("settings.encryption.removePassword")}
"settings.encryption.removePassword",
"Remove Password",
)}
</Button> </Button>
</div> </div>
</div> </div>
@@ -986,10 +1013,7 @@ export function SettingsDialog({
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
type="password" type="password"
placeholder={t( placeholder={t("settings.encryption.passwordPlaceholder")}
"settings.encryption.passwordPlaceholder",
"Password (min 8 characters)",
)}
value={e2ePassword} value={e2ePassword}
onChange={(e) => { onChange={(e) => {
setE2ePassword(e.target.value); setE2ePassword(e.target.value);
@@ -998,10 +1022,7 @@ export function SettingsDialog({
/> />
<Input <Input
type="password" type="password"
placeholder={t( placeholder={t("settings.encryption.confirmPlaceholder")}
"settings.encryption.confirmPlaceholder",
"Confirm password",
)}
value={e2ePasswordConfirm} value={e2ePasswordConfirm}
onChange={(e) => { onChange={(e) => {
setE2ePasswordConfirm(e.target.value); setE2ePasswordConfirm(e.target.value);
@@ -1017,21 +1038,11 @@ export function SettingsDialog({
isLoading={isSavingE2e} isLoading={isSavingE2e}
onClick={async () => { onClick={async () => {
if (e2ePassword.length < 8) { if (e2ePassword.length < 8) {
setE2eError( setE2eError(t("settings.encryption.passwordTooShort"));
t(
"settings.encryption.passwordTooShort",
"Password must be at least 8 characters",
),
);
return; return;
} }
if (e2ePassword !== e2ePasswordConfirm) { if (e2ePassword !== e2ePasswordConfirm) {
setE2eError( setE2eError(t("settings.encryption.passwordMismatch"));
t(
"settings.encryption.passwordMismatch",
"Passwords do not match",
),
);
return; return;
} }
setIsSavingE2e(true); setIsSavingE2e(true);
@@ -1043,10 +1054,7 @@ export function SettingsDialog({
setE2ePassword(""); setE2ePassword("");
setE2ePasswordConfirm(""); setE2ePasswordConfirm("");
showSuccessToast( showSuccessToast(
t( t("settings.encryption.passwordSaved"),
"settings.encryption.passwordSaved",
"Encryption password set",
),
); );
} catch (error) { } catch (error) {
showErrorToast(String(error)); showErrorToast(String(error));
@@ -1055,7 +1063,7 @@ export function SettingsDialog({
} }
}} }}
> >
{t("settings.encryption.setPassword", "Set Password")} {t("settings.encryption.setPassword")}
</LoadingButton> </LoadingButton>
</div> </div>
)} )}
@@ -1064,28 +1072,29 @@ export function SettingsDialog({
{/* Commercial License Section */} {/* Commercial License Section */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
Commercial License {t("settings.commercial.title")}
</Label> </Label>
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40"> <div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
{trialStatus?.type === "Active" ? ( {trialStatus?.type === "Active" ? (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Trial: {trialStatus.days_remaining} days,{" "} {t("settings.commercial.trialActive", {
{trialStatus.hours_remaining} hours remaining days: trialStatus.days_remaining,
hours: trialStatus.hours_remaining,
})}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Commercial use is free during the trial period {t("settings.commercial.trialActiveDescription")}
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-warning"> <p className="text-sm font-medium text-warning">
Trial expired {t("settings.commercial.trialExpired")}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Personal use remains free. Commercial use requires a {t("settings.commercial.trialExpiredDescription")}
license.
</p> </p>
</div> </div>
)} )}
@@ -1094,7 +1103,9 @@ export function SettingsDialog({
{/* Advanced Section */} {/* Advanced Section */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label> <Label className="text-base font-medium">
{t("settings.advanced.title")}
</Label>
{!isLinux && ( {!isLinux && (
<div className="flex items-start space-x-3 p-3 rounded-lg border"> <div className="flex items-start space-x-3 p-3 rounded-lg border">
@@ -1119,6 +1130,30 @@ export function SettingsDialog({
</div> </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 <LoadingButton
isLoading={isClearingCache} isLoading={isClearingCache}
onClick={() => { onClick={() => {
@@ -1129,13 +1164,11 @@ export function SettingsDialog({
variant="outline" variant="outline"
className="w-full" className="w-full"
> >
Clear All Version Cache {t("settings.advanced.clearCache")}
</LoadingButton> </LoadingButton>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Clear all cached browser version data and refresh all browser {t("settings.advanced.clearCacheDescription")}
versions from their sources. This will force a fresh download of
version information for all browsers.
</p> </p>
</div> </div>
@@ -1151,7 +1184,7 @@ export function SettingsDialog({
<DialogFooter className="shrink-0"> <DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isSaving} isLoading={isSaving}
@@ -1162,7 +1195,7 @@ export function SettingsDialog({
}} }}
disabled={isLoading || !hasChanges} disabled={isLoading || !hasChanges}
> >
Save Settings {t("common.buttons.saveSettings")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -74,6 +74,7 @@ function ObjectEditor({
title, title,
readOnly = false, readOnly = false,
}: ObjectEditorProps) { }: ObjectEditorProps) {
const { t } = useTranslation();
const [jsonString, setJsonString] = useState(""); const [jsonString, setJsonString] = useState("");
useEffect(() => { useEffect(() => {
@@ -111,7 +112,7 @@ function ObjectEditor({
onChange={(e) => { onChange={(e) => {
handleChange(e.target.value); handleChange(e.target.value);
}} }}
placeholder={`Enter ${title} as JSON`} placeholder={t("fingerprint.enterAsJson", { title })}
className="font-mono text-sm" className="font-mono text-sm"
rows={6} rows={6}
disabled={readOnly} disabled={readOnly}
@@ -465,7 +466,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined, e.target.value || undefined,
); );
}} }}
placeholder="e.g., Intel Mac OS X 10.15" placeholder={t(
"config.camoufox.fingerprint.osCpuPlaceholder",
)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -904,7 +907,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined, e.target.value || undefined,
); );
}} }}
placeholder="e.g., llvmpipe, or similar" placeholder={t(
"config.camoufox.fingerprint.webglRendererPlaceholder",
)}
/> />
</div> </div>
</div> </div>
@@ -1010,7 +1015,7 @@ export function SharedCamoufoxConfigForm({
selected.map((s: Option) => s.value), selected.map((s: Option) => s.value),
); );
}} }}
placeholder="Add fonts..." placeholder={t("fingerprint.addFontsPlaceholder")}
creatable creatable
/> />
</div> </div>
+34 -70
View File
@@ -32,6 +32,14 @@ const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
interface SyncConfigDialogProps { interface SyncConfigDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: (loginOccurred?: boolean) => void; onClose: (loginOccurred?: boolean) => void;
/**
* Called after the user clicks "Login" so the parent can open the
* device-code verify dialog as a separate step. Implementations should
* close this dialog and open the verify one that keeps the verify
* step visually independent and avoids stacking on top of other
* dialogs (e.g. the profile selector triggered by deep links).
*/
onLoginStarted?: () => void;
} }
interface ProxyUsage { interface ProxyUsage {
@@ -42,7 +50,11 @@ interface ProxyUsage {
extra_limit_mb: number; extra_limit_mb: number;
} }
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { export function SyncConfigDialog({
isOpen,
onClose,
onLoginStarted,
}: SyncConfigDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// Self-hosted state // Self-hosted state
@@ -58,11 +70,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
user, user,
isLoggedIn, isLoggedIn,
isLoading: isCloudLoading, isLoading: isCloudLoading,
exchangeDeviceCode,
logout, logout,
} = useCloudAuth(); } = useCloudAuth();
const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [activeTab, setActiveTab] = useState<string>("cloud"); const [activeTab, setActiveTab] = useState<string>("cloud");
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null); const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
@@ -103,7 +112,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
if (isOpen) { if (isOpen) {
setConnectionStatus("unknown"); setConnectionStatus("unknown");
void loadSettings(); void loadSettings();
setLinkCode("");
void invoke<ProxyUsage | null>("cloud_get_proxy_usage") void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
.then(setLiveProxyUsage) .then(setLiveProxyUsage)
.catch(() => { .catch(() => {
@@ -126,7 +134,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const handleTestConnection = useCallback(async () => { const handleTestConnection = useCallback(async () => {
if (!serverUrl) { if (!serverUrl) {
showErrorToast("Please enter a server URL"); showErrorToast(t("sync.config.serverUrlRequired"));
return; return;
} }
@@ -137,18 +145,18 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const response = await fetch(healthUrl); const response = await fetch(healthUrl);
if (response.ok) { if (response.ok) {
setConnectionStatus("connected"); setConnectionStatus("connected");
showSuccessToast("Connection successful!"); showSuccessToast(t("sync.config.connectionSuccess"));
} else { } else {
setConnectionStatus("error"); setConnectionStatus("error");
showErrorToast("Server responded with an error"); showErrorToast(t("sync.config.serverError"));
} }
} catch { } catch {
setConnectionStatus("error"); setConnectionStatus("error");
showErrorToast("Failed to connect to server"); showErrorToast(t("sync.config.connectFailed"));
} finally { } finally {
setIsTesting(false); setIsTesting(false);
} }
}, [serverUrl]); }, [serverUrl, t]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@@ -162,15 +170,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
} catch (e) { } catch (e) {
console.error("Failed to restart sync service:", e); console.error("Failed to restart sync service:", e);
} }
showSuccessToast("Sync settings saved"); showSuccessToast(t("sync.config.settingsSaved"));
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to save sync settings:", error); console.error("Failed to save sync settings:", error);
showErrorToast("Failed to save settings"); showErrorToast(t("sync.config.saveFailed"));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [serverUrl, token, onClose]); }, [serverUrl, token, onClose, t]);
const handleDisconnect = useCallback(async () => { const handleDisconnect = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@@ -187,44 +195,27 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
setServerUrl(""); setServerUrl("");
setToken(""); setToken("");
setConnectionStatus("unknown"); setConnectionStatus("unknown");
showSuccessToast("Sync disconnected"); showSuccessToast(t("sync.config.disconnected"));
} catch (error) { } catch (error) {
console.error("Failed to disconnect:", error); console.error("Failed to disconnect:", error);
showErrorToast("Failed to disconnect"); showErrorToast(t("sync.config.disconnectFailed"));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, []); }, [t]);
const handleOpenLogin = useCallback(async () => { const handleOpenLogin = useCallback(async () => {
try { try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL }); await invoke("handle_url_open", { url: DEVICE_LINK_URL });
// Hand off the verify step to its own dialog so the user has a
// focused place to paste the code, and so it doesn't visually
// stack with this dialog or any other modal currently on screen.
onLoginStarted?.();
} catch (error) { } catch (error) {
console.error("Failed to open login link:", error); console.error("Failed to open login link:", error);
showErrorToast(String(error)); showErrorToast(String(error));
} }
}, []); }, [onLoginStarted]);
const handleVerifyCode = useCallback(async () => {
const trimmed = linkCode.trim();
if (!trimmed) return;
setIsVerifying(true);
try {
await exchangeDeviceCode(trimmed);
showSuccessToast(t("sync.cloud.loginSuccess"));
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
onClose(true);
} catch (error) {
console.error("Device-code exchange failed:", error);
showErrorToast(String(error));
} finally {
setIsVerifying(false);
}
}, [linkCode, exchangeDeviceCode, t, onClose]);
const handleCloudLogout = useCallback(async () => { const handleCloudLogout = useCallback(async () => {
try { try {
@@ -375,37 +366,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
> >
{t("sync.cloud.openLogin")} {t("sync.cloud.openLogin")}
</Button> </Button>
<div className="space-y-2">
<Label htmlFor="cloud-link-code">
{t("sync.cloud.linkCodeLabel")}
</Label>
<Input
id="cloud-link-code"
placeholder={t("sync.cloud.linkCodePlaceholder")}
value={linkCode}
onChange={(e) => {
setLinkCode(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && linkCode.trim()) {
void handleVerifyCode();
}
}}
autoComplete="off"
spellCheck={false}
/>
<LoadingButton
onClick={() => void handleVerifyCode()}
isLoading={isVerifying}
disabled={!linkCode.trim()}
className="w-full"
>
{isVerifying
? t("sync.cloud.loggingIn")
: t("sync.cloud.verifyAndLogin")}
</LoadingButton>
</div>
</div> </div>
)} )}
</TabsContent> </TabsContent>
@@ -452,7 +412,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
setShowToken(!showToken); setShowToken(!showToken);
}} }}
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent" className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={showToken ? "Hide token" : "Show token"} aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
> >
{showToken ? ( {showToken ? (
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" /> <LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
+84 -41
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { import {
Area, Area,
AreaChart, AreaChart,
@@ -152,6 +153,7 @@ export function TrafficDetailsDialog({
profileId, profileId,
profileName, profileName,
}: TrafficDetailsDialogProps) { }: TrafficDetailsDialogProps) {
const { t } = useTranslation();
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null); const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m"); const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
@@ -211,7 +213,9 @@ export function TrafficDetailsDialog({
{payload.map((entry) => ( {payload.map((entry) => (
<p key={String(entry.dataKey)} className="text-sm"> <p key={String(entry.dataKey)} className="text-sm">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{entry.dataKey === "sent" ? "↑ Sent: " : "↓ Received: "} {entry.dataKey === "sent"
? t("traffic.tooltipSent")
: t("traffic.tooltipReceived")}
</span> </span>
<span className="font-medium"> <span className="font-medium">
{formatBytesPerSecond( {formatBytesPerSecond(
@@ -223,7 +227,7 @@ export function TrafficDetailsDialog({
</div> </div>
); );
}, },
[], [t],
); );
// Top domains sorted by total traffic // Top domains sorted by total traffic
@@ -255,7 +259,7 @@ export function TrafficDetailsDialog({
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Traffic Details {t("traffic.title")}
{profileName && ( {profileName && (
<span className="text-muted-foreground font-normal ml-2"> <span className="text-muted-foreground font-normal ml-2">
{profileName} {profileName}
@@ -269,7 +273,9 @@ export function TrafficDetailsDialog({
{/* Chart with Period Selector */} {/* Chart with Period Selector */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">Bandwidth Over Time</h3> <h3 className="text-sm font-medium">
{t("traffic.bandwidthOverTime")}
</h3>
<Select <Select
value={timePeriod} value={timePeriod}
onValueChange={(v) => { onValueChange={(v) => {
@@ -277,19 +283,21 @@ export function TrafficDetailsDialog({
}} }}
> >
<SelectTrigger className="w-[120px] h-8"> <SelectTrigger className="w-[120px] h-8">
<SelectValue placeholder="Time period" /> <SelectValue
placeholder={t("traffic.timePeriodPlaceholder")}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1m">Last 1 min</SelectItem> <SelectItem value="1m">{t("traffic.last1m")}</SelectItem>
<SelectItem value="5m">Last 5 min</SelectItem> <SelectItem value="5m">{t("traffic.last5m")}</SelectItem>
<SelectItem value="30m">Last 30 min</SelectItem> <SelectItem value="30m">{t("traffic.last30m")}</SelectItem>
<SelectItem value="1h">Last 1 hour</SelectItem> <SelectItem value="1h">{t("traffic.last1h")}</SelectItem>
<SelectItem value="2h">Last 2 hours</SelectItem> <SelectItem value="2h">{t("traffic.last2h")}</SelectItem>
<SelectItem value="4h">Last 4 hours</SelectItem> <SelectItem value="4h">{t("traffic.last4h")}</SelectItem>
<SelectItem value="1d">Last 1 day</SelectItem> <SelectItem value="1d">{t("traffic.last1d")}</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem> <SelectItem value="7d">{t("traffic.last7d")}</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem> <SelectItem value="30d">{t("traffic.last30d")}</SelectItem>
<SelectItem value="all">All time</SelectItem> <SelectItem value="all">{t("traffic.allTime")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -393,7 +401,9 @@ export function TrafficDetailsDialog({
className="w-3 h-3 rounded" className="w-3 h-3 rounded"
style={{ backgroundColor: "var(--chart-1)" }} style={{ backgroundColor: "var(--chart-1)" }}
/> />
<span className="text-xs text-muted-foreground">Sent</span> <span className="text-xs text-muted-foreground">
{t("traffic.sentLegend")}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
@@ -401,7 +411,7 @@ export function TrafficDetailsDialog({
style={{ backgroundColor: "var(--chart-2)" }} style={{ backgroundColor: "var(--chart-2)" }}
/> />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Received {t("traffic.receivedLegend")}
</span> </span>
</div> </div>
</div> </div>
@@ -411,7 +421,12 @@ export function TrafficDetailsDialog({
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="bg-muted/50 rounded-lg p-3"> <div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sent ({timePeriod === "all" ? "total" : timePeriod}) {t("traffic.sentLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p> </p>
<p className="text-lg font-semibold text-chart-1"> <p className="text-lg font-semibold text-chart-1">
{formatBytes(stats?.period_bytes_sent ?? 0)} {formatBytes(stats?.period_bytes_sent ?? 0)}
@@ -419,7 +434,12 @@ export function TrafficDetailsDialog({
</div> </div>
<div className="bg-muted/50 rounded-lg p-3"> <div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Received ({timePeriod === "all" ? "total" : timePeriod}) {t("traffic.receivedLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p> </p>
<p className="text-lg font-semibold text-chart-2"> <p className="text-lg font-semibold text-chart-2">
{formatBytes(stats?.period_bytes_received ?? 0)} {formatBytes(stats?.period_bytes_received ?? 0)}
@@ -427,7 +447,12 @@ export function TrafficDetailsDialog({
</div> </div>
<div className="bg-muted/50 rounded-lg p-3"> <div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Requests ({timePeriod === "all" ? "total" : timePeriod}) {t("traffic.requestsLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p> </p>
<p className="text-lg font-semibold"> <p className="text-lg font-semibold">
{(stats?.period_requests ?? 0).toLocaleString()} {(stats?.period_requests ?? 0).toLocaleString()}
@@ -438,38 +463,50 @@ export function TrafficDetailsDialog({
{/* Total Stats (smaller, under period stats) */} {/* Total Stats (smaller, under period stats) */}
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4"> <div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
<div> <div>
<span className="font-medium">All-time traffic:</span>{" "} <span className="font-medium">
{t("traffic.allTimeTraffic")}
</span>{" "}
{formatBytes( {formatBytes(
(stats?.total_bytes_sent ?? 0) + (stats?.total_bytes_sent ?? 0) +
(stats?.total_bytes_received ?? 0), (stats?.total_bytes_received ?? 0),
)} )}
</div> </div>
<div> <div>
<span className="font-medium">All-time requests:</span>{" "} <span className="font-medium">
{t("traffic.allTimeRequests")}
</span>{" "}
{stats?.total_requests?.toLocaleString() ?? 0} {stats?.total_requests?.toLocaleString() ?? 0}
</div> </div>
</div> </div>
{/* Disclaimer about proxy/VPN traffic calculation */} {/* Disclaimer about proxy/VPN traffic calculation */}
<p className="text-xs text-muted-foreground italic"> <p className="text-xs text-muted-foreground italic">
Note: If you are using a proxy, VPN, or similar service, your {t("traffic.proxyDisclaimer")}
provider may calculate traffic differently due to encryption
overhead and protocol differences.
</p> </p>
{/* Top Domains by Traffic */} {/* Top Domains by Traffic */}
{topDomainsByTraffic.length > 0 && ( {topDomainsByTraffic.length > 0 && (
<div> <div>
<h3 className="text-sm font-medium mb-2"> <h3 className="text-sm font-medium mb-2">
Top Domains by Traffic ( {t("traffic.topByTraffic", {
{timePeriod === "all" ? "all time" : timePeriod}) period:
timePeriod === "all"
? t("traffic.allTimeShort")
: timePeriod,
})}
</h3> </h3>
<div className="border rounded-md"> <div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30"> <div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<span>Domain</span> <span>{t("traffic.columnDomain")}</span>
<span className="text-right">Requests</span> <span className="text-right">
<span className="text-right">Sent</span> {t("traffic.columnRequests")}
<span className="text-right">Received</span> </span>
<span className="text-right">
{t("traffic.columnSent")}
</span>
<span className="text-right">
{t("traffic.columnReceived")}
</span>
</div> </div>
<div className="max-h-[180px] overflow-y-auto"> <div className="max-h-[180px] overflow-y-auto">
{topDomainsByTraffic.map((domain, index) => ( {topDomainsByTraffic.map((domain, index) => (
@@ -503,14 +540,22 @@ export function TrafficDetailsDialog({
{topDomainsByRequests.length > 0 && ( {topDomainsByRequests.length > 0 && (
<div> <div>
<h3 className="text-sm font-medium mb-2"> <h3 className="text-sm font-medium mb-2">
Top Domains by Requests ( {t("traffic.topByRequests", {
{timePeriod === "all" ? "all time" : timePeriod}) period:
timePeriod === "all"
? t("traffic.allTimeShort")
: timePeriod,
})}
</h3> </h3>
<div className="border rounded-md"> <div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30"> <div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<span>Domain</span> <span>{t("traffic.columnDomain")}</span>
<span className="text-right">Requests</span> <span className="text-right">
<span className="text-right">Total Traffic</span> {t("traffic.columnRequests")}
</span>
<span className="text-right">
{t("traffic.columnTotal")}
</span>
</div> </div>
<div className="max-h-[180px] overflow-y-auto"> <div className="max-h-[180px] overflow-y-auto">
{topDomainsByRequests.map((domain, index) => ( {topDomainsByRequests.map((domain, index) => (
@@ -543,7 +588,7 @@ export function TrafficDetailsDialog({
{stats?.unique_ips && stats.unique_ips.length > 0 && ( {stats?.unique_ips && stats.unique_ips.length > 0 && (
<div> <div>
<h3 className="text-sm font-medium mb-2"> <h3 className="text-sm font-medium mb-2">
Unique IPs ({stats.unique_ips.length}) {t("traffic.uniqueIps", { count: stats.unique_ips.length })}
</h3> </h3>
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto"> <div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -563,10 +608,8 @@ export function TrafficDetailsDialog({
{/* No data state */} {/* No data state */}
{!stats && ( {!stats && (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<p>No traffic data available for this profile.</p> <p>{t("traffic.noData")}</p>
<p className="text-sm mt-1"> <p className="text-sm mt-1">{t("traffic.noDataHint")}</p>
Traffic data will appear after you launch the profile.
</p>
</div> </div>
)} )}
</div> </div>
+3 -1
View File
@@ -14,6 +14,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { LuPipette } from "react-icons/lu"; import { LuPipette } from "react-icons/lu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -366,12 +367,13 @@ export const ColorPickerOutput = ({
className: _className, className: _className,
...props ...props
}: ColorPickerOutputProps) => { }: ColorPickerOutputProps) => {
const { t } = useTranslation();
const { mode, setMode } = useColorPicker(); const { mode, setMode } = useColorPicker();
return ( return (
<Select onValueChange={setMode} value={mode}> <Select onValueChange={setMode} value={mode}>
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}> <SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
<SelectValue placeholder="Mode" /> <SelectValue placeholder={t("common.labels.mode")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{formats.map((format) => ( {formats.map((format) => (
+11 -79
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu"; import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -39,13 +40,18 @@ export function Combobox({
options, options,
value, value,
onValueChange, onValueChange,
placeholder = "Select option...", placeholder,
searchPlaceholder = "Search...", searchPlaceholder,
className, className,
disabled, disabled,
}: ComboboxProps) { }: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
const resolvedSearchPlaceholder =
searchPlaceholder ?? t("common.buttons.search");
return ( return (
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}> <Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -58,15 +64,15 @@ export function Combobox({
> >
{value {value
? options.find((option) => option.value === value)?.label ? options.find((option) => option.value === value)?.label
: placeholder} : resolvedPlaceholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0"> <PopoverContent className="w-full p-0">
<Command> <Command>
<CommandInput placeholder={searchPlaceholder} /> <CommandInput placeholder={resolvedSearchPlaceholder} />
<CommandList> <CommandList>
<CommandEmpty>No option found.</CommandEmpty> <CommandEmpty>{t("common.noResults")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem
@@ -100,77 +106,3 @@ export function Combobox({
</Popover> </Popover>
); );
} }
const frameworks = [
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
];
export function ComboboxDemo() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value
? frameworks.find((framework) => framework.value === value)?.label
: "Select framework..."}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search framework..." />
<CommandList>
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
{frameworks.map((framework) => (
<CommandItem
key={framework.value}
value={framework.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
value === framework.value ? "opacity-100" : "opacity-0",
)}
/>
{framework.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+9 -4
View File
@@ -2,6 +2,7 @@
import { Command as CommandPrimitive } from "cmdk"; import { Command as CommandPrimitive } from "cmdk";
import type * as React from "react"; import type * as React from "react";
import { useTranslation } from "react-i18next";
import { LuSearch } from "react-icons/lu"; import { LuSearch } from "react-icons/lu";
import { import {
@@ -30,19 +31,23 @@ function Command({
} }
function CommandDialog({ function CommandDialog({
title = "Command Palette", title,
description = "Search for a command to run...", description,
children, children,
...props ...props
}: React.ComponentProps<typeof Dialog> & { }: React.ComponentProps<typeof Dialog> & {
title?: string; title?: string;
description?: string; description?: string;
}) { }) {
const { t } = useTranslation();
const resolvedTitle = title ?? t("common.commandPalette.title");
const resolvedDescription =
description ?? t("common.commandPalette.description");
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle> <DialogTitle>{resolvedTitle}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{resolvedDescription}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogContent className="overflow-hidden p-0"> <DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+6 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuCopy } from "react-icons/lu"; import { LuCheck, LuCopy } from "react-icons/lu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { showSuccessToast } from "@/lib/toast-utils"; import { showSuccessToast } from "@/lib/toast-utils";
@@ -26,6 +27,7 @@ export function CopyToClipboard({
className, className,
successMessage = "Copied to clipboard", successMessage = "Copied to clipboard",
}: CopyToClipboardProps) { }: CopyToClipboardProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyToClipboard = useCallback(async () => { const copyToClipboard = useCallback(async () => {
@@ -47,9 +49,11 @@ export function CopyToClipboard({
size={size} size={size}
className={`relative ${className ?? ""}`} className={`relative ${className ?? ""}`}
onClick={copyToClipboard} onClick={copyToClipboard}
aria-label={copied ? "Copied" : "Copy to clipboard"} aria-label={copied ? t("common.aria.copied") : t("common.aria.copy")}
> >
<span className="sr-only">{copied ? "Copied" : "Copy"}</span> <span className="sr-only">
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
</span>
<LuCopy <LuCopy
className={`h-4 w-4 transition-all duration-300 ${ className={`h-4 w-4 transition-all duration-300 ${
copied ? "scale-0" : "scale-100" copied ? "scale-0" : "scale-100"
+4 -2
View File
@@ -3,6 +3,7 @@
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react"; import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
import { Dialog as DialogPrimitive } from "radix-ui"; import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { useTranslation } from "react-i18next";
import { RxCross2 } from "react-icons/rx"; import { RxCross2 } from "react-icons/rx";
import { useControlledState } from "@/hooks/use-controlled-state"; import { useControlledState } from "@/hooks/use-controlled-state";
@@ -115,6 +116,7 @@ function DialogContent({
transition = { type: "spring", stiffness: 150, damping: 25 }, transition = { type: "spring", stiffness: 150, damping: 25 },
...props ...props
}: DialogContentProps) { }: DialogContentProps) {
const { t } = useTranslation();
const initialRotation = const initialRotation =
from === "bottom" || from === "left" ? "20deg" : "-20deg"; from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom"; const isVertical = from === "top" || from === "bottom";
@@ -158,7 +160,7 @@ function DialogContent({
}} }}
transition={transition} transition={transition}
className={cn( className={cn(
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg", "bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
className, className,
)} )}
{...props} {...props}
@@ -166,7 +168,7 @@ function DialogContent({
{children} {children}
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> <DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 /> <RxCross2 />
<span className="sr-only">Close</span> <span className="sr-only">{t("common.buttons.close")}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</motion.div> </motion.div>
</DialogPrimitive.Content> </DialogPrimitive.Content>
+16 -10
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { FiCheck } from "react-icons/fi"; import { FiCheck } from "react-icons/fi";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -28,6 +29,7 @@ export function VpnCheckButton({
setCheckingVpnId, setCheckingVpnId,
disabled = false, disabled = false,
}: VpnCheckButtonProps) { }: VpnCheckButtonProps) {
const { t } = useTranslation();
const [result, setResult] = React.useState<ProxyCheckResult | undefined>(); const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
const handleCheck = React.useCallback(async () => { const handleCheck = React.useCallback(async () => {
@@ -41,14 +43,14 @@ export function VpnCheckButton({
setResult(checkResult); setResult(checkResult);
if (checkResult.is_valid) { if (checkResult.is_valid) {
toast.success(`VPN "${vpnName}" configuration is valid`); toast.success(t("vpnCheck.valid", { name: vpnName }));
} else { } else {
toast.error(`VPN "${vpnName}" configuration is invalid`); toast.error(t("vpnCheck.invalid", { name: vpnName }));
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
toast.error(`VPN check failed: ${errorMessage}`); toast.error(t("vpnCheck.failed", { error: errorMessage }));
setResult({ setResult({
ip: "", ip: "",
@@ -58,7 +60,7 @@ export function VpnCheckButton({
} finally { } finally {
setCheckingVpnId(null); setCheckingVpnId(null);
} }
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]); }, [vpnId, vpnName, checkingVpnId, setCheckingVpnId, t]);
const isCurrentlyChecking = checkingVpnId === vpnId; const isCurrentlyChecking = checkingVpnId === vpnId;
@@ -85,23 +87,27 @@ export function VpnCheckButton({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{isCurrentlyChecking ? ( {isCurrentlyChecking ? (
<p>Checking VPN config...</p> <p>{t("vpnCheck.tooltipChecking")}</p>
) : result?.is_valid ? ( ) : result?.is_valid ? (
<div className="space-y-1"> <div className="space-y-1">
<p>Configuration valid</p> <p>{t("vpnCheck.tooltipValid")}</p>
<p className="text-xs text-primary-foreground/70"> <p className="text-xs text-primary-foreground/70">
Checked {formatRelativeTime(result.timestamp)} {t("vpnCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
</p> </p>
</div> </div>
) : result && !result.is_valid ? ( ) : result && !result.is_valid ? (
<div> <div>
<p>Configuration invalid</p> <p>{t("vpnCheck.tooltipInvalid")}</p>
<p className="text-xs text-primary-foreground/70"> <p className="text-xs text-primary-foreground/70">
Checked {formatRelativeTime(result.timestamp)} {t("vpnCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
</p> </p>
</div> </div>
) : ( ) : (
<p>Check VPN config validity</p> <p>{t("vpnCheck.tooltipDefault")}</p>
)} )}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
+50 -36
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { import {
@@ -74,6 +75,7 @@ export function VpnFormDialog({
onClose, onClose,
editingVpn, editingVpn,
}: VpnFormDialogProps) { }: VpnFormDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [wireGuardForm, setWireGuardForm] = const [wireGuardForm, setWireGuardForm] =
useState<WireGuardFormData>(defaultWireGuardForm); useState<WireGuardFormData>(defaultWireGuardForm);
@@ -103,7 +105,7 @@ export function VpnFormDialog({
const name = wireGuardForm.name.trim(); const name = wireGuardForm.name.trim();
if (!name) { if (!name) {
toast.error("VPN name is required"); toast.error(t("vpns.form.nameRequired"));
return; return;
} }
@@ -114,12 +116,12 @@ export function VpnFormDialog({
name, name,
}); });
await emit("vpn-configs-changed"); await emit("vpn-configs-changed");
toast.success("VPN updated successfully"); toast.success(t("vpns.form.updated"));
onClose(); onClose();
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
toast.error(`Failed to update VPN: ${errorMessage}`); toast.error(t("vpns.form.updateFailed", { error: errorMessage }));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -130,23 +132,23 @@ export function VpnFormDialog({
wireGuardForm; wireGuardForm;
if (!name.trim()) { if (!name.trim()) {
toast.error("VPN name is required"); toast.error(t("vpns.form.nameRequired"));
return; return;
} }
if (!privateKey.trim()) { if (!privateKey.trim()) {
toast.error("Private key is required"); toast.error(t("vpns.form.privateKeyRequired"));
return; return;
} }
if (!address.trim()) { if (!address.trim()) {
toast.error("Address is required"); toast.error(t("vpns.form.addressRequired"));
return; return;
} }
if (!peerPublicKey.trim()) { if (!peerPublicKey.trim()) {
toast.error("Peer public key is required"); toast.error(t("vpns.form.peerPublicKeyRequired"));
return; return;
} }
if (!peerEndpoint.trim()) { if (!peerEndpoint.trim()) {
toast.error("Peer endpoint is required"); toast.error(t("vpns.form.peerEndpointRequired"));
return; return;
} }
@@ -159,16 +161,16 @@ export function VpnFormDialog({
configData, configData,
}); });
await emit("vpn-configs-changed"); await emit("vpn-configs-changed");
toast.success("WireGuard VPN created successfully"); toast.success(t("vpns.form.created"));
onClose(); onClose();
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
toast.error(`Failed to create VPN: ${errorMessage}`); toast.error(t("vpns.form.createFailed", { error: errorMessage }));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [editingVpn, wireGuardForm, onClose]); }, [editingVpn, wireGuardForm, onClose, t]);
const updateWireGuard = useCallback( const updateWireGuard = useCallback(
(field: keyof WireGuardFormData, value: string) => { (field: keyof WireGuardFormData, value: string) => {
@@ -177,10 +179,12 @@ export function VpnFormDialog({
[], [],
); );
const dialogTitle = editingVpn ? "Edit VPN" : "Create WireGuard VPN"; const dialogTitle = editingVpn
? t("vpns.form.titleEdit")
: t("vpns.form.titleCreate");
const dialogDescription = editingVpn const dialogDescription = editingVpn
? "Update the name of your VPN configuration." ? t("vpns.form.descEdit")
: "Enter your WireGuard interface and peer details."; : t("vpns.form.descCreate");
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
@@ -193,14 +197,14 @@ export function VpnFormDialog({
<ScrollArea className="max-h-[60vh] pr-4"> <ScrollArea className="max-h-[60vh] pr-4">
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-name">Name</Label> <Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
<Input <Input
id="wg-name" id="wg-name"
value={wireGuardForm.name} value={wireGuardForm.name}
onChange={(e) => { onChange={(e) => {
updateWireGuard("name", e.target.value); updateWireGuard("name", e.target.value);
}} }}
placeholder="e.g. Home WireGuard" placeholder={t("vpns.form.namePlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
@@ -208,47 +212,49 @@ export function VpnFormDialog({
{!editingVpn && ( {!editingVpn && (
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-private-key">Private Key</Label> <Label htmlFor="wg-private-key">
{t("vpns.form.privateKey")}
</Label>
<Input <Input
id="wg-private-key" id="wg-private-key"
value={wireGuardForm.privateKey} value={wireGuardForm.privateKey}
onChange={(e) => { onChange={(e) => {
updateWireGuard("privateKey", e.target.value); updateWireGuard("privateKey", e.target.value);
}} }}
placeholder="Base64-encoded private key" placeholder={t("vpns.form.privateKeyPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-address">Address</Label> <Label htmlFor="wg-address">{t("vpns.form.address")}</Label>
<Input <Input
id="wg-address" id="wg-address"
value={wireGuardForm.address} value={wireGuardForm.address}
onChange={(e) => { onChange={(e) => {
updateWireGuard("address", e.target.value); updateWireGuard("address", e.target.value);
}} }}
placeholder="e.g. 10.0.0.2/24" placeholder={t("vpns.form.addressPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-dns">DNS (optional)</Label> <Label htmlFor="wg-dns">{t("vpns.form.dnsOptional")}</Label>
<Input <Input
id="wg-dns" id="wg-dns"
value={wireGuardForm.dns} value={wireGuardForm.dns}
onChange={(e) => { onChange={(e) => {
updateWireGuard("dns", e.target.value); updateWireGuard("dns", e.target.value);
}} }}
placeholder="e.g. 1.1.1.1" placeholder={t("vpns.form.dnsPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-mtu">MTU (optional)</Label> <Label htmlFor="wg-mtu">{t("vpns.form.mtuOptional")}</Label>
<Input <Input
id="wg-mtu" id="wg-mtu"
type="number" type="number"
@@ -256,47 +262,53 @@ export function VpnFormDialog({
onChange={(e) => { onChange={(e) => {
updateWireGuard("mtu", e.target.value); updateWireGuard("mtu", e.target.value);
}} }}
placeholder="e.g. 1420" placeholder={t("vpns.form.mtuPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-peer-public-key">Peer Public Key</Label> <Label htmlFor="wg-peer-public-key">
{t("vpns.form.peerPublicKey")}
</Label>
<Input <Input
id="wg-peer-public-key" id="wg-peer-public-key"
value={wireGuardForm.peerPublicKey} value={wireGuardForm.peerPublicKey}
onChange={(e) => { onChange={(e) => {
updateWireGuard("peerPublicKey", e.target.value); updateWireGuard("peerPublicKey", e.target.value);
}} }}
placeholder="Base64-encoded peer public key" placeholder={t("vpns.form.peerPublicKeyPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label> <Label htmlFor="wg-peer-endpoint">
{t("vpns.form.peerEndpoint")}
</Label>
<Input <Input
id="wg-peer-endpoint" id="wg-peer-endpoint"
value={wireGuardForm.peerEndpoint} value={wireGuardForm.peerEndpoint}
onChange={(e) => { onChange={(e) => {
updateWireGuard("peerEndpoint", e.target.value); updateWireGuard("peerEndpoint", e.target.value);
}} }}
placeholder="e.g. vpn.example.com:51820" placeholder={t("vpns.form.peerEndpointPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label> <Label htmlFor="wg-allowed-ips">
{t("vpns.form.allowedIps")}
</Label>
<Input <Input
id="wg-allowed-ips" id="wg-allowed-ips"
value={wireGuardForm.allowedIps} value={wireGuardForm.allowedIps}
onChange={(e) => { onChange={(e) => {
updateWireGuard("allowedIps", e.target.value); updateWireGuard("allowedIps", e.target.value);
}} }}
placeholder="e.g. 0.0.0.0/0, ::/0" placeholder={t("vpns.form.allowedIpsPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
@@ -304,7 +316,7 @@ export function VpnFormDialog({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-keepalive"> <Label htmlFor="wg-keepalive">
Persistent Keepalive (optional) {t("vpns.form.keepaliveOptional")}
</Label> </Label>
<Input <Input
id="wg-keepalive" id="wg-keepalive"
@@ -313,14 +325,14 @@ export function VpnFormDialog({
onChange={(e) => { onChange={(e) => {
updateWireGuard("persistentKeepalive", e.target.value); updateWireGuard("persistentKeepalive", e.target.value);
}} }}
placeholder="e.g. 25" placeholder={t("vpns.form.keepalivePlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="wg-preshared-key"> <Label htmlFor="wg-preshared-key">
Preshared Key (optional) {t("vpns.form.presharedKeyOptional")}
</Label> </Label>
<Input <Input
id="wg-preshared-key" id="wg-preshared-key"
@@ -328,7 +340,7 @@ export function VpnFormDialog({
onChange={(e) => { onChange={(e) => {
updateWireGuard("presharedKey", e.target.value); updateWireGuard("presharedKey", e.target.value);
}} }}
placeholder="Base64-encoded preshared key" placeholder={t("vpns.form.presharedKeyPlaceholder")}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
@@ -344,10 +356,12 @@ export function VpnFormDialog({
onClick={handleClose} onClick={handleClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
<LoadingButton isLoading={isSubmitting} onClick={handleSubmit}> <LoadingButton isLoading={isSubmitting} onClick={handleSubmit}>
{editingVpn ? "Update VPN" : "Create VPN"} {editingVpn
? t("vpns.form.updateButton")
: t("vpns.form.createButton")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+53 -43
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuShield, LuUpload } from "react-icons/lu"; import { LuShield, LuUpload } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
@@ -56,6 +57,7 @@ const detectVpnType = (
}; };
export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) { export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<ImportStep>("dropzone"); const [step, setStep] = useState<ImportStep>("dropzone");
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null); const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
@@ -81,25 +83,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
onClose(); onClose();
}, [resetState, onClose]); }, [resetState, onClose]);
const processContent = useCallback((content: string, filename: string) => { const processContent = useCallback(
const detection = detectVpnType(content, filename); (content: string, filename: string) => {
if (!detection.isVpn) { const detection = detectVpnType(content, filename);
toast.error("Content does not appear to be a valid VPN configuration"); if (!detection.isVpn) {
return; toast.error(t("vpns.import.invalidContent"));
} return;
setVpnPreview({ }
content, setVpnPreview({
filename, content,
detectedType: detection.type, filename,
endpoint: detection.endpoint, detectedType: detection.type,
}); endpoint: detection.endpoint,
const baseName = filename });
.replace(/\.conf$/i, "") const baseName = filename
.replace(/_/g, " ") .replace(/\.conf$/i, "")
.replace(/-/g, " "); .replace(/_/g, " ")
setVpnName(baseName || `${detection.type} VPN`); .replace(/-/g, " ");
setStep("vpn-preview"); setVpnName(baseName || `${detection.type} VPN`);
}, []); setStep("vpn-preview");
},
[t],
);
const handleFileRead = useCallback( const handleFileRead = useCallback(
(file: File) => { (file: File) => {
@@ -109,11 +114,11 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
processContent(content, file.name); processContent(content, file.name);
}; };
reader.onerror = () => { reader.onerror = () => {
toast.error("Failed to read file"); toast.error(t("vpns.import.fileReadError"));
}; };
reader.readAsText(file); reader.readAsText(file);
}, },
[processContent], [processContent, t],
); );
const handleDrop = useCallback( const handleDrop = useCallback(
@@ -125,10 +130,10 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
if (validFile) { if (validFile) {
handleFileRead(validFile); handleFileRead(validFile);
} else { } else {
toast.error("Please drop a WireGuard .conf file"); toast.error(t("vpns.import.wrongFileType"));
} }
}, },
[handleFileRead], [handleFileRead, t],
); );
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
@@ -173,23 +178,22 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
} }
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "Failed to import VPN config", error instanceof Error ? error.message : t("vpns.import.failedGeneric"),
); );
} finally { } finally {
setIsImporting(false); setIsImporting(false);
} }
}, [vpnPreview, vpnName]); }, [vpnPreview, vpnName, t]);
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Import VPN Config</DialogTitle> <DialogTitle>{t("vpns.import.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{step === "dropzone" && {step === "dropzone" && t("vpns.import.descDropzone")}
"Import a WireGuard (.conf) configuration file"} {step === "vpn-preview" && t("vpns.import.descPreview")}
{step === "vpn-preview" && "Review the VPN configuration to import"} {step === "vpn-result" && t("vpns.import.descResult")}
{step === "vpn-result" && "VPN import completed"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -217,7 +221,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
> >
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" /> <LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Drop a WireGuard .conf file here or click to browse {t("vpns.import.dropzonePrompt")}
</p> </p>
<input <input
id="vpn-file-input" id="vpn-file-input"
@@ -232,7 +236,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
/> />
</div> </div>
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
Paste from clipboard with {modKey}+V {t("vpns.import.pasteHint", { modKey })}
</p> </p>
</div> </div>
)} )}
@@ -243,21 +247,25 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
<LuShield className="w-8 h-8 text-primary" /> <LuShield className="w-8 h-8 text-primary" />
<div> <div>
<div className="font-medium"> <div className="font-medium">
{vpnPreview.detectedType} Configuration {t("vpns.import.configurationLabel", {
type: vpnPreview.detectedType,
})}
</div> </div>
{vpnPreview.endpoint && ( {vpnPreview.endpoint && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Endpoint: {vpnPreview.endpoint} {t("vpns.import.endpointLabel", {
endpoint: vpnPreview.endpoint,
})}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="vpn-name">VPN Name</Label> <Label htmlFor="vpn-name">{t("vpns.import.vpnNameLabel")}</Label>
<Input <Input
id="vpn-name" id="vpn-name"
placeholder="My VPN" placeholder={t("vpns.import.vpnNamePlaceholder")}
value={vpnName} value={vpnName}
onChange={(e) => { onChange={(e) => {
setVpnName(e.target.value); setVpnName(e.target.value);
@@ -266,7 +274,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Config Preview</Label> <Label>{t("vpns.import.configPreview")}</Label>
<ScrollArea className="h-[150px] border rounded-md"> <ScrollArea className="h-[150px] border rounded-md">
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all"> <pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
{vpnPreview.content.slice(0, 1000)} {vpnPreview.content.slice(0, 1000)}
@@ -287,7 +295,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
<LuShield className="w-8 h-8 text-success" /> <LuShield className="w-8 h-8 text-success" />
<div> <div>
<div className="font-medium text-success"> <div className="font-medium text-success">
VPN Imported Successfully {t("vpns.import.importedSuccess")}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{vpnImportResult.name} ({vpnImportResult.vpn_type}) {vpnImportResult.name} ({vpnImportResult.vpn_type})
@@ -297,7 +305,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium text-destructive"> <div className="font-medium text-destructive">
Import Failed {t("vpns.import.importFailed")}
</div> </div>
<div className="text-sm text-destructive"> <div className="text-sm text-destructive">
{vpnImportResult.error} {vpnImportResult.error}
@@ -311,26 +319,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
<DialogFooter> <DialogFooter>
{step === "dropzone" && ( {step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}> <RippleButton variant="outline" onClick={handleClose}>
Cancel {t("common.buttons.cancel")}
</RippleButton> </RippleButton>
)} )}
{step === "vpn-preview" && ( {step === "vpn-preview" && (
<> <>
<RippleButton variant="outline" onClick={resetState}> <RippleButton variant="outline" onClick={resetState}>
Back {t("common.buttons.back")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isImporting} isLoading={isImporting}
onClick={() => void handleImport()} onClick={() => void handleImport()}
> >
Import VPN {t("vpns.import.importButton")}
</LoadingButton> </LoadingButton>
</> </>
)} )}
{step === "vpn-result" && ( {step === "vpn-result" && (
<RippleButton onClick={handleClose}>Done</RippleButton> <RippleButton onClick={handleClose}>
{t("vpns.import.doneButton")}
</RippleButton>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+10 -4
View File
@@ -316,7 +316,9 @@ export function WayfernConfigForm({
e.target.value || undefined, e.target.value || undefined,
); );
}} }}
placeholder="e.g., Win32, MacIntel, Linux x86_64" placeholder={t(
"config.wayfern.fingerprint.platformPlaceholder",
)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -755,7 +757,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined, e.target.value ? parseInt(e.target.value, 10) : undefined,
); );
}} }}
placeholder="e.g., 300 for EST (UTC-5)" placeholder={t(
"config.wayfern.fingerprint.timezoneOffsetPlaceholder",
)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -841,7 +845,9 @@ export function WayfernConfigForm({
e.target.value || undefined, e.target.value || undefined,
); );
}} }}
placeholder="e.g., Intel(R) HD Graphics" placeholder={t(
"config.wayfern.fingerprint.webglRendererPlaceholder",
)}
/> />
</div> </div>
</div> </div>
@@ -880,7 +886,7 @@ export function WayfernConfigForm({
e.target.value || undefined, e.target.value || undefined,
); );
}} }}
placeholder="Enter a seed string for canvas fingerprint" placeholder={t("fingerprint.canvasNoiseSeedPlaceholder")}
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("fingerprint.canvasNoiseSeedDescription")} {t("fingerprint.canvasNoiseSeedDescription")}
+11 -12
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { import {
Dialog, Dialog,
@@ -22,24 +23,25 @@ export function WayfernTermsDialog({
isOpen, isOpen,
onAccepted, onAccepted,
}: WayfernTermsDialogProps) { }: WayfernTermsDialogProps) {
const { t } = useTranslation();
const [isAccepting, setIsAccepting] = useState(false); const [isAccepting, setIsAccepting] = useState(false);
const handleAccept = useCallback(async () => { const handleAccept = useCallback(async () => {
setIsAccepting(true); setIsAccepting(true);
try { try {
await invoke("accept_wayfern_terms"); await invoke("accept_wayfern_terms");
showSuccessToast("Terms accepted successfully"); showSuccessToast(t("wayfernTerms.acceptSuccess"));
onAccepted(); onAccepted();
} catch (error) { } catch (error) {
console.error("Failed to accept terms:", error); console.error("Failed to accept terms:", error);
showErrorToast("Failed to accept terms", { showErrorToast(t("wayfernTerms.acceptFailed"), {
description: description:
error instanceof Error ? error.message : "Please try again", error instanceof Error ? error.message : t("wayfernTerms.tryAgain"),
}); });
} finally { } finally {
setIsAccepting(false); setIsAccepting(false);
} }
}, [onAccepted]); }, [onAccepted, t]);
return ( return (
<Dialog open={isOpen}> <Dialog open={isOpen}>
@@ -56,16 +58,13 @@ export function WayfernTermsDialog({
}} }}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>Wayfern Terms and Conditions</DialogTitle> <DialogTitle>{t("wayfernTerms.title")}</DialogTitle>
<DialogDescription> <DialogDescription>{t("wayfernTerms.description")}</DialogDescription>
Before using Donut Browser, you must read and agree to Wayfern's
Terms and Conditions.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Please review the Terms and Conditions at: {t("wayfernTerms.reviewLabel")}
</p> </p>
<a <a
href="https://wayfern.com/tos" href="https://wayfern.com/tos"
@@ -76,13 +75,13 @@ export function WayfernTermsDialog({
https://wayfern.com/tos https://wayfern.com/tos
</a> </a>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
By clicking "I Accept", you agree to be bound by these terms. {t("wayfernTerms.agreeNotice")}
</p> </p>
</div> </div>
<DialogFooter> <DialogFooter>
<LoadingButton onClick={handleAccept} isLoading={isAccepting}> <LoadingButton onClick={handleAccept} isLoading={isAccepting}>
I Accept {t("wayfernTerms.acceptButton")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+4 -2
View File
@@ -2,6 +2,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type Platform = "macos" | "windows" | "linux"; type Platform = "macos" | "windows" | "linux";
@@ -13,6 +14,7 @@ function detectPlatform(): Platform {
} }
export function WindowDragArea() { export function WindowDragArea() {
const { t } = useTranslation();
const [platform, setPlatform] = useState<Platform | null>(null); const [platform, setPlatform] = useState<Platform | null>(null);
useEffect(() => { useEffect(() => {
@@ -104,7 +106,7 @@ export function WindowDragArea() {
viewBox="0 0 10 1" viewBox="0 0 10 1"
fill="currentColor" fill="currentColor"
role="img" role="img"
aria-label="Minimize" aria-label={t("common.window.minimize")}
> >
<rect width="10" height="1" /> <rect width="10" height="1" />
</svg> </svg>
@@ -124,7 +126,7 @@ export function WindowDragArea() {
stroke="currentColor" stroke="currentColor"
strokeWidth="1.2" strokeWidth="1.2"
role="img" role="img"
aria-label="Close" aria-label={t("common.buttons.close")}
> >
<line x1="1" y1="1" x2="9" y2="9" /> <line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" /> <line x1="9" y1="1" x2="1" y2="9" />
+32 -27
View File
@@ -3,12 +3,14 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { AppUpdateToast } from "@/components/app-update-toast"; import { AppUpdateToast } from "@/components/app-update-toast";
import { showToast } from "@/lib/toast-utils"; import { showToast } from "@/lib/toast-utils";
import type { AppUpdateInfo, AppUpdateProgress } from "@/types"; import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
export function useAppUpdateNotifications() { export function useAppUpdateNotifications() {
const { t } = useTranslation();
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [updateProgress, setUpdateProgress] = const [updateProgress, setUpdateProgress] =
@@ -60,32 +62,35 @@ export function useAppUpdateNotifications() {
} }
}, [isClient]); }, [isClient]);
const handleAppUpdate = useCallback(async (appUpdateInfo: AppUpdateInfo) => { const handleAppUpdate = useCallback(
try { async (appUpdateInfo: AppUpdateInfo) => {
setIsUpdating(true); try {
setUpdateProgress({ setIsUpdating(true);
stage: "downloading", setUpdateProgress({
percentage: 0, stage: "downloading",
speed: undefined, percentage: 0,
eta: undefined, speed: undefined,
message: "Starting update...", eta: undefined,
}); message: "Starting update...",
});
await invoke("download_and_prepare_app_update", { await invoke("download_and_prepare_app_update", {
updateInfo: appUpdateInfo, updateInfo: appUpdateInfo,
}); });
} catch (error) { } catch (error) {
console.error("Failed to update app:", error); console.error("Failed to update app:", error);
showToast({ showToast({
type: "error", type: "error",
title: "Failed to update Donut Browser", title: t("appUpdate.toast.updateFailed"),
description: String(error), description: String(error),
duration: 6000, duration: 6000,
}); });
setIsUpdating(false); setIsUpdating(false);
setUpdateProgress(null); setUpdateProgress(null);
} }
}, []); },
[t],
);
const handleRestart = useCallback(async () => { const handleRestart = useCallback(async () => {
try { try {
@@ -94,12 +99,12 @@ export function useAppUpdateNotifications() {
console.error("Failed to restart app:", error); console.error("Failed to restart app:", error);
showToast({ showToast({
type: "error", type: "error",
title: "Failed to restart", title: t("appUpdate.toast.restartFailed"),
description: String(error), description: String(error),
duration: 6000, duration: 6000,
}); });
} }
}, []); }, [t]);
const dismissAppUpdate = useCallback(() => { const dismissAppUpdate = useCallback(() => {
if (!isClient) return; if (!isClient) return;
+52 -21
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type { Event as TauriEvent } from "@tauri-apps/api/event"; import type { Event as TauriEvent } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import { getBrowserDisplayName } from "@/lib/browser-utils"; import { getBrowserDisplayName } from "@/lib/browser-utils";
import { import {
dismissToast, dismissToast,
@@ -106,11 +107,18 @@ export function useBrowserDownload() {
return githubReleases; return githubReleases;
} catch (error) { } catch (error) {
console.error("Failed to load versions:", error); console.error("Failed to load versions:", error);
showErrorToast(`Failed to fetch ${browserName} versions`, { showErrorToast(
description: i18n.t("browserDownload.toast.fetchVersionsFailed", {
error instanceof Error ? error.message : "Unknown error occurred", browser: browserName,
duration: 4000, }),
}); {
description:
error instanceof Error
? error.message
: i18n.t("common.errors.unknown"),
duration: 4000,
},
);
throw error; throw error;
} }
}, []); }, []);
@@ -146,10 +154,16 @@ export function useBrowserDownload() {
// Show notification about new versions if any were found // Show notification about new versions if any were found
if (result.new_versions_count && result.new_versions_count > 0) { if (result.new_versions_count && result.new_versions_count > 0) {
showSuccessToast( showSuccessToast(
`Found ${result.new_versions_count} new ${browserName} versions!`, i18n.t("browserDownload.toast.foundNewVersions", {
count: result.new_versions_count,
browser: browserName,
}),
{ {
duration: 3000, duration: 3000,
description: `Total available: ${result.total_versions_count} versions`, description: i18n.t(
"browserDownload.toast.totalAvailableVersions",
{ count: result.total_versions_count },
),
}, },
); );
} }
@@ -157,11 +171,18 @@ export function useBrowserDownload() {
return githubReleases; return githubReleases;
} catch (error) { } catch (error) {
console.error("Failed to load versions:", error); console.error("Failed to load versions:", error);
showErrorToast(`Failed to fetch ${browserName} versions`, { showErrorToast(
description: i18n.t("browserDownload.toast.fetchVersionsFailed", {
error instanceof Error ? error.message : "Unknown error occurred", browser: browserName,
duration: 4000, }),
}); {
description:
error instanceof Error
? error.message
: i18n.t("common.errors.unknown"),
duration: 4000,
},
);
throw error; throw error;
} }
}, []); }, []);
@@ -215,7 +236,7 @@ export function useBrowserDownload() {
// Dismiss any existing download toast and show error // Dismiss any existing download toast and show error
dismissToast(`download-${browserStr}-${version}`); dismissToast(`download-${browserStr}-${version}`);
let errorMessage = "Unknown error occurred"; let errorMessage = i18n.t("common.errors.unknown");
if (error instanceof Error) { if (error instanceof Error) {
errorMessage = error.message; errorMessage = error.message;
} else if (typeof error === "string") { } else if (typeof error === "string") {
@@ -226,10 +247,16 @@ export function useBrowserDownload() {
// Ensure the long-running download toast is dismissed, and show a finite error toast // Ensure the long-running download toast is dismissed, and show a finite error toast
dismissToast(`download-${browserStr}-${version}`); dismissToast(`download-${browserStr}-${version}`);
showErrorToast(`Failed to download ${browserName} ${version}`, { showErrorToast(
description: errorMessage, i18n.t("browserDownload.toast.downloadFailed", {
duration: 8000, browser: browserName,
}); version,
}),
{
description: errorMessage,
duration: 8000,
},
);
} }
throw error; throw error;
} finally { } finally {
@@ -297,7 +324,7 @@ export function useBrowserDownload() {
).toFixed(1); ).toFixed(1);
const etaText = progress.eta_seconds const etaText = progress.eta_seconds
? formatTime(progress.eta_seconds) ? formatTime(progress.eta_seconds)
: "calculating..."; : i18n.t("browserDownload.toast.calculating");
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`; const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
showDownloadToast( showDownloadToast(
@@ -346,10 +373,14 @@ export function useBrowserDownload() {
); );
setDownloadProgress(null); setDownloadProgress(null);
showErrorToast( showErrorToast(
`${browserName} ${progress.version}: extraction failed`, i18n.t("browserDownload.toast.extractionFailed", {
browser: browserName,
version: progress.version,
}),
{ {
description: description: i18n.t(
"The corrupt file was deleted. It will be re-downloaded on next attempt.", "browserDownload.toast.extractionFailedDescription",
),
}, },
); );
} else if (progress.stage === "completed") { } else if (progress.stage === "completed") {

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