Compare commits

..

23 Commits

Author SHA1 Message Date
zhom e1b79037bf chore: version bump 2026-06-18 02:10:36 +04:00
zhom 57036bdc95 docs: readme 2026-06-18 02:09:42 +04:00
zhom d3169ad7a9 refactor: better tray icon 2026-06-18 01:41:14 +04:00
zhom e1fcfd5403 refactor: simplify socks connection 2026-06-17 18:33:09 +04:00
zhom 9dc9e13182 refactor: switch local proxy from http to socks 2026-06-17 18:33:09 +04:00
zhom c5a168ae0f docs: readme 2026-06-17 18:33:09 +04:00
zhom 168b7ac6d4 feat: amek window resizable 2026-06-17 18:33:09 +04:00
dependabot[bot] e5910ad5cf ci(deps): bump anomalyco/opencode in the github-actions group (#437)
Bumps the github-actions group with 1 update: [anomalyco/opencode](https://github.com/anomalyco/opencode).


Updates `anomalyco/opencode` from 1.16.2 to 1.17.4
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/76c631d198f9ff620e15468e45f3457d50481b57...abda3515f444c4d28a98953d153c5a3e1892d3d4)

---
updated-dependencies:
- dependency-name: anomalyco/opencode
  dependency-version: 1.17.4
  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-06-13 09:43:17 +00:00
github-actions[bot] 202f2c852b chore: update flake.nix for v0.26.0 [skip ci] (#428)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-08 02:02:46 +00:00
github-actions[bot] 5a8864654d docs: update CHANGELOG.md and README.md for v0.26.0 [skip ci] (#427)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-08 02:02:30 +00:00
zhom ba40458216 chore: version bump 2026-06-08 04:38:01 +04:00
zhom 91e6381ba5 chore: linting 2026-06-08 00:44:03 +04:00
zhom 2055108578 feat: add cookie export 2026-06-08 00:06:44 +04:00
zhom fc9a00b97d refactor: deprecate camoufox 2026-06-08 00:06:44 +04:00
zhom 15f3aa03f7 refactor: cleanup 2026-06-08 00:06:44 +04:00
dependabot[bot] 6b31c937ea deps(rust)(deps): bump the rust-dependencies group (#422)
Bumps the rust-dependencies group in /src-tauri with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [log](https://github.com/rust-lang/log) | `0.4.30` | `0.4.32` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [chrono](https://github.com/chronotope/chrono) | `0.4.44` | `0.4.45` |
| [rusqlite](https://github.com/rusqlite/rusqlite) | `0.40.0` | `0.40.1` |
| [serial_test](https://github.com/palfrey/serial_test) | `3.4.0` | `3.5.0` |
| [hashlink](https://github.com/djc/hashlink) | `0.11.0` | `0.12.0` |
| [libfuzzer-sys](https://github.com/rust-fuzz/libfuzzer) | `0.4.12` | `0.4.13` |
| [libsqlite3-sys](https://github.com/rusqlite/rusqlite) | `0.38.0` | `0.38.1` |
| [serde_with](https://github.com/jonasbb/serde_with) | `3.20.0` | `3.21.0` |
| [serde_with_macros](https://github.com/jonasbb/serde_with) | `3.20.0` | `3.21.0` |
| [serial_test_derive](https://github.com/palfrey/serial_test) | `3.4.0` | `3.5.0` |
| [unicode-segmentation](https://github.com/unicode-rs/unicode-segmentation) | `1.13.2` | `1.13.3` |
| [yoke](https://github.com/unicode-org/icu4x) | `0.8.2` | `0.8.3` |


Updates `log` from 0.4.30 to 0.4.32
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.30...0.4.32)

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 `chrono` from 0.4.44 to 0.4.45
- [Release notes](https://github.com/chronotope/chrono/releases)
- [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md)
- [Commits](https://github.com/chronotope/chrono/compare/v0.4.44...v0.4.45)

Updates `rusqlite` from 0.40.0 to 0.40.1
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.40.0...v0.40.1)

Updates `serial_test` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/palfrey/serial_test/releases)
- [Commits](https://github.com/palfrey/serial_test/compare/v3.4.0...v3.5.0)

Updates `hashlink` from 0.11.0 to 0.12.0
- [Release notes](https://github.com/djc/hashlink/releases)
- [Changelog](https://github.com/djc/hashlink/blob/main/CHANGELOG.md)
- [Commits](https://github.com/djc/hashlink/compare/v0.11.0...v0.12.0)

Updates `libfuzzer-sys` from 0.4.12 to 0.4.13
- [Changelog](https://github.com/rust-fuzz/libfuzzer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-fuzz/libfuzzer/compare/0.4.12...0.4.13)

Updates `libsqlite3-sys` from 0.38.0 to 0.38.1
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/commits)

Updates `serde_with` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.20.0...v3.21.0)

Updates `serde_with_macros` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.20.0...v3.21.0)

Updates `serial_test_derive` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/palfrey/serial_test/releases)
- [Commits](https://github.com/palfrey/serial_test/compare/v3.4.0...v3.5.0)

Updates `unicode-segmentation` from 1.13.2 to 1.13.3
- [Commits](https://github.com/unicode-rs/unicode-segmentation/commits)

Updates `yoke` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits/ind/yoke@0.8.3)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  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: chrono
  dependency-version: 0.4.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rusqlite
  dependency-version: 0.40.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serial_test
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hashlink
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libfuzzer-sys
  dependency-version: 0.4.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libsqlite3-sys
  dependency-version: 0.38.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serde_with
  dependency-version: 3.21.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.21.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serial_test_derive
  dependency-version: 3.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: unicode-segmentation
  dependency-version: 1.13.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: yoke
  dependency-version: 0.8.3
  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-06-06 10:28:59 +00:00
dependabot[bot] 96e4f22e38 ci(deps): bump the github-actions group with 3 updates (#421)
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v6.0.2...v6.0.3)

Updates `anomalyco/opencode` from 1.15.13 to 1.16.2
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/385cb694419f98103af0e8fc6187ddcbcbb6eecb...76c631d198f9ff620e15468e45f3457d50481b57)

Updates `crate-ci/typos` from 1.47.0 to 1.47.2
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/f8a58b6b53f2279f71eb605f03a4ae4d10608f45...37bb98842b0d8c4ffebdb75301a13db0267cef89)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.16.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.47.2
  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-06-06 09:42:21 +00:00
github-actions[bot] ef7af59ef8 chore: update flake.nix for v0.25.3 [skip ci] (#417)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-03 20:25:14 +00:00
github-actions[bot] 3df5bffdf5 docs: update CHANGELOG.md and README.md for v0.25.3 [skip ci] (#416)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-03 20:24:58 +00:00
zhom e98d02a585 chore: version bump 2026-06-03 23:05:21 +04:00
zhom afa2326584 fix: launch wayfern with proper dimentions for mobile devices 2026-06-03 23:05:21 +04:00
github-actions[bot] d25d8549e4 chore: update flake.nix for v0.25.2 [skip ci] (#415)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 01:43:35 +00:00
github-actions[bot] 662b370ed0 docs: update CHANGELOG.md and README.md for v0.25.2 [skip ci] (#414)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 01:43:19 +00:00
115 changed files with 4030 additions and 2089 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Install Nix
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Gather context
env:
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Check if first-time contributor
id: check-first-time
@@ -479,7 +479,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Check if first-time contributor
id: check-first-time
@@ -617,10 +617,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Run opencode
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
uses: anomalyco/opencode/github@abda3515f444c4d28a98953d153c5a3e1892d3d4 #v1.17.4
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: main
fetch-depth: 0
+17 -2
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Determine release tag
id: tag
@@ -59,4 +59,19 @@ jobs:
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
run: |
# GitHub injects secrets verbatim. If a value was pasted with
# surrounding quotes or a trailing newline — the local .env wraps all
# four R2_* values in double quotes — it reaches the script malformed:
# e.g. an endpoint of https://"host" yields
# `Could not connect to the endpoint URL`, and a quoted key yields
# `Unauthorized`. The local run is unaffected because publish-repo.sh
# sources .env through bash, which strips the quotes; CI has no .env,
# so strip here. No-op when the secrets are already clean. The script
# itself is intentionally left untouched.
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
fetch-depth: 0
+4 -4
View File
@@ -105,7 +105,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
@@ -288,7 +288,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
ref: main
fetch-depth: 0
@@ -454,7 +454,7 @@ jobs:
needs: [release, changelog]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
ref: main
fetch-depth: 0
@@ -552,7 +552,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
with:
ref: main
+2 -2
View File
@@ -104,7 +104,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
@@ -284,7 +284,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Generate nightly tag
id: tag
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Spell Check Repo
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
+3
View File
@@ -22,3 +22,6 @@ jobs:
stale-pr-label: "stale"
days-before-stale: 30
days-before-close: 7
# Never let the maintainer's own assigned issues go stale or get
# closed, regardless of inactivity.
exempt-issue-assignees: "zhom"
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
@@ -73,7 +73,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Start MinIO
run: |
+7 -4
View File
@@ -1,3 +1,9 @@
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
---
# Project Guidelines
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
@@ -27,9 +33,7 @@ donutbrowser/
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
│ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence
@@ -60,9 +64,8 @@ donutbrowser/
Three log surfaces, in order of usefulness:
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
+53
View File
@@ -1,6 +1,59 @@
# Changelog
## v0.26.0 (2026-06-08)
### Features
- add cookie export
### Refactoring
- deprecate camoufox
- cleanup
### Maintenance
- chore: version bump
- chore: linting
- ci(deps): bump the github-actions group with 3 updates (#421)
- chore: update flake.nix for v0.25.3 [skip ci] (#417)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#422)
## v0.25.3 (2026-06-03)
### Bug Fixes
- launch wayfern with proper dimentions for mobile devices
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.25.2 [skip ci] (#415)
## v0.25.2 (2026-06-02)
### Refactoring
- cleanup
### Documentation
- update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
### Maintenance
- chore: simplify linux repo publish
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.25.1 [skip ci] (#413)
## v0.25.1 (2026-06-01)
### Maintenance
+6 -6
View File
@@ -26,7 +26,7 @@
## Features
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), which is privacy-focused Chromium fork that comes with advanced fingerprint spoofing which naturally hides information in a way that is not detected by Cloudflare, reCaptcha v3, and other browser fingerprinting and anti-bot services.
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- **VPN support** — WireGuard configs per profile
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64.dmg) |
Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut-0.25.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut-0.25.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+30 -3
View File
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import {
type CanActivate,
type ExecutionContext,
@@ -10,6 +11,13 @@ import type { Request } from "express";
import * as jwt from "jsonwebtoken";
import type { UserContext } from "./user-context.interface.js";
/** Constant-time string compare; false on length mismatch (no early return). */
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
@Injectable()
export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
@@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate {
// Try SYNC_TOKEN first (self-hosted mode)
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
if (expectedToken && token === expectedToken) {
if (expectedToken && safeEqual(token, expectedToken)) {
(request as unknown as Record<string, unknown>).user = {
mode: "self-hosted",
prefix: "",
@@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate {
algorithms: ["RS256"],
}) as jwt.JwtPayload;
// Validate the scope claims' SHAPE before trusting them as S3 key
// prefixes. An empty/over-broad prefix would make validateKeyAccess
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
// bug or permissive claim must not silently widen scope.
const prefix = decoded.prefix || `users/${decoded.sub}/`;
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
}
const teamPrefix =
decoded.teamPrefix === undefined || decoded.teamPrefix === null
? null
: decoded.teamPrefix;
if (
teamPrefix !== null &&
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
) {
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
}
(request as unknown as Record<string, unknown>).user = {
mode: "cloud",
prefix: decoded.prefix || `users/${decoded.sub}/`,
teamPrefix: decoded.teamPrefix || null,
prefix,
teamPrefix,
profileLimit: decoded.profileLimit || 0,
teamProfileLimit: decoded.teamProfileLimit || 0,
} satisfies UserContext;
+9 -1
View File
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import {
Body,
Controller,
@@ -9,6 +10,13 @@ import {
import { ConfigService } from "@nestjs/config";
import { SyncService } from "./sync.service.js";
/** Constant-time string compare; false on length mismatch. */
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
@Controller("v1/internal")
export class InternalController {
private readonly internalKey: string | undefined;
@@ -26,7 +34,7 @@ export class InternalController {
@Headers("x-internal-key") key: string,
@Body() body: { userId: string; maxProfiles: number },
) {
if (!this.internalKey || key !== this.internalKey) {
if (!this.internalKey || !key || !safeEqual(key, this.internalKey)) {
throw new UnauthorizedException("Invalid internal key");
}
+34 -5
View File
@@ -54,6 +54,29 @@ import type {
*/
const MANIFEST_KEY = ".donut-sync-manifest";
/** Max presigned-URL lifetime. The client requests ~1h; never mint a URL that
* outlives this, regardless of a (possibly hostile) client-supplied expiresIn. */
const MAX_PRESIGN_EXPIRES_IN = 3600;
/** Clamp a client-supplied expiresIn to a sane positive range. */
function clampExpiresIn(requested: number | undefined): number {
const v = typeof requested === "number" && requested > 0 ? requested : 3600;
return Math.min(v, MAX_PRESIGN_EXPIRES_IN);
}
/** Only this metadata key is meaningful to sync (LWW conflict resolution).
* Whitelisting prevents a client from signing arbitrary x-amz-meta-* values. */
function sanitizeMetadata(
metadata: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!metadata) return undefined;
const out: Record<string, string> = {};
if (typeof metadata["updated-at"] === "string") {
out["updated-at"] = metadata["updated-at"];
}
return Object.keys(out).length > 0 ? out : undefined;
}
@Injectable()
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
@@ -286,16 +309,19 @@ export class SyncService implements OnModuleInit {
await this.checkProfileLimit(ctx);
}
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
// Whitelist metadata to the single key sync relies on, so a client can't
// sign arbitrary x-amz-meta-* values into its objects.
const metadata = sanitizeMetadata(dto.metadata);
const command = new PutCmd({
Bucket: this.bucket,
Key: key,
ContentType: dto.contentType || "application/octet-stream",
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
// exactly these headers on the PUT, so we echo them in the response.
Metadata: dto.metadata,
Metadata: metadata,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
@@ -313,6 +339,9 @@ export class SyncService implements OnModuleInit {
return {
url,
expiresAt: expiresAt.toISOString(),
// Echo the metadata we actually signed so the client sends matching
// x-amz-meta-* headers on the PUT (S3 rejects unsigned ones).
metadata,
};
}
@@ -323,7 +352,7 @@ export class SyncService implements OnModuleInit {
const key = this.scopeKey(ctx, dto.key);
this.validateKeyAccess(ctx, key);
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
const command = new GetObjectCommand({
@@ -438,7 +467,7 @@ export class SyncService implements OnModuleInit {
await this.checkProfileLimit(ctx);
}
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
const items = await Promise.all(
@@ -491,7 +520,7 @@ export class SyncService implements OnModuleInit {
dto: PresignDownloadBatchRequestDto,
ctx: UserContext,
): Promise<PresignDownloadBatchResponseDto> {
const expiresIn = dto.expiresIn || 3600;
const expiresIn = clampExpiresIn(dto.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
const items = await Promise.all(
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.25.1";
releaseVersion = "0.26.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.AppImage";
hash = "sha256-+wtKVCYUjDgXyL96oCqHC0ekWHIe9pLjn1RLBfWHamA=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage";
hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.AppImage";
hash = "sha256-fEmf8OzYG3XoEHwOVLh1mONDcJEGeW3d4bb3y//6gPs=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
}
else
null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.25.2",
"version": "0.27.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+62 -58
View File
@@ -169,7 +169,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -214,7 +214,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1068,9 +1068,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -1709,7 +1709,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1784,7 +1784,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.25.2"
version = "0.27.0"
dependencies = [
"aes 0.9.1",
"aes-gcm",
@@ -1838,6 +1838,7 @@ dependencies = [
"sha2 0.11.0",
"shadowsocks",
"smoltcp",
"subtle",
"sys-locale",
"sysinfo",
"tar",
@@ -1852,6 +1853,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-window-state",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -2097,7 +2099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2862,14 +2864,17 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.11.0"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
dependencies = [
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -3621,9 +3626,9 @@ dependencies = [
[[package]]
name = "libfuzzer-sys"
version = "0.4.12"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
@@ -3656,9 +3661,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.38.0"
version = "0.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c"
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
dependencies = [
"cc",
"pkg-config",
@@ -3688,9 +3693,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.30"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
dependencies = [
"value-bag",
]
@@ -3910,7 +3915,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4087,7 +4092,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -4447,7 +4452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5496,9 +5501,9 @@ dependencies = [
[[package]]
name = "rusqlite"
version = "0.40.0"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4"
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
@@ -5561,7 +5566,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5636,15 +5641,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -5711,12 +5707,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "seahash"
version = "4.1.0"
@@ -5916,9 +5906,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -5936,9 +5926,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"proc-macro2",
@@ -5961,24 +5951,23 @@ dependencies = [
[[package]]
name = "serial_test"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
dependencies = [
"futures-executor",
"futures-util",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
dependencies = [
"proc-macro2",
"quote",
@@ -6230,7 +6219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6870,6 +6859,21 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.1",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.11.2"
@@ -6977,10 +6981,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7482,7 +7486,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7554,7 +7558,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7642,9 +7646,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-vo"
@@ -8212,7 +8216,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8738,7 +8742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9018,9 +9022,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yoke"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.25.2"
version = "0.27.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -41,6 +41,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-window-state = "2"
log = "0.4"
env_logger = "0.11"
@@ -81,6 +82,7 @@ aes-gcm = "0.10"
aes = "0.9"
cbc = "0.2"
ring = "0.17"
subtle = "2"
sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
hyper = { version = "1.10", features = ["full"] }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+141 -12
View File
@@ -58,13 +58,25 @@ pub struct ApiProfileResponse {
pub struct CreateProfileRequest {
pub name: String,
pub browser: String,
pub version: String,
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
/// version of the chosen browser. A concrete version must already be
/// downloaded; the create path does not fetch new versions.
#[serde(default)]
pub version: Option<String>,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
/// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`.
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
/// generated automatically at creation. Provide a `fingerprint` field to
/// pin a specific one.
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
/// generated automatically at creation. Provide a `fingerprint` field to
/// pin a specific one.
#[schema(value_type = Object)]
pub wayfern_config: Option<serde_json::Value>,
pub group_id: Option<String>,
@@ -74,7 +86,9 @@ pub struct CreateProfileRequest {
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
pub name: Option<String>,
pub browser: Option<String>,
// No `browser` field: a profile's engine is fixed at creation (changing it
// would invalidate the generated fingerprint and on-disk profile dir).
// Accepting it here only to silently ignore it misled API clients.
pub version: Option<String>,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
@@ -405,6 +419,9 @@ impl ApiServer {
let api = ApiDoc::openapi();
let v1_routes = v1_routes
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
.layer(middleware::from_fn(rate_limit_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
@@ -508,8 +525,14 @@ async fn auth_middleware(
}
};
// Compare tokens
if token != stored_token {
// Constant-time comparison so the auth check doesn't leak the shared-prefix
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
// lengths simply compare unequal.
use subtle::ConstantTimeEq;
let token_bytes = token.as_bytes();
let stored_bytes = stored_token.as_bytes();
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
if !matches {
log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED);
}
@@ -550,6 +573,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
response
}
/// Chokepoint for the future per-hour automation request limit. The limit
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
/// this middleware is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated requests per rolling hour and
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
async fn rate_limit_middleware(
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
Ok(next.run(request).await)
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
@@ -694,14 +731,24 @@ async fn get_profile(
}
}
/// Create a profile.
///
/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected
/// with 400.
/// - `version` is optional: omit it or pass `"latest"` to use the newest
/// already-downloaded version of that browser. The version must be present
/// locally (this endpoint does not download new versions); 400 if none is.
/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an
/// empty object `{}`, generates a fresh fingerprint automatically.
#[utoipa::path(
post,
path = "/v1/profiles",
request_body = CreateProfileRequest,
responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Bad request"),
(status = 400, description = "Invalid browser, or no downloaded version available"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Selected proxy requires payment"),
(status = 500, description = "Internal server error")
),
security(
@@ -715,6 +762,34 @@ async fn create_profile(
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
// (fingerprint generation, launch, run) supports nothing else. Reject anything
// else up front — otherwise the profile is created with no fingerprint and an
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
// create_profile validation.
if request.browser != "wayfern" && request.browser != "camoufox" {
return Err(StatusCode::BAD_REQUEST);
}
// Resolve the version. Omitted, empty, or "latest" means "newest version
// already downloaded for this browser". The create path generates the
// fingerprint by launching that binary, so the version must be present
// locally — we don't fetch new versions here. 400 if none is downloaded.
let version = match request.version.as_deref() {
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
_ => {
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let mut versions = registry.get_downloaded_versions(&request.browser);
// browsers is a HashMap, so keys are unordered — sort newest-first by
// semver before taking the latest.
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
match versions.into_iter().next() {
Some(v) => v,
None => return Err(StatusCode::BAD_REQUEST),
}
}
};
// Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config {
serde_json::from_value(config.clone()).ok()
@@ -747,7 +822,7 @@ async fn create_profile(
&state.app_handle,
&request.name,
&request.browser,
&request.version,
&version,
request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(),
request.vpn_id.clone(),
@@ -895,10 +970,10 @@ async fn update_profile(
}
if let Some(camoufox_config) = request.camoufox_config {
// Editing a profile's fingerprint config is a paid feature everywhere
// (GUI, API, MCP). Viewing it is free; mutating it is not.
// Editing a profile's fingerprint config is part of the cross-OS fingerprint
// capability (GUI, API, MCP). Viewing it is free; mutating it is not.
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_cross_os_fingerprints()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
@@ -1721,7 +1796,7 @@ async fn run_profile(
Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
@@ -1807,7 +1882,7 @@ async fn open_url_in_profile(
Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
@@ -1849,7 +1924,7 @@ async fn kill_profile(
// Programmatically launching and stopping profiles is a paid feature; the
// run/open-url handlers gate the same way.
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
@@ -2090,3 +2165,57 @@ async fn refresh_wayfern_token(
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token }))
}
#[cfg(test)]
mod tests {
use super::*;
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
// `browser` values on create, must NOT make the API reject requests that
// carry extra/unknown fields — old clients still send them. serde ignores
// unknown fields by default; these tests lock that in so a future
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
#[test]
fn update_profile_request_ignores_unknown_fields() {
// `browser` is no longer a field, plus a wholly unknown field. Both must
// be accepted and ignored, not rejected.
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
let parsed: UpdateProfileRequest =
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
assert_eq!(parsed.name.as_deref(), Some("p"));
}
#[test]
fn create_profile_request_ignores_unknown_fields() {
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
let parsed: CreateProfileRequest =
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
assert_eq!(parsed.browser, "wayfern");
}
#[test]
fn create_profile_request_allows_omitting_version_and_configs() {
// Minimal body: no version, no wayfern_config/camoufox_config. Must
// deserialize (version resolves to latest-downloaded at the handler; an
// absent config triggers fresh-fingerprint generation).
let json = r#"{"name": "p", "browser": "wayfern"}"#;
let parsed: CreateProfileRequest =
serde_json::from_str(json).expect("version and configs are optional");
assert_eq!(parsed.browser, "wayfern");
assert!(parsed.version.is_none());
assert!(parsed.wayfern_config.is_none());
assert!(parsed.camoufox_config.is_none());
}
#[test]
fn create_profile_browser_validation_matches_supported_engines() {
// The handler rejects anything that isn't a launchable engine; this is the
// same predicate it uses, kept in lockstep with MCP's create_profile.
let is_valid = |b: &str| b == "wayfern" || b == "camoufox";
assert!(is_valid("wayfern"));
assert!(is_valid("camoufox"));
assert!(!is_valid("chromium"));
assert!(!is_valid("firefox"));
assert!(!is_valid(""));
}
}
+7
View File
@@ -162,6 +162,11 @@ async fn main() {
Arg::new("blocklist-file")
.long("blocklist-file")
.help("Path to DNS blocklist file (one domain per line)"),
)
.arg(
Arg::new("local-protocol")
.long("local-protocol")
.help("Protocol served to the browser: http (default) or socks5"),
),
)
.subcommand(
@@ -251,6 +256,7 @@ async fn main() {
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
match start_proxy_process_with_profile(
upstream_url,
@@ -258,6 +264,7 @@ async fn main() {
profile_id,
bypass_rules,
blocklist_file,
local_protocol,
)
.await
{
+13 -2
View File
@@ -261,6 +261,11 @@ impl BrowserRunner {
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
// Camoufox (Firefox 150, and Firefox 135 on the not-yet-updated
// Windows build) keeps the local HTTP proxy: Firefox's QUIC stack
// bypasses a configured proxy, so QUIC is disabled and HTTP CONNECT
// covers everything. SOCKS5 is reserved for Wayfern.
"http",
)
.await
.map_err(|e| {
@@ -527,6 +532,11 @@ impl BrowserRunner {
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
// leaking the real IP, rather than being forced direct as they
// would be over an HTTP CONNECT proxy.
"socks5",
)
.await
.map_err(|e| {
@@ -535,8 +545,9 @@ impl BrowserRunner {
error_msg
})?;
// Format proxy URL for wayfern - always use HTTP for the local proxy
let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port);
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
// Set proxy in wayfern config
wayfern_config.proxy = Some(proxy_url);
+189 -23
View File
@@ -21,6 +21,76 @@ use crate::sync;
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
/// rate-limit chokepoints in api_server / mcp_server.
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
/// Capability + limit set the account is entitled to, derived from its plan.
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
/// flags instead of a single "is paid?" boolean, so a plan like the future
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
/// data here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entitlements {
#[serde(default)]
pub active: bool,
#[serde(rename = "browserAutomation", default)]
pub browser_automation: bool,
#[serde(rename = "crossOsFingerprints", default)]
pub cross_os_fingerprints: bool,
#[serde(rename = "cloudBackup", default)]
pub cloud_backup: bool,
#[serde(rename = "teamCollaboration", default)]
pub team_collaboration: bool,
#[serde(rename = "profileLimit", default)]
pub profile_limit: i64,
#[serde(rename = "requestsPerHour", default)]
pub requests_per_hour: i64,
}
/// Local fallback mirror of the backend plan -> capability matrix, used only when
/// the server hasn't sent an entitlements object (older cached state / backend).
fn derive_entitlements(
plan: &str,
plan_period: Option<&str>,
subscription_status: &str,
profile_limit: i64,
) -> Entitlements {
let active =
plan != "free" && (subscription_status == "active" || plan_period == Some("lifetime"));
if !active {
return Entitlements {
active: false,
browser_automation: false,
cross_os_fingerprints: false,
cloud_backup: false,
team_collaboration: false,
profile_limit: 0,
requests_per_hour: 0,
};
}
// pro and any unrecognized paid plan -> pro-level (never team).
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
"starter" => (false, true, true, false),
"team" | "enterprise" => (true, true, true, true),
_ => (true, true, true, false),
};
Entitlements {
active,
browser_automation,
cross_os_fingerprints,
cloud_backup,
team_collaboration,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
} else {
0
},
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudUser {
pub id: String,
@@ -56,6 +126,26 @@ pub struct CloudUser {
pub device_count: Option<i64>,
#[serde(rename = "isPrimaryDevice", default)]
pub is_primary_device: Option<bool>,
/// Capability/limit set derived from the plan by the backend. `default` (None)
/// keeps older login/state payloads deserializing; resolve via `entitlements()`.
#[serde(default)]
pub entitlements: Option<Entitlements>,
}
impl CloudUser {
/// Authoritative entitlements: the server-sent set when present, else derived
/// locally from the plan fields (keeps older cached state / backends working).
pub fn entitlements(&self) -> Entitlements {
if let Some(e) = &self.entitlements {
return e.clone();
}
derive_entitlements(
&self.plan,
self.plan_period.as_deref(),
&self.subscription_status,
self.profile_limit,
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -658,39 +748,83 @@ impl CloudAuthManager {
state.is_some()
}
pub async fn has_active_paid_subscription(&self) -> bool {
/// Resolve this session's entitlements (server-sent or locally derived).
pub async fn entitlements(&self) -> Option<Entitlements> {
let state = self.state.lock().await;
match &*state {
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
}
state.as_ref().map(|auth| auth.user.entitlements())
}
/// Account is in a paid/active state. Used for the "any active plan" gates
/// (sync token, wayfern token); per-feature access uses the capability helpers.
pub async fn has_active_paid_subscription(&self) -> bool {
self.entitlements().await.map(|e| e.active).unwrap_or(false)
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() {
Ok(state) => match &*state {
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
},
Ok(state) => state
.as_ref()
.map(|auth| auth.user.entitlements().active)
.unwrap_or(false),
Err(_) => false,
}
}
/// Launch/drive profiles programmatically (local API + MCP automation).
pub async fn can_use_browser_automation(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.browser_automation)
.unwrap_or(false)
}
/// Edit fingerprints / use a non-native OS fingerprint.
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.cross_os_fingerprints)
.unwrap_or(false)
}
/// Cloud profile sync / backup (async).
pub async fn can_use_cloud_backup(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.cloud_backup)
.unwrap_or(false)
}
/// Cloud profile sync / backup (non-async, try_lock; false if unavailable).
pub fn can_use_cloud_backup_sync(&self) -> bool {
match self.state.try_lock() {
Ok(state) => state
.as_ref()
.map(|auth| auth.user.entitlements().cloud_backup)
.unwrap_or(false),
Err(_) => false,
}
}
/// Per-hour cap on automation requests (0 when automation is unavailable).
/// Carried for the future local rate limiter; read by the inert chokepoints.
pub async fn requests_per_hour(&self) -> i64 {
self
.entitlements()
.await
.map(|e| e.requests_per_hour)
.unwrap_or(0)
}
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
let host_os = crate::profile::types::get_host_os();
match fingerprint_os {
None => true,
Some(os) if os == host_os => true,
Some(_) => self.has_active_paid_subscription().await,
Some(_) => self.can_use_cross_os_fingerprints().await,
}
}
@@ -1016,7 +1150,7 @@ impl CloudAuthManager {
return Ok(());
}
let token = self
let result = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
// Bound the request: without a timeout, an unreachable
@@ -1050,7 +1184,31 @@ impl CloudAuthManager {
Ok(result.token)
}
})
.await?;
.await;
let token = match result {
Ok(token) => token,
Err(e) => {
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
// token-reuse throttle, "active subscription required", and the
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
// This is distinct from a 401 (dead access token) — the session is still
// valid, the user is just temporarily/conditionally not entitled. So we
// do NOT invalidate the session. Instead: drop the stale wayfern token so
// no browser launches half-authenticated, re-fetch the profile so the
// cached plan reflects the backend's real state (it may have changed),
// and signal the UI so the user learns why automation stopped working.
if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403): {e}");
self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
}
let _ = crate::events::emit_empty("wayfern-paid-blocked");
}
return Err(e);
}
};
let mut wt = self.wayfern_token.lock().await;
*wt = Some(token);
@@ -1184,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
app_handle: tauri::AppHandle,
code: String,
) -> Result<CloudAuthState, String> {
let state = CLOUD_AUTH.exchange_device_code(&code).await?;
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
log::info!(
@@ -1219,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
let _ = crate::events::emit_empty("cloud-auth-changed");
let _ = &app_handle;
state.user.entitlements = Some(state.user.entitlements());
Ok(state)
}
#[tauri::command]
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
Ok(CLOUD_AUTH.get_user().await)
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
// Always hand the frontend a resolved entitlements object so it never has to
// derive capabilities itself (covers older cached state with no entitlements).
state.user.entitlements = Some(state.user.entitlements());
state
}))
}
#[tauri::command]
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
CLOUD_AUTH.fetch_profile().await
let mut user = CLOUD_AUTH.fetch_profile().await?;
user.entitlements = Some(user.entitlements());
Ok(user)
}
#[tauri::command]
+62 -6
View File
@@ -43,6 +43,7 @@ pub mod proxy_runner;
pub mod proxy_server;
pub mod proxy_storage;
mod settings_manager;
pub mod socks5_local;
pub mod sync;
mod synchronizer;
pub mod traffic_stats;
@@ -150,6 +151,8 @@ use api_server::{get_api_server_status, start_api_server, stop_api_server};
pub trait WindowExt {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
#[cfg(target_os = "macos")]
fn disable_native_fullscreen(&self) -> Result<(), String>;
}
impl<R: Runtime> WindowExt for WebviewWindow<R> {
@@ -164,7 +167,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
if transparent {
// Hide the title text
ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden
ns_window.setTitleVisibility(NSWindowTitleVisibility(1)); // NSWindowTitleHidden
// Make titlebar transparent
ns_window.setTitlebarAppearsTransparent(true);
@@ -189,6 +192,33 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
Ok(())
}
#[cfg(target_os = "macos")]
fn disable_native_fullscreen(&self) -> Result<(), String> {
use objc2::rc::Retained;
use objc2_app_kit::{NSWindow, NSWindowCollectionBehavior};
unsafe {
let ns_window: Retained<NSWindow> =
Retained::retain(self.ns_window().unwrap().cast()).unwrap();
// Make the green title-bar button (and titlebar double-click) "zoom"
// the window to fill the screen as an ordinary window instead of
// entering immersive native fullscreen that hides the menu bar and
// moves to its own Space. Mirrors Electron's `fullscreenable: false`:
// clear FullScreenPrimary and set FullScreenNone. AppKit then maps the
// green button to the standard zoom, expanding to the visible screen
// frame while keeping the window chrome and the current Space.
const FULL_SCREEN_PRIMARY: usize = 1 << 7;
const FULL_SCREEN_NONE: usize = 1 << 9;
let current = ns_window.collectionBehavior();
let updated =
NSWindowCollectionBehavior((current.0 & !FULL_SCREEN_PRIMARY) | FULL_SCREEN_NONE);
ns_window.setCollectionBehavior(updated);
}
Ok(())
}
}
// Called internally for deep-link / startup URL handling — not invoked from the
@@ -1272,13 +1302,18 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
.item(&quit_item)
.build()?;
// macOS uses a black template icon (the OS tints it for light/dark menu
// bars). Windows and Linux use the full-color icon, because neither tints a
// template — a black template would be invisible on dark Linux panels.
// macOS uses the black icon as a template the OS tints it for the light or
// dark menu bar. Linux (and other non-Windows desktops) get a white-bodied
// icon with a dark outline so it stays legible on both dark and light
// panels: Tauri feeds the SNI/AppIndicator a fixed pixmap with no template
// tinting, so the icon has to carry its own contrast (a solid black icon is
// invisible on GNOME's dark top bar). Windows keeps its own solid icon.
#[cfg(target_os = "macos")]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-linux-44.png");
let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8();
let (tray_w, tray_h) = tray_rgba.dimensions();
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
@@ -1388,6 +1423,21 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init())
// Persist window size/position across restarts. VISIBLE is excluded
// because the app hides to tray: restoring visibility would otherwise
// relaunch with an invisible window after quitting from the tray while
// hidden. FULLSCREEN is excluded because native fullscreen is disabled
// (the green button zooms instead) — the maximized flag captures the
// "filled screen" state, including green-button zoom on macOS.
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
)
.build(),
)
.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -1403,7 +1453,8 @@ pub fn run() {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(880.0, 500.0)
.resizable(false)
.min_inner_size(640.0, 400.0)
.resizable(true)
.fullscreen(false)
.center()
.focused(true)
@@ -1447,6 +1498,11 @@ pub fn run() {
if let Err(e) = window.set_transparent_titlebar(true) {
log::warn!("Failed to set transparent titlebar: {e}");
}
// Green title-bar button maximizes (zoom) the window rather than
// entering immersive native fullscreen.
if let Err(e) = window.disable_native_fullscreen() {
log::warn!("Failed to disable native fullscreen: {e}");
}
}
// Set up deep link handler
+131 -34
View File
@@ -152,11 +152,11 @@ impl McpServer {
self.is_running.load(Ordering::SeqCst)
}
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
// Log the failed gate so customer logs explain why an MCP tool returned
// an error. Include enough state (logged-in vs not, plan, status) for
// support to diagnose without leaking secrets.
/// Gate an MCP tool on a capability the caller already resolved (e.g.
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
/// with enough state for support to diagnose, without leaking secrets.
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
if !allowed {
let summary = match CLOUD_AUTH.get_user().await {
Some(state) => format!(
"logged_in=true plan={} status={} period={:?}",
@@ -164,10 +164,10 @@ impl McpServer {
),
None => "logged_in=false".to_string(),
};
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
return Err(McpError {
code: -32000,
message: format!("{feature} requires an active paid subscription"),
message: format!("{feature} requires a plan that includes this feature"),
});
}
Ok(())
@@ -286,6 +286,9 @@ impl McpServer {
.delete(Self::handle_mcp_delete),
)
.route("/health", get(Self::handle_health))
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
.layer(middleware::from_fn(Self::rate_limit_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
Self::auth_middleware,
@@ -316,6 +319,17 @@ impl McpServer {
}
}
/// Chokepoint for the future per-hour automation request limit, mirroring the
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
/// entitlements; this is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated tool calls per rolling hour
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
Ok(next.run(req).await)
}
async fn auth_middleware(
State(state): State<McpHttpState>,
req: Request<Body>,
@@ -339,8 +353,16 @@ impl McpServer {
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
let valid =
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
// Constant-time comparison to avoid leaking the token prefix via timing.
use subtle::ConstantTimeEq;
let expected = state.token.as_bytes();
let ct_eq = |t: Option<&str>| {
t.is_some_and(|t| {
let b = t.as_bytes();
b.len() == expected.len() && b.ct_eq(expected).into()
})
};
let valid = ct_eq(path_token) || ct_eq(header_token);
if !valid {
return Err(StatusCode::UNAUTHORIZED);
@@ -1639,10 +1661,21 @@ impl McpServer {
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_run_profile(arguments).await
}
"kill_profile" => self.handle_kill_profile(arguments).await,
"kill_profile" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_kill_profile(arguments).await
}
"create_profile" => self.handle_create_profile(arguments).await,
"update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_profile(arguments).await,
@@ -1671,13 +1704,16 @@ impl McpServer {
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management — viewing and editing both require a paid plan.
"get_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_get_profile_fingerprint(arguments).await
}
// Fingerprint management — viewing is free everywhere (matches the REST
// API and the get_profile tool, which already expose the config); only
// editing requires a paid plan.
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
Self::require_capability(
"Fingerprint editing",
CLOUD_AUTH.can_use_cross_os_fingerprints().await,
)
.await?;
self.handle_update_profile_fingerprint(arguments).await
}
"update_profile_proxy_bypass_rules" => {
@@ -1706,7 +1742,11 @@ impl McpServer {
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
// Synchronizer tools
"start_sync_session" => {
Self::require_paid_subscription("Synchronizer").await?;
Self::require_capability(
"Synchronizer",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_start_sync_session(arguments).await
}
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
@@ -1714,43 +1754,83 @@ impl McpServer {
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
// Browser interaction tools (require paid subscription)
"navigate" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_navigate(arguments).await
}
"screenshot" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_screenshot(arguments).await
}
"evaluate_javascript" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_evaluate_javascript(arguments).await
}
"click_element" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_click_element(arguments).await
}
"type_text" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_text(arguments).await
}
"get_page_content" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_page_content(arguments).await
}
"get_page_info" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_page_info(arguments).await
}
"get_interactive_elements" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_interactive_elements(arguments).await
}
"click_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_click_by_index(arguments).await
}
"type_by_index" => {
Self::require_paid_subscription("Browser automation").await?;
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_by_index(arguments).await
}
_ => Err(McpError {
@@ -1829,8 +1909,12 @@ impl McpServer {
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Launching profiles programmatically is a paid feature.
Self::require_paid_subscription("Launching a profile").await?;
// Launching profiles programmatically requires the automation capability.
Self::require_capability(
"Launching a profile",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_id = arguments
.get("profile_id")
@@ -1913,8 +1997,12 @@ impl McpServer {
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Stopping profiles programmatically is a paid feature.
Self::require_paid_subscription("Killing a profile").await?;
// Stopping profiles programmatically requires the automation capability.
Self::require_capability(
"Killing a profile",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_id = arguments
.get("profile_id")
@@ -2592,6 +2680,15 @@ impl McpServer {
message: "Missing proxy_type".to_string(),
})?;
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
// enforce it here so a bad value can't produce a non-functional proxy.
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
});
}
let host = arguments
.get("host")
.and_then(|v| v.as_str())
@@ -3243,10 +3340,10 @@ impl McpServer {
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
if !CLOUD_AUTH.can_use_cross_os_fingerprints().await {
return Err(McpError {
code: -32000,
message: "Fingerprint editing requires an active Pro subscription".to_string(),
message: "Fingerprint editing requires a plan that includes it".to_string(),
});
}
+38 -19
View File
@@ -3,6 +3,42 @@ use crate::profile::BrowserProfile;
use std::path::Path;
use std::process::Command;
/// True if a process command line refers to `profile_path` as a real browser
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
/// force-killed unrelated processes that happened to mention the path (editors,
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
///
/// Only the macOS and Linux process-kill paths use this; Windows has no
/// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
for (i, arg) in args.iter().enumerate() {
// Exact argument equality (Firefox/Camoufox: `-profile <path>`; some launchers
// pass the path as its own arg).
if *arg == profile_path {
return true;
}
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
if let Some(val) = arg
.strip_prefix("--user-data-dir=")
.or_else(|| arg.strip_prefix("-profile="))
{
if val == profile_path {
return true;
}
}
// Flag followed by the path as the next argument.
if (*arg == "-profile" || *arg == "--user-data-dir")
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
{
return true;
}
}
false
}
// Platform-specific modules
#[cfg(target_os = "macos")]
#[allow(dead_code)]
@@ -215,16 +251,7 @@ pub mod macos {
continue;
}
// Check if any command line argument contains the profile path
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
}
@@ -832,15 +859,7 @@ pub mod linux {
continue;
}
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
}
+18 -3
View File
@@ -1035,7 +1035,7 @@ impl ProfileManager {
fs::create_dir_all(&dest_dir)?;
}
let new_profile = BrowserProfile {
let mut new_profile = BrowserProfile {
id: new_id,
name: clone_name,
browser: source.browser,
@@ -1071,6 +1071,21 @@ impl ProfileManager {
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Donut: a clone must NOT be linkable to its source. The source
// wayfern_config embeds the persisted fingerprint JSON (including the
// canvas_noise_seed), so copying it verbatim makes the clone emit
// BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device
// signals as the source — trivially linkable if both run concurrently. Clear
// the fingerprint so the launch path mints a fresh one (a new
// canvas_noise_seed via RandBytes + an independent device fingerprint),
// exactly as create_profile does when fingerprint.is_none(). NOTE: the
// user-data-dir copy above still duplicates cookies/localStorage/TLS state —
// a separate storage-linkage vector the user must clear if they want full
// isolation between a clone and its source.
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
cfg.fingerprint = None;
}
self.save_profile(&new_profile)?;
if let Err(e) = events::emit_empty("profiles-changed") {
@@ -2501,7 +2516,7 @@ pub async fn update_camoufox_config(
) -> Result<(), String> {
if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_cross_os_fingerprints()
.await
{
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
@@ -2529,7 +2544,7 @@ pub async fn update_wayfern_config(
) -> Result<(), String> {
if config.fingerprint.is_some()
&& !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.can_use_cross_os_fingerprints()
.await
{
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
+18 -415
View File
@@ -2,7 +2,7 @@ use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use std::path::Path;
use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
}
fn map_browser_type(browser: &str) -> &str {
// Firefox-based sources map to the now-deprecated Camoufox. They are no longer
// detected for import; the mapping is kept only so the import command can
// recognize and REJECT them. Everything else maps to Wayfern.
match browser {
"firefox" | "firefox-developer" | "zen" => "camoufox",
"chromium" | "brave" => "wayfern",
"camoufox" => "camoufox",
"wayfern" => "wayfern",
"firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
_ => "wayfern",
}
}
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
profile_manager: &'static ProfileManager,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
@@ -44,7 +43,6 @@ impl ProfileImporter {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
profile_manager: ProfileManager::instance(),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
}
}
@@ -58,12 +56,12 @@ impl ProfileImporter {
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new();
detected_profiles.extend(self.detect_firefox_profiles()?);
// Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox,
// which is deprecated — they can no longer be imported. Only Chromium-based
// sources (mapping to Wayfern) are detected.
detected_profiles.extend(self.detect_chrome_profiles()?);
detected_profiles.extend(self.detect_brave_profiles()?);
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
detected_profiles.extend(self.detect_chromium_profiles()?);
detected_profiles.extend(self.detect_zen_browser_profiles()?);
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
@@ -74,80 +72,6 @@ impl ProfileImporter {
Ok(unique_profiles)
}
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let firefox_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
let local_app_data = self.base_dirs.data_local_dir();
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
if firefox_local_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
}
}
#[cfg(target_os = "linux")]
{
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
}
Ok(profiles)
}
fn detect_firefox_developer_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let firefox_dev_alt_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox Developer Edition/Profiles");
if firefox_dev_alt_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
}
}
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
}
#[cfg(target_os = "linux")]
{
let firefox_dev_dir = self
.base_dirs
.home_dir()
.join(".mozilla/firefox-dev-edition");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
}
}
Ok(profiles)
}
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -235,191 +159,6 @@ impl ProfileImporter {
Ok(profiles)
}
fn detect_zen_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let zen_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
let zen_dir = app_data.join("Zen/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
#[cfg(target_os = "linux")]
{
let zen_dir = self.base_dirs.home_dir().join(".zen");
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
}
Ok(profiles)
}
fn scan_firefox_profiles_dir(
&self,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
if !profiles_dir.exists() {
return Ok(profiles);
}
let profiles_ini = profiles_dir
.parent()
.unwrap_or(profiles_dir)
.join("profiles.ini");
if profiles_ini.exists() {
if let Ok(content) = fs::read_to_string(&profiles_ini) {
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
}
}
if let Ok(entries) = fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let prefs_file = path.join("prefs.js");
if prefs_file.exists() {
let profile_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown Profile");
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
if !already_added {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} Profile - {}",
self.get_browser_display_name(browser_type),
profile_name
),
path: path.to_string_lossy().to_string(),
description: format!("Profile folder: {profile_name}"),
});
}
}
}
}
}
Ok(profiles)
}
fn parse_firefox_profiles_ini(
&self,
content: &str,
profiles_dir: &Path,
browser_type: &str,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
let mut current_section = String::new();
let mut profile_name = String::new();
let mut profile_path = String::new();
let mut is_relative = true;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
current_section = line[1..line.len() - 1].to_string();
profile_name.clear();
profile_path.clear();
is_relative = true;
} else if line.contains('=') {
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() == 2 {
let key = parts[0].trim();
let value = parts[1].trim();
match key {
"Name" => profile_name = value.to_string(),
"Path" => profile_path = value.to_string(),
"IsRelative" => is_relative = value == "1",
_ => {}
}
}
}
}
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
{
let full_path = if is_relative {
profiles_dir.join(&profile_path)
} else {
PathBuf::from(&profile_path)
};
if full_path.exists() {
let display_name = if profile_name.is_empty() {
format!("{} Profile", self.get_browser_display_name(browser_type))
} else {
format!(
"{} - {}",
self.get_browser_display_name(browser_type),
profile_name
)
};
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
});
}
}
Ok(profiles)
}
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
@@ -493,7 +232,7 @@ impl ProfileImporter {
browser_type: &str,
new_profile_name: &str,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
_camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
let source_path = Path::new(source_path);
@@ -529,88 +268,9 @@ impl ProfileImporter {
let version = self.get_default_version_for_browser(mapped)?;
let final_camoufox_config = if mapped == "camoufox" {
let mut config = camoufox_config.unwrap_or_default();
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
launch_hook: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
.camoufox_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
// Camoufox import is removed; only Wayfern profiles are imported now, so the
// imported profile never carries a Camoufox config.
let final_camoufox_config: Option<CamoufoxConfig> = None;
let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default();
@@ -806,6 +466,12 @@ pub async fn import_browser_profile(
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), String> {
// Camoufox is deprecated — Firefox-based profiles (which map to Camoufox) can
// no longer be imported. Reject them before doing any work.
if map_browser_type(&browser_type) == "camoufox" {
return Err(serde_json::json!({ "code": "CAMOUFOX_IMPORT_DEPRECATED" }).to_string());
}
let fingerprint_os = camoufox_config
.as_ref()
.and_then(|c| c.os.as_deref())
@@ -897,24 +563,6 @@ mod tests {
let _profiles = result.unwrap();
}
#[test]
fn test_scan_firefox_profiles_dir_nonexistent() {
let (importer, temp_dir) = create_test_profile_importer();
let nonexistent_dir = temp_dir.path().join("nonexistent");
let result = importer.scan_firefox_profiles_dir(&nonexistent_dir, "firefox");
assert!(
result.is_ok(),
"Should handle nonexistent directory gracefully"
);
let profiles = result.unwrap();
assert!(
profiles.is_empty(),
"Should return empty vector for nonexistent directory"
);
}
#[test]
fn test_scan_chrome_profiles_dir_nonexistent() {
let (importer, temp_dir) = create_test_profile_importer();
@@ -933,51 +581,6 @@ mod tests {
);
}
#[test]
fn test_parse_firefox_profiles_ini_empty() {
let (importer, _temp_dir) = create_test_profile_importer();
let empty_content = "";
let profiles_dir = Path::new("/tmp");
let result = importer.parse_firefox_profiles_ini(empty_content, profiles_dir, "firefox");
assert!(result.is_ok(), "Should handle empty profiles.ini");
let profiles = result.unwrap();
assert!(
profiles.is_empty(),
"Should return empty vector for empty content"
);
}
#[test]
fn test_parse_firefox_profiles_ini_valid() {
let (importer, temp_dir) = create_test_profile_importer();
let profiles_dir = temp_dir.path().join("profiles");
let profile_dir = profiles_dir.join("test.profile");
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
let prefs_file = profile_dir.join("prefs.js");
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
let profiles_ini_content = r#"
[Profile0]
Name=Test Profile
IsRelative=1
Path=test.profile
"#;
let result =
importer.parse_firefox_profiles_ini(profiles_ini_content, &profiles_dir, "firefox");
assert!(result.is_ok(), "Should parse valid profiles.ini");
let profiles = result.unwrap();
assert_eq!(profiles.len(), 1, "Should find one profile");
assert_eq!(profiles[0].name, "Firefox - Test Profile");
assert_eq!(profiles[0].browser, "firefox");
assert_eq!(profiles[0].mapped_browser, "camoufox");
}
#[test]
fn test_copy_directory_recursive() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
+48 -14
View File
@@ -774,6 +774,17 @@ impl ProxyManager {
list
}
/// Insert/replace a stored proxy in the in-memory map. Used by sync's
/// download_proxy after it writes the file to disk, mirroring how
/// download_group/download_vpn/download_extension keep their managers'
/// in-memory state in sync. Without this, get_stored_proxies (which reads
/// only the map) never sees a downloaded proxy until restart, so sync keeps
/// re-downloading it indefinitely.
pub fn upsert_stored_proxy(&self, proxy: StoredProxy) {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(proxy.id.clone(), proxy);
}
// Get a stored proxy by ID
// Update a stored proxy
@@ -1467,6 +1478,7 @@ impl ProxyManager {
// Start a proxy for given proxy settings and associate it with a browser process ID
// If proxy_settings is None, starts a direct proxy for traffic monitoring
#[allow(clippy::too_many_arguments)]
pub async fn start_proxy(
&self,
app_handle: tauri::AppHandle,
@@ -1475,6 +1487,10 @@ impl ProxyManager {
profile_id: Option<&str>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
// Protocol the local worker serves the browser: "http" (Camoufox) or
// "socks5" (Wayfern). Reflected in the returned ProxySettings.proxy_type
// so the caller formats the right local proxy URL scheme.
local_protocol: &str,
) -> Result<ProxySettings, String> {
if let Some(name) = profile_id {
// Check if we have an active proxy recorded for this profile
@@ -1508,7 +1524,7 @@ impl ProxyManager {
if proxies.contains_key(&browser_pid) {
// Already mapped, reuse it
return Ok(ProxySettings {
proxy_type: "http".to_string(),
proxy_type: local_protocol.to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
@@ -1548,7 +1564,7 @@ impl ProxyManager {
if profile_id_matches {
// Reuse existing local proxy (settings and profile_id match)
return Ok(ProxySettings {
proxy_type: "http".to_string(),
proxy_type: local_protocol.to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
@@ -1607,6 +1623,9 @@ impl ProxyManager {
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
}
// Tell the worker which protocol to serve the browser (http or socks5)
proxy_cmd = proxy_cmd.arg("--local-protocol").arg(local_protocol);
// Execute the command and wait for it to complete
// The donut-proxy binary should start the worker and then exit
let output = proxy_cmd
@@ -1698,7 +1717,7 @@ impl ProxyManager {
// Return proxy settings for the browser
Ok(ProxySettings {
proxy_type: "http".to_string(),
proxy_type: local_protocol.to_string(),
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
port: proxy_info.local_port,
username: None,
@@ -1730,12 +1749,18 @@ impl ProxyManager {
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
// We still return Ok since we've already removed the proxy from our tracking
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
// not panic the cleanup task — the proxy is already removed from tracking,
// so degrade gracefully like the non-success branch below.
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
}
// Clear profile-to-proxy mapping if it references this proxy
@@ -1795,11 +1820,16 @@ impl ProxyManager {
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
// Don't panic if the sidecar can't be spawned — still clear the mapping.
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
}
// Clear profile-to-proxy mapping
@@ -2863,6 +2893,7 @@ mod tests {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
local_protocol: None,
};
let dead_config = ProxyConfig {
id: dead_id.clone(),
@@ -2874,6 +2905,7 @@ mod tests {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
local_protocol: None,
};
save_proxy_config(&live_config).unwrap();
@@ -2913,6 +2945,7 @@ mod tests {
profile_id: Some("prof_abc".to_string()),
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
blocklist_file: None,
local_protocol: None,
};
// Save
@@ -3231,6 +3264,7 @@ mod tests {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
local_protocol: None,
};
save_proxy_config(&config).unwrap();
+4 -2
View File
@@ -160,7 +160,7 @@ pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
}
pub async fn start_proxy_process_with_profile(
@@ -169,6 +169,7 @@ pub async fn start_proxy_process_with_profile(
profile_id: Option<String>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
local_protocol: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -183,7 +184,8 @@ pub async fn start_proxy_process_with_profile(
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
.with_profile_id(profile_id.clone())
.with_bypass_rules(bypass_rules)
.with_blocklist_file(blocklist_file);
.with_blocklist_file(blocklist_file)
.with_local_protocol(local_protocol);
save_proxy_config(&config)?;
// Log profile_id for debugging
+93 -67
View File
@@ -21,9 +21,9 @@ use tokio::net::TcpStream;
/// Combined read+write trait for tunnel target streams, allowing
/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and
/// Shadowsocks through the same bidirectional-copy path.
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
pub(crate) trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncStream for T {}
type BoxedAsyncStream = Box<dyn AsyncStream>;
pub(crate) type BoxedAsyncStream = Box<dyn AsyncStream>;
use url::Url;
enum CompiledRule {
@@ -509,47 +509,20 @@ async fn handle_http_via_socks4(
}
};
// Resolve target host to IP (SOCKS4 requires IP addresses)
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
match addr.ip() {
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
std::net::IpAddr::V6(_) => {
log::error!("SOCKS4 does not support IPv6");
let mut response = Response::new(Full::new(Bytes::from(
"SOCKS4 does not support IPv6 addresses",
)));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
} else {
log::error!("Failed to resolve target host: {}", target_host);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
target_host
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
Err(e) => {
log::error!("Failed to resolve target host {}: {}", target_host, e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
};
// Build SOCKS4 CONNECT request
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
// hostname locally: tokio::net::lookup_host would call the HOST resolver
// (getaddrinfo), leaking the destination domain to the host's DNS server and
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
// HTTP — prefer SOCKS5 there.)
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
socks_request.extend_from_slice(&target_port.to_be_bytes());
socks_request.extend_from_slice(&target_ip);
socks_request.push(0); // NULL terminator for userid
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
socks_request.push(0); // empty userid, NULL-terminated
socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve
socks_request.push(0); // NULL-terminated hostname
// Send SOCKS4 CONNECT request
if let Err(e) = socks_stream.write_all(&socks_request).await {
@@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy(
Proxy::http(upstream_url)?
}
"socks5" => {
// For SOCKS5, reqwest supports it directly
Proxy::all(upstream_url)?
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
// which resolves the destination hostname on the HOST (getaddrinfo) BEFORE
// connecting — leaking the destination domain to the host's DNS resolver
// and defeating the per-profile proxy. The `socks5h` scheme maps to
// DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks.
// (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's
// AddrKind::Domain.)
let remote_dns_url = match upstream_url.strip_prefix("socks5://") {
Some(rest) => format!("socks5h://{rest}"),
None => upstream_url.to_string(),
};
Proxy::all(remote_dns_url)?
}
"socks4" => {
// SOCKS4 is handled manually in handle_http_via_socks4
@@ -1263,10 +1247,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
log::info!("Successfully bound to port {}", actual_port);
// Update config with actual port and local_url
// Protocol served to the browser: "socks5" (Wayfern) or "http" (default).
let local_protocol = config.local_protocol_or_default();
let serve_socks5 = local_protocol == "socks5";
// Update config with actual port and local_url (scheme matches the protocol
// we serve, so the parent's readiness check and any consumer see the truth)
let mut updated_config = config.clone();
updated_config.local_port = Some(actual_port);
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
updated_config.local_url = Some(format!(
"{}://127.0.0.1:{}",
if serve_socks5 { "socks5" } else { "http" },
actual_port
));
if !crate::proxy_storage::update_proxy_config(&updated_config) {
log::error!("Failed to update proxy config");
@@ -1387,9 +1380,15 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
let upstream = upstream_url.clone();
let matcher = bypass_matcher.clone();
let blocker = blocklist_matcher.clone();
tokio::task::spawn(async move {
handle_proxy_connection(stream, upstream, matcher, blocker).await;
});
if serve_socks5 {
tokio::task::spawn(async move {
crate::socks5_local::handle_socks5_connection(stream, upstream, matcher, blocker).await;
});
} else {
tokio::task::spawn(async move {
handle_proxy_connection(stream, upstream, matcher, blocker).await;
});
}
}
Err(e) => {
log::error!("Error accepting connection: {:?}", e);
@@ -1460,20 +1459,51 @@ async fn handle_connect_from_buffer(
);
// Connect to target (directly or via upstream proxy).
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
// Shadowsocks) share the same bidirectional-copy tunnel code below.
let target_stream = connect_to_target_via_upstream(
target_host,
target_port,
upstream_url.as_deref(),
&bypass_matcher,
)
.await?;
// Send 200 Connection Established response to client
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
client_stream
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await?;
client_stream.flush().await?;
log::trace!("Sent 200 Connection Established response, starting tunnel");
tunnel_streams(client_stream, target_stream, domain).await;
Ok(())
}
/// Establish a stream to `target_host:target_port`, either directly or through
/// the configured upstream proxy. Shared by the HTTP CONNECT path and the
/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT,
/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a
/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly.
pub(crate) async fn connect_to_target_via_upstream(
target_host: &str,
target_port: u16,
upstream_url: Option<&str>,
bypass_matcher: &BypassMatcher,
) -> Result<BoxedAsyncStream, Box<dyn std::error::Error>> {
let should_bypass = bypass_matcher.should_bypass(target_host);
// Helper: configure outbound TCP to match browser TCP fingerprint
let configure_tcp = |stream: &TcpStream| {
let _ = stream.set_nodelay(true);
};
let target_stream: BoxedAsyncStream = match upstream_url.as_ref() {
let target_stream: BoxedAsyncStream = match upstream_url {
None => {
let s = TcpStream::connect((target_host, target_port)).await?;
configure_tcp(&s);
Box::new(s)
}
Some(url) if url == "DIRECT" => {
Some("DIRECT") => {
let s = TcpStream::connect((target_host, target_port)).await?;
configure_tcp(&s);
Box::new(s)
@@ -1632,20 +1662,18 @@ async fn handle_connect_from_buffer(
}
};
// TCP_NODELAY is set per-stream where applicable (TcpStream paths).
// For encrypted streams (Shadowsocks), the underlying TCP connection
// is managed by the library and nodelay is handled internally.
Ok(target_stream)
}
// Send 200 Connection Established response to client
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
client_stream
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await?;
client_stream.flush().await?;
log::trace!("Sent 200 Connection Established response, starting tunnel");
// Now tunnel data bidirectionally with counting
/// Bidirectionally relay `client_stream` <-> `target_stream` until either side
/// closes, counting bytes for traffic stats and attributing them to `domain`.
/// The caller is responsible for having already sent any protocol-specific
/// success reply (HTTP `200` or SOCKS5 reply) before calling this.
pub(crate) async fn tunnel_streams(
client_stream: TcpStream,
target_stream: BoxedAsyncStream,
domain: String,
) {
// Wrap streams to count bytes transferred
let counting_client = CountingStream::new(client_stream);
let counting_target = CountingStream::new(target_stream);
@@ -1708,8 +1736,6 @@ async fn handle_connect_from_buffer(
if let Some(tracker) = get_traffic_tracker() {
tracker.update_domain_bytes(&domain, final_sent, final_recv);
}
Ok(())
}
#[cfg(test)]
+21
View File
@@ -16,6 +16,12 @@ pub struct ProxyConfig {
pub bypass_rules: Vec<String>,
#[serde(default)]
pub blocklist_file: Option<String>,
/// Protocol the local worker serves to the browser: "http" (default, used
/// by Camoufox/Firefox) or "socks5" (used by Wayfern/Chromium so QUIC and
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
#[serde(default)]
pub local_protocol: Option<String>,
}
impl ProxyConfig {
@@ -30,6 +36,7 @@ impl ProxyConfig {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
local_protocol: None,
}
}
@@ -47,6 +54,20 @@ impl ProxyConfig {
self.blocklist_file = blocklist_file;
self
}
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
self.local_protocol = local_protocol;
self
}
/// "socks5" or "http" (default). Lowercased for case-insensitive matching.
pub fn local_protocol_or_default(&self) -> String {
self
.local_protocol
.as_deref()
.unwrap_or("http")
.to_lowercase()
}
}
pub fn get_storage_dir() -> PathBuf {
+639
View File
@@ -0,0 +1,639 @@
//! Local SOCKS5 server served to the browser (Wayfern/Chromium).
//!
//! The HTTP front-end (`proxy_server::handle_proxy_connection`) can only tunnel
//! TCP, so QUIC and WebRTC — which are UDP — would be forced direct and leak the
//! real IP. Serving SOCKS5 instead lets Chromium proxy UDP via SOCKS5 UDP
//! ASSOCIATE (RFC 1928). TCP CONNECT reuses the exact same upstream-dial and
//! tunnel code as the HTTP path, so every upstream type (direct, HTTP/HTTPS
//! CONNECT, SOCKS4/5, Shadowsocks) behaves identically.
//!
//! UDP ASSOCIATE is leak-safe by construction: UDP is only relayed where it
//! cannot expose the host IP — directly when there is no upstream proxy, or
//! tunneled through a UDP-capable SOCKS5 upstream. For upstreams that cannot
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
//! the association) the request is refused, so Chromium falls back to proxied
//! TCP rather than sending UDP from the real IP.
use crate::proxy_server::{
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
};
use crate::traffic_stats::get_traffic_tracker;
use async_socks5::{AddrKind, Auth, SocksDatagram};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};
use url::Url;
// SOCKS5 reply codes (RFC 1928 §6).
const REP_SUCCEEDED: u8 = 0x00;
const REP_GENERAL_FAILURE: u8 = 0x01;
const REP_NOT_ALLOWED: u8 = 0x02;
const REP_COMMAND_NOT_SUPPORTED: u8 = 0x07;
// SOCKS5 commands (RFC 1928 §4).
const CMD_CONNECT: u8 = 0x01;
const CMD_UDP_ASSOCIATE: u8 = 0x03;
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
const UDP_BUF: usize = 65_536;
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
/// IP never leaks.
#[derive(Debug, PartialEq, Eq)]
enum UdpMode {
/// No upstream proxy: relay UDP directly (the host IP is the profile's IP,
/// so there is nothing to hide).
Direct,
/// SOCKS5 upstream: attempt SOCKS5 UDP ASSOCIATE against it. Tunnels UDP if
/// the upstream grants it; refuses (no leak) if it does not.
Socks5Upstream,
/// Upstream that cannot carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks): refuse so
/// Chromium falls back to proxied TCP instead of leaking UDP.
Refuse,
}
/// Decide the leak-safe UDP policy for an upstream URL.
fn udp_mode(upstream_url: Option<&str>) -> UdpMode {
match upstream_url {
None => UdpMode::Direct,
Some("DIRECT") => UdpMode::Direct,
Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) {
Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream,
// http / https / socks4 / ss / shadowsocks / anything else: TCP-only.
_ => UdpMode::Refuse,
},
}
}
/// `0.0.0.0:0` — used for BND fields in replies where the bound address is
/// irrelevant to the client (e.g. CONNECT).
fn unspecified() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
}
/// Handle one SOCKS5 client connection from the browser. Mirrors the spawn
/// contract of `proxy_server::handle_proxy_connection`.
pub async fn handle_socks5_connection(
mut stream: TcpStream,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
blocklist_matcher: BlocklistMatcher,
) {
let _ = stream.set_nodelay(true);
if let Err(e) = negotiate_method(&mut stream).await {
log::debug!("SOCKS5 method negotiation failed: {e}");
return;
}
let request = match read_request(&mut stream).await {
Ok(r) => r,
Err(e) => {
log::debug!("SOCKS5 request parse failed: {e}");
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
};
match request.cmd {
CMD_CONNECT => {
handle_connect(
stream,
request.host,
request.port,
upstream_url,
bypass_matcher,
blocklist_matcher,
)
.await;
}
CMD_UDP_ASSOCIATE => {
handle_udp_associate(stream, upstream_url).await;
}
other => {
log::debug!("SOCKS5 unsupported command {other:#04x}");
let _ = send_reply(&mut stream, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
}
}
}
/// Read the SOCKS5 greeting and select the no-auth method. The local proxy is
/// loopback-only, so no authentication is required (Chromium offers no-auth).
async fn negotiate_method(stream: &mut TcpStream) -> std::io::Result<()> {
let mut head = [0u8; 2];
stream.read_exact(&mut head).await?;
if head[0] != 0x05 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"not a SOCKS5 greeting",
));
}
let nmethods = head[1] as usize;
let mut methods = vec![0u8; nmethods];
stream.read_exact(&mut methods).await?;
if methods.contains(&0x00) {
stream.write_all(&[0x05, 0x00]).await?;
Ok(())
} else {
// No acceptable methods.
let _ = stream.write_all(&[0x05, 0xFF]).await;
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"no no-auth method offered",
))
}
}
struct Socks5Request {
cmd: u8,
host: String,
port: u16,
}
/// Read a SOCKS5 request line: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT.
async fn read_request(stream: &mut TcpStream) -> std::io::Result<Socks5Request> {
let mut head = [0u8; 4];
stream.read_exact(&mut head).await?;
if head[0] != 0x05 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"bad SOCKS5 request version",
));
}
let cmd = head[1];
let atyp = head[3];
let host = read_addr(stream, atyp).await?;
let mut port = [0u8; 2];
stream.read_exact(&mut port).await?;
Ok(Socks5Request {
cmd,
host,
port: u16::from_be_bytes(port),
})
}
/// Read a SOCKS5 address of the given type into a host string (an IP literal or
/// a domain name; `connect_to_target_via_upstream` handles both).
async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result<String> {
match atyp {
0x01 => {
let mut b = [0u8; 4];
stream.read_exact(&mut b).await?;
Ok(Ipv4Addr::new(b[0], b[1], b[2], b[3]).to_string())
}
0x04 => {
let mut b = [0u8; 16];
stream.read_exact(&mut b).await?;
Ok(Ipv6Addr::from(b).to_string())
}
0x03 => {
let mut len = [0u8; 1];
stream.read_exact(&mut len).await?;
let mut domain = vec![0u8; len[0] as usize];
stream.read_exact(&mut domain).await?;
Ok(String::from_utf8_lossy(&domain).to_string())
}
other => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported SOCKS5 address type {other:#04x}"),
)),
}
}
/// Write a SOCKS5 reply with the given code and bound address.
async fn send_reply(stream: &mut TcpStream, rep: u8, bnd: SocketAddr) -> std::io::Result<()> {
let mut resp = vec![0x05, rep, 0x00];
push_addr(&mut resp, bnd);
stream.write_all(&resp).await
}
/// Append an ATYP + address + port to a SOCKS5 message buffer.
fn push_addr(buf: &mut Vec<u8>, addr: SocketAddr) {
match addr.ip() {
IpAddr::V4(v4) => {
buf.push(0x01);
buf.extend_from_slice(&v4.octets());
}
IpAddr::V6(v6) => {
buf.push(0x04);
buf.extend_from_slice(&v6.octets());
}
}
buf.extend_from_slice(&addr.port().to_be_bytes());
}
/// SOCKS5 CONNECT: dial the target via the upstream and bidirectionally tunnel,
/// reusing the same code path as the HTTP CONNECT proxy.
async fn handle_connect(
mut stream: TcpStream,
host: String,
port: u16,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
blocklist_matcher: BlocklistMatcher,
) {
if blocklist_matcher.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 CONNECT to {host}");
let _ = send_reply(&mut stream, REP_NOT_ALLOWED, unspecified()).await;
return;
}
if let Some(tracker) = get_traffic_tracker() {
tracker.record_request(&host, 0, 0);
}
log::info!(
"SOCKS5 CONNECT {}:{} (upstream={})",
host,
port,
upstream_url.as_deref().unwrap_or("DIRECT")
);
// Resolve to the target stream, logging and dropping the (non-Send) dial
// error inside the match arm so it is never held across the await below.
let target =
match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher)
.await
{
Ok(t) => Some(t),
Err(e) => {
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
None
}
};
let Some(target) = target else {
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
return;
};
if send_reply(&mut stream, REP_SUCCEEDED, unspecified())
.await
.is_err()
{
return;
}
tunnel_streams(stream, target, host).await;
}
/// SOCKS5 UDP ASSOCIATE, leak-safe per upstream (see [`UdpMode`]).
///
/// `control` is the TCP control connection; the UDP association lives exactly
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
/// the browser closes it.
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) {
let mode = udp_mode(upstream_url.as_deref());
if mode == UdpMode::Refuse {
log::info!(
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
upstream_url.as_deref().unwrap_or("DIRECT")
);
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
return;
}
// The UDP relay socket the browser sends its datagrams to. Loopback-only.
let relay = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to bind UDP relay socket: {e}");
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
};
let relay_addr = match relay.local_addr() {
Ok(a) => a,
Err(e) => {
log::warn!("Failed to read UDP relay addr: {e}");
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
};
match mode {
UdpMode::Direct => {
// Bind the egress socket before replying so a failure surfaces as a
// refusal (no half-open association).
let out = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to bind UDP egress socket: {e}");
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
};
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
.await
.is_err()
{
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
run_udp_relay_direct(control, relay, out).await;
}
UdpMode::Socks5Upstream => {
// Establish the upstream association FIRST; if the upstream refuses UDP,
// refuse to the browser too (no leak).
let upstream = upstream_url.as_deref().unwrap_or("");
let datagram = match associate_upstream(upstream).await {
Ok(d) => d,
Err(e) => {
log::info!(
"SOCKS5 upstream did not grant UDP ASSOCIATE ({e}); refusing so Chromium uses proxied TCP"
);
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
return;
}
};
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
.await
.is_err()
{
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
run_udp_relay_socks5(control, relay, datagram).await;
}
UdpMode::Refuse => unreachable!("handled above"),
}
}
/// Open a SOCKS5 UDP association against the upstream proxy.
async fn associate_upstream(
upstream_url: &str,
) -> Result<SocksDatagram<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
let upstream = Url::parse(upstream_url)?;
let host = upstream.host_str().unwrap_or("127.0.0.1");
let port = upstream.port().unwrap_or(1080);
let auth = if !upstream.username().is_empty() {
Some(Auth {
username: upstream.username().to_string(),
password: upstream.password().unwrap_or("").to_string(),
})
} else {
None
};
let proxy_stream = TcpStream::connect((host, port)).await?;
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?;
Ok(datagram)
}
/// Parsed SOCKS5 UDP datagram header (RFC 1928 §7): the destination and the
/// offset at which the payload begins. Fragmented datagrams (FRAG != 0) are
/// rejected by the caller.
struct UdpHeader {
frag: u8,
dst: AddrKind,
data_offset: usize,
}
fn parse_udp_header(buf: &[u8]) -> Option<UdpHeader> {
if buf.len() < 4 {
return None;
}
let frag = buf[2];
let atyp = buf[3];
match atyp {
0x01 => {
if buf.len() < 10 {
return None;
}
let ip = Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
Some(UdpHeader {
frag,
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V4(ip), port)),
data_offset: 10,
})
}
0x04 => {
if buf.len() < 22 {
return None;
}
let mut octets = [0u8; 16];
octets.copy_from_slice(&buf[4..20]);
let ip = Ipv6Addr::from(octets);
let port = u16::from_be_bytes([buf[20], buf[21]]);
Some(UdpHeader {
frag,
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V6(ip), port)),
data_offset: 22,
})
}
0x03 => {
let dlen = *buf.get(4)? as usize;
let needed = 5 + dlen + 2;
if buf.len() < needed {
return None;
}
let domain = String::from_utf8_lossy(&buf[5..5 + dlen]).to_string();
let port = u16::from_be_bytes([buf[5 + dlen], buf[6 + dlen]]);
Some(UdpHeader {
frag,
dst: AddrKind::Domain(domain, port),
data_offset: needed,
})
}
_ => None,
}
}
/// Build a SOCKS5 UDP response datagram (header + payload) to send back to the
/// browser, naming `peer` as the source.
fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
push_addr(&mut out, peer);
out.extend_from_slice(data);
out
}
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
/// there is no upstream proxy, so the host IP is the profile's own IP.
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) {
let mut client_addr: Option<SocketAddr> = None;
let mut from_client = vec![0u8; UDP_BUF];
let mut from_target = vec![0u8; UDP_BUF];
let mut ctrl_buf = [0u8; 256];
loop {
tokio::select! {
// Control connection closed => association ends.
r = control.read(&mut ctrl_buf) => {
match r {
Ok(0) | Err(_) => break,
Ok(_) => {} // ignore any data on the control channel
}
}
// Browser -> target.
r = relay.recv_from(&mut from_client) => {
let Ok((n, src)) = r else { break };
client_addr = Some(src);
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
if header.frag != 0 {
continue; // fragmentation unsupported
}
let payload = &from_client[header.data_offset..n];
let dst = match resolve_addr(&header.dst).await {
Some(d) => d,
None => continue,
};
let _ = out.send_to(payload, dst).await;
}
// Target -> browser.
r = out.recv_from(&mut from_target) => {
let Ok((n, peer)) = r else { continue };
if let Some(client) = client_addr {
let resp = build_udp_response(peer, &from_target[..n]);
let _ = relay.send_to(&resp, client).await;
}
}
}
}
}
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
async fn run_udp_relay_socks5(
mut control: TcpStream,
relay: UdpSocket,
datagram: SocksDatagram<TcpStream>,
) {
let mut client_addr: Option<SocketAddr> = None;
let mut from_client = vec![0u8; UDP_BUF];
let mut from_upstream = vec![0u8; UDP_BUF];
let mut ctrl_buf = [0u8; 256];
loop {
tokio::select! {
r = control.read(&mut ctrl_buf) => {
match r {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
// Browser -> upstream.
r = relay.recv_from(&mut from_client) => {
let Ok((n, src)) = r else { break };
client_addr = Some(src);
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
if header.frag != 0 {
continue;
}
let payload = from_client[header.data_offset..n].to_vec();
let _ = datagram.send_to(&payload, header.dst).await;
}
// Upstream -> browser.
r = datagram.recv_from(&mut from_upstream) => {
let Ok((n, peer)) = r else { continue };
if let Some(client) = client_addr {
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
let _ = relay.send_to(&resp, client).await;
}
}
}
}
}
/// Resolve a UDP destination to a concrete socket address for direct relay.
async fn resolve_addr(addr: &AddrKind) -> Option<SocketAddr> {
match addr {
AddrKind::Ip(s) => Some(*s),
AddrKind::Domain(domain, port) => tokio::net::lookup_host(format!("{domain}:{port}"))
.await
.ok()
.and_then(|mut it| it.next()),
}
}
/// Best-effort conversion of an upstream-reported source address into a
/// `SocketAddr` for the response header. A domain (rare for UDP) collapses to
/// `0.0.0.0:port`, which clients treat as "from the proxy".
fn addrkind_to_socketaddr(addr: &AddrKind) -> SocketAddr {
match addr {
AddrKind::Ip(s) => *s,
AddrKind::Domain(_, port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), *port),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn udp_mode_direct_for_none_and_direct() {
assert_eq!(udp_mode(None), UdpMode::Direct);
assert_eq!(udp_mode(Some("DIRECT")), UdpMode::Direct);
}
#[test]
fn udp_mode_socks5_upstream() {
assert_eq!(
udp_mode(Some("socks5://user:pass@1.2.3.4:1080")),
UdpMode::Socks5Upstream
);
assert_eq!(
udp_mode(Some("socks5://1.2.3.4:1080")),
UdpMode::Socks5Upstream
);
}
#[test]
fn udp_mode_refuses_tcp_only_upstreams() {
// HTTP/HTTPS CONNECT, SOCKS4, and Shadowsocks cannot carry UDP, so UDP
// ASSOCIATE must be refused (Chromium then uses proxied TCP — no leak).
assert_eq!(udp_mode(Some("http://1.2.3.4:8080")), UdpMode::Refuse);
assert_eq!(udp_mode(Some("https://1.2.3.4:8080")), UdpMode::Refuse);
assert_eq!(udp_mode(Some("socks4://1.2.3.4:1080")), UdpMode::Refuse);
assert_eq!(
udp_mode(Some("ss://aes-256-gcm:pw@1.2.3.4:8388")),
UdpMode::Refuse
);
}
#[test]
fn parse_udp_header_ipv4() {
// RSV RSV FRAG ATYP=1 1.2.3.4 :443 payload="hi"
let buf = [0, 0, 0, 0x01, 1, 2, 3, 4, 0x01, 0xBB, b'h', b'i'];
let h = parse_udp_header(&buf).expect("ipv4 header");
assert_eq!(h.frag, 0);
assert_eq!(h.data_offset, 10);
assert_eq!(
h.dst,
AddrKind::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443))
);
assert_eq!(&buf[h.data_offset..], b"hi");
}
#[test]
fn parse_udp_header_domain() {
// ATYP=3, len=3, "abc", port 8080, payload "x"
let mut buf = vec![0, 0, 0, 0x03, 3, b'a', b'b', b'c', 0x1F, 0x90];
buf.push(b'x');
let h = parse_udp_header(&buf).expect("domain header");
assert_eq!(h.dst, AddrKind::Domain("abc".to_string(), 8080));
assert_eq!(&buf[h.data_offset..], b"x");
}
#[test]
fn parse_udp_header_rejects_truncated() {
assert!(parse_udp_header(&[0, 0, 0]).is_none());
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
}
#[test]
fn build_udp_response_prefixes_header() {
let resp = build_udp_response(
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
b"data",
);
// RSV RSV FRAG ATYP=1 9.9.9.9 :53 "data"
assert_eq!(
resp,
vec![0, 0, 0, 0x01, 9, 9, 9, 9, 0x00, 0x35, b'd', b'a', b't', b'a']
);
}
}
+11 -1
View File
@@ -294,7 +294,10 @@ impl SyncProgressTracker {
/// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool {
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
// Cloud backup is a plan capability. Every paid plan (incl. the future
// "starter" tier) grants it, but gating on the capability — not just "is paid"
// — keeps this correct if a plan without cloud backup is ever added.
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
return true;
}
let manager = SettingsManager::instance();
@@ -1597,6 +1600,13 @@ impl SyncEngine {
))
})?;
// Keep the in-memory cache in sync with disk. Without this, get_stored_proxies
// (which reads only the in-memory map) never sees the downloaded proxy until
// restart, so check_for_missing_synced_entities/sync_proxy treat it as
// missing every pass and re-download it forever. Mirrors download_group/
// download_vpn/download_extension.
proxy_manager.upsert_stored_proxy(proxy.clone());
// Emit event for UI update
if let Some(_handle) = app_handle {
let _ = events::emit("stored-proxies-changed", ());
+201 -3
View File
@@ -4,8 +4,9 @@ use boringtun::x25519::{PublicKey, StaticSecret};
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
use smoltcp::socket::udp;
use smoltcp::time::Instant as SmolInstant;
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address};
use std::collections::VecDeque;
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use std::sync::{Arc, Mutex};
@@ -13,6 +14,58 @@ use tokio::net::{TcpListener, TcpStream};
const SMOLTCP_TCP_RX_BUF: usize = 65536;
const SMOLTCP_TCP_TX_BUF: usize = 65536;
const SMOLTCP_UDP_BUF: usize = 65536;
/// Parse an RFC 1928 §7 UDP request header. Returns the destination endpoint
/// and the payload offset, or None if malformed, fragmented, or domain-typed.
/// Only literal IPs are routed through the tunnel: resolving a domain on the
/// host would leak DNS, and QUIC/WebRTC datagrams always carry literal IPs.
fn parse_udp_datagram(buf: &[u8]) -> Option<(IpEndpoint, usize)> {
if buf.len() < 4 || buf[2] != 0 {
// too short, or FRAG != 0 (fragmentation unsupported)
return None;
}
match buf[3] {
0x01 => {
if buf.len() < 10 {
return None;
}
let ip = Ipv4Address::new(buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
Some((IpEndpoint::new(IpAddress::Ipv4(ip), port), 10))
}
0x04 => {
if buf.len() < 22 {
return None;
}
let mut o = [0u8; 16];
o.copy_from_slice(&buf[4..20]);
let ip = smoltcp::wire::Ipv6Address::from(o);
let port = u16::from_be_bytes([buf[20], buf[21]]);
Some((IpEndpoint::new(IpAddress::Ipv6(ip), port), 22))
}
_ => None,
}
}
/// Wrap a tunnel-received datagram in an RFC 1928 §7 UDP reply header naming
/// `src` as the origin, for delivery back to the browser's relay socket.
fn build_udp_datagram(src: IpEndpoint, payload: &[u8]) -> Vec<u8> {
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
match src.addr {
IpAddress::Ipv4(v4) => {
out.push(0x01);
out.extend_from_slice(&v4.octets());
}
IpAddress::Ipv6(v6) => {
out.push(0x04);
out.extend_from_slice(&v6.octets());
}
}
out.extend_from_slice(&src.port.to_be_bytes());
out.extend_from_slice(payload);
out
}
struct WgDevice {
tunn: Arc<Mutex<Box<Tunn>>>,
@@ -432,6 +485,15 @@ impl WireGuardSocks5Server {
let mut sockets = SocketSet::new(vec![]);
// A live SOCKS5 UDP ASSOCIATE: the loopback relay socket the browser sends
// datagrams to, and the browser's learned source address. The tunnel-side
// smoltcp UDP socket lives in `sockets`, keyed by the connection's
// (repurposed) `smol_handle`.
struct UdpAssoc {
relay: UdpSocket,
client_addr: Option<SocketAddr>,
}
struct Connection {
smol_handle: SocketHandle,
tcp_stream: TcpStream,
@@ -440,6 +502,7 @@ impl WireGuardSocks5Server {
greeting_done: bool,
read_buf: Vec<u8>,
dest_addr: Option<SocketAddr>,
udp: Option<UdpAssoc>,
}
let mut connections: Vec<Connection> = Vec::new();
@@ -463,6 +526,7 @@ impl WireGuardSocks5Server {
greeting_done: false,
read_buf: Vec::new(),
dest_addr: None,
udp: None,
});
}
@@ -540,8 +604,17 @@ impl WireGuardSocks5Server {
}
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
// SOCKS5 connect request
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
// SOCKS5 request: CONNECT (0x01) or UDP ASSOCIATE (0x03)
if conn.read_buf[0] != 0x05 {
completed.push(idx);
continue;
}
let cmd = conn.read_buf[1];
if cmd != 0x01 && cmd != 0x03 {
// command not supported
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
@@ -613,6 +686,75 @@ impl WireGuardSocks5Server {
};
conn.read_buf.drain(..addr_len);
if cmd == 0x03 {
// === SOCKS5 UDP ASSOCIATE ===
// The request's DST is the client's intended source (typically
// 0.0.0.0:0) and is ignored — the browser's relay source is
// learned from its first datagram. Bind a loopback relay socket
// the browser sends to, plus a smoltcp UDP socket that egresses
// through the WireGuard tunnel on the interface IP.
let relay = match UdpSocket::bind("127.0.0.1:0") {
Ok(s) => s,
Err(_) => {
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
};
let _ = relay.set_nonblocking(true);
let relay_port = relay.local_addr().map(|a| a.port()).unwrap_or(0);
// Reply with the relay endpoint (127.0.0.1:relay_port).
if conn
.tcp_stream
.try_write(&[
0x05,
0x00,
0x00,
0x01,
127,
0,
0,
1,
(relay_port >> 8) as u8,
(relay_port & 0xff) as u8,
])
.is_err()
{
completed.push(idx);
continue;
}
let udp_rx = udp::PacketBuffer::new(
vec![udp::PacketMetadata::EMPTY; 32],
vec![0u8; SMOLTCP_UDP_BUF],
);
let udp_tx = udp::PacketBuffer::new(
vec![udp::PacketMetadata::EMPTY; 32],
vec![0u8; SMOLTCP_UDP_BUF],
);
let mut udp_socket = udp::Socket::new(udp_rx, udp_tx);
let local_port = 20000 + (rand::random::<u16>() % 40000);
if udp_socket.bind(local_port).is_err() {
completed.push(idx);
continue;
}
// Swap this connection's unused TCP socket for the UDP socket;
// `smol_handle` now keys the UDP socket, so teardown is unchanged.
sockets.remove(conn.smol_handle);
conn.smol_handle = sockets.add(udp_socket);
conn.udp = Some(UdpAssoc {
relay,
client_addr: None,
});
conn.socks_done = true;
continue;
}
conn.dest_addr = Some(addr);
// Open smoltcp TCP socket to the destination
@@ -641,6 +783,62 @@ impl WireGuardSocks5Server {
conn.connecting = true;
}
} else if conn.udp.is_some() {
// === UDP ASSOCIATE relay ===
// The association lives only while the TCP control connection is
// open (RFC 1928 §6); tear down when the browser closes it.
let mut probe = [0u8; 1];
match conn.tcp_stream.try_read(&mut probe) {
Ok(0) => {
completed.push(idx);
continue;
}
Ok(_) => {} // ignore any data on the control channel
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(_) => {
completed.push(idx);
continue;
}
}
let handle = conn.smol_handle;
let Some(udp) = conn.udp.as_mut() else {
continue;
};
// Browser → tunnel: strip the §7 header and forward the payload.
let mut dbuf = [0u8; SMOLTCP_UDP_BUF];
loop {
match udp.relay.recv_from(&mut dbuf) {
Ok((n, src)) => {
udp.client_addr = Some(src);
if let Some((dst, off)) = parse_udp_datagram(&dbuf[..n]) {
let socket = sockets.get_mut::<udp::Socket>(handle);
if socket.can_send() {
let _ = socket.send_slice(&dbuf[off..n], dst);
}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
// Tunnel → browser: wrap each datagram in a §7 header and relay back.
loop {
let socket = sockets.get_mut::<udp::Socket>(handle);
if !socket.can_recv() {
break;
}
let (payload, src) = match socket.recv() {
Ok((data, meta)) => (data.to_vec(), meta.endpoint),
Err(_) => break,
};
if let Some(client) = udp.client_addr {
let resp = build_udp_datagram(src, &payload);
let _ = udp.relay.send_to(&resp, client);
}
}
} else {
// Data relay between SOCKS5 client and smoltcp socket
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
+141 -3
View File
@@ -138,6 +138,46 @@ impl WayfernManager {
fingerprint
}
/// Derive the on-screen window size Chromium should open at, from the stored
/// fingerprint. `Wayfern.setFingerprint` only spoofs what the page *reports*
/// for `windowOuterWidth`/`screenWidth`/etc.; it does not move or resize the
/// real top-level window. Without `--window-size` the OS window keeps
/// Chromium's default, so the visible window contradicts the reported
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
/// window matches the fingerprint.
///
/// Keys are the camelCase fields Wayfern uses in its fingerprint
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
/// Camoufox-style keys. Preference order, matching how the fingerprint
/// describes the window:
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
/// 3. `screenWidth` / `screenHeight` — full screen.
///
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
/// Chromium's default untouched. The fingerprint JSON may be the bare object
/// or the legacy `{ "fingerprint": {...} }` wrapper.
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
let obj = fp.as_object()?;
// Accept both numeric and stringified numbers (Wayfern emits numbers, but a
// CDP echo or older saved fingerprint may stringify them).
let read = |key: &str| -> Option<u32> {
let v = obj.get(key)?;
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
.filter(|n| *n > 0)
.map(|n| n as u32)
};
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
pair("windowOuterWidth", "windowOuterHeight")
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
.or_else(|| pair("screenWidth", "screenHeight"))
}
async fn wait_for_cdp_ready(
&self,
port: u16,
@@ -611,13 +651,30 @@ impl WayfernManager {
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
// re-enabled by an imported/synced network_prediction_options pref (which a
// compile-time pref default could be).
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
"--use-mock-keychain".to_string(),
"--password-store=basic".to_string(),
];
if headless {
args.push("--headless=new".to_string());
} else if let Some((w, h)) = config
.fingerprint
.as_deref()
.and_then(Self::window_size_from_fingerprint)
{
// Size the real OS window to match the fingerprint so the visible window
// agrees with the reported windowOuterWidth/screen dimensions. Anchor at
// 0,0 so the window also fits within the spoofed screen origin. Skipped in
// headless mode, where there is no on-screen window.
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
args.push(format!("--window-size={w},{h}"));
args.push("--window-position=0,0".to_string());
}
#[cfg(target_os = "linux")]
@@ -671,9 +728,21 @@ impl WayfernManager {
}
if let Some(proxy) = proxy_url {
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
// PROXY is HTTP CONNECT (TCP only). The host:port is the same either way.
let (pac_directive, host_port) = if let Some(rest) = proxy.strip_prefix("socks5://") {
("SOCKS5", rest)
} else {
(
"PROXY",
proxy
.trim_start_matches("http://")
.trim_start_matches("https://"),
)
};
let pac_data = format!(
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}",
proxy.trim_start_matches("http://").trim_start_matches("https://")
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"{pac_directive} {host_port}\";}}",
);
args.push(format!("--proxy-pac-url={pac_data}"));
args.push("--dns-prefetch-disable".to_string());
@@ -1198,3 +1267,72 @@ impl WayfernManager {
lazy_static::lazy_static! {
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_size_prefers_outer_window_dimensions() {
// Field names + values mirror a real Wayfern fingerprint (camelCase).
let fp = r#"{"windowOuterWidth": 1268, "windowOuterHeight": 764,
"windowInnerWidth": 1253, "windowInnerHeight": 630,
"screenAvailWidth": 1280, "screenAvailHeight": 775,
"screenWidth": 1280, "screenHeight": 800}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(fp),
Some((1268, 764))
);
}
#[test]
fn window_size_falls_back_to_avail_then_full_screen() {
let avail = r#"{"screenAvailWidth": 1280, "screenAvailHeight": 775,
"screenWidth": 1280, "screenHeight": 800}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(avail),
Some((1280, 775))
);
let full = r#"{"screenWidth": 2560, "screenHeight": 1440}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(full),
Some((2560, 1440))
);
}
#[test]
fn window_size_handles_wrapper_and_stringified_numbers() {
let wrapped = r#"{"fingerprint": {"windowOuterWidth": "1366", "windowOuterHeight": "768"}}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(wrapped),
Some((1366, 768))
);
}
#[test]
fn window_size_none_when_missing_or_invalid() {
// No dimensions at all.
assert_eq!(
WayfernManager::window_size_from_fingerprint(r#"{"userAgent": "x"}"#),
None
);
// A width with no matching height is not a usable pair.
assert_eq!(
WayfernManager::window_size_from_fingerprint(r#"{"windowOuterWidth": 1268}"#),
None
);
// Zero is rejected as a degenerate size.
assert_eq!(
WayfernManager::window_size_from_fingerprint(
r#"{"windowOuterWidth": 0, "windowOuterHeight": 0}"#
),
None
);
// Not valid JSON.
assert_eq!(
WayfernManager::window_size_from_fingerprint("not json"),
None
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.25.2",
"version": "0.27.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+24 -9
View File
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
import { CommandPalette } from "@/components/command-palette";
@@ -59,6 +60,7 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import {
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
@@ -225,10 +227,7 @@ export default function Home() {
// Cloud auth for cross-OS unlock
const { user: cloudUser } = useCloudAuth();
const crossOsUnlocked =
cloudUser?.plan !== "free" &&
(cloudUser?.subscriptionStatus === "active" ||
cloudUser?.planPeriod === "lifetime");
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
useState(false);
@@ -1168,11 +1167,14 @@ export default function Home() {
profileId: profile.id,
syncMode: enabling ? "Regular" : "Disabled",
});
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
description: enabling
? "Profile sync has been enabled"
: "Profile sync has been disabled",
});
showSuccessToast(
t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
{
description: t(
enabling ? "sync.enabledDescription" : "sync.disabledDescription",
),
},
);
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast(t("errors.updateSyncSettingsFailed"));
@@ -1325,6 +1327,7 @@ export default function Home() {
let unlistenStarted: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
let unlistenCompleted: (() => void) | undefined;
let unlistenWayfernBlocked: (() => void) | undefined;
void (async () => {
unlistenRequired = await listen(
@@ -1386,6 +1389,16 @@ export default function Home() {
duration: 5000,
});
});
unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => {
showToast({
id: "wayfern-paid-blocked",
type: "error",
title: t("wayfernBlocked.title"),
description: t("wayfernBlocked.description"),
duration: 15000,
});
});
})();
return () => {
@@ -1393,6 +1406,7 @@ export default function Home() {
unlistenStarted?.();
unlistenProgress?.();
unlistenCompleted?.();
unlistenWayfernBlocked?.();
};
}, [t]);
@@ -1512,6 +1526,7 @@ export default function Home() {
return (
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<CloseConfirmDialog />
<CamoufoxDeprecationDialog profiles={profiles} />
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
searchQuery={searchQuery}
+11 -4
View File
@@ -25,7 +25,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types";
interface AccountPageProps {
@@ -196,8 +198,13 @@ export function AccountPage({
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<DialogContent className="max-w-2xl max-h-[calc(100vh-4rem)] flex flex-col">
<div
className={cn(
"flex flex-col gap-4 p-4 overflow-y-auto flex-1 min-h-0",
subPage && "w-full max-w-2xl mx-auto",
)}
>
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
@@ -298,7 +305,7 @@ export function AccountPage({
{isLoggedIn &&
user &&
user.plan !== "free" &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
@@ -306,7 +313,7 @@ export function AccountPage({
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
+3 -3
View File
@@ -63,11 +63,11 @@ export function BandwidthMiniChart({
type="button"
onClick={onClick}
className={cn(
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent",
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors w-full min-w-0 border-none bg-transparent",
className,
)}
>
<div className="flex-1 h-3 pointer-events-none">
<div className="flex-1 min-w-0 h-3 pointer-events-none">
<ResponsiveContainer
width="100%"
height="100%"
@@ -111,7 +111,7 @@ export function BandwidthMiniChart({
</AreaChart>
</ResponsiveContainer>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap min-w-[60px] text-right">
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0 min-w-[60px] text-right">
{formatBytes(currentBandwidth)}
</span>
</button>
+2 -2
View File
@@ -149,7 +149,7 @@ export function CamoufoxConfigDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-3xl h-[min(85vh,52rem)] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>
{isRunning
@@ -164,7 +164,7 @@ export function CamoufoxConfigDialog({
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 h-[300px]">
<ScrollArea className="flex-1 min-h-0">
<div className="py-4">
{profile.browser === "wayfern" ? (
<WayfernConfigForm
@@ -0,0 +1,78 @@
"use client";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BrowserProfile } from "@/types";
import { RippleButton } from "./ui/ripple";
interface CamoufoxDeprecationDialogProps {
profiles: BrowserProfile[];
}
/**
* Warns users who still have Camoufox profiles that Camoufox support is ending.
* Shown once per app session (this component mounts for the app lifetime), only
* when at least one Camoufox profile exists. Not a toast a blocking dialog so
* the deprecation can't be missed.
*/
export function CamoufoxDeprecationDialog({
profiles,
}: CamoufoxDeprecationDialogProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [shown, setShown] = useState(false);
useEffect(() => {
if (shown) return;
const hasCamoufox = profiles.some((p) => p.browser === "camoufox");
if (hasCamoufox) {
setIsOpen(true);
setShown(true);
}
}, [profiles, shown]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning" />
{t("camoufoxDeprecation.title")}
</DialogTitle>
<DialogDescription>
{t("camoufoxDeprecation.description")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => {
void openUrl(
"https://github.com/zhom/donutbrowser/discussions/426",
);
}}
>
{t("common.buttons.learnMore")}
</RippleButton>
<RippleButton
onClick={() => {
setIsOpen(false);
}}
>
{t("camoufoxDeprecation.acknowledge")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -77,7 +77,7 @@ export function CloneProfileDialog({
if (!open) onClose();
}}
>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
<DialogDescription>
+4 -4
View File
@@ -157,7 +157,7 @@ export function CommandPalette({
return (
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
<CommandInput placeholder={t("commandPalette.placeholder")} />
<CommandList>
<CommandList className="max-h-[min(60vh,480px)]">
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
<CommandGroup heading={t("commandPalette.groups.navigation")}>
@@ -205,7 +205,7 @@ export function CommandPalette({
}}
>
<LuCircleStop />
<span>
<span className="min-w-0 flex-1 truncate">
{t("commandPalette.actions.stopProfile", {
name: p.name,
})}
@@ -221,7 +221,7 @@ export function CommandPalette({
}}
>
<LuPlay />
<span>
<span className="min-w-0 flex-1 truncate">
{t("commandPalette.actions.launchProfile", {
name: p.name,
})}
@@ -239,7 +239,7 @@ export function CommandPalette({
}}
>
<LuInfo />
<span>
<span className="min-w-0 flex-1 truncate">
{t("commandPalette.actions.profileInfo", { name: p.name })}
</span>
</CommandItem>
+5 -5
View File
@@ -332,7 +332,7 @@ export function CookieCopyDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuCookie className="size-5" />
@@ -463,7 +463,7 @@ export function CookieCopyDialog({
: t("cookies.copy.noFound")}
</div>
) : (
<ScrollArea className="h-[250px] border rounded-md">
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
<div className="p-2 space-y-1">
{filteredDomains.map((domain) => (
<DomainRow
@@ -559,7 +559,7 @@ function DomainRow({
/>
<button
type="button"
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
className="flex items-center gap-1 flex-1 min-w-0 text-left bg-transparent border-none cursor-pointer"
onClick={() => {
onToggleExpand(domain.domain);
}}
@@ -569,8 +569,8 @@ function DomainRow({
) : (
<LuChevronRight className="size-4" />
)}
<span className="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground">
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
({domain.cookie_count})
</span>
</button>
+2 -2
View File
@@ -390,7 +390,7 @@ export function CookieManagementDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-[min(44rem,calc(100%-4rem))]">
<DialogHeader>
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
</DialogHeader>
@@ -563,7 +563,7 @@ export function CookieManagementDialog({
{t("cookies.management.noCookies")}
</div>
) : (
<FadingScrollArea className="h-[200px]">
<FadingScrollArea className="h-[clamp(140px,30vh,420px)]">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
+65 -306
View File
@@ -14,8 +14,6 @@ import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -56,15 +54,9 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type {
BrowserReleaseTypes,
CamoufoxConfig,
CamoufoxOS,
WayfernConfig,
WayfernOS,
} from "@/types";
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
const getCurrentOS = (): CamoufoxOS => {
const getCurrentOS = (): WayfernOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
@@ -86,7 +78,6 @@ interface CreateProfileDialogProps {
releaseType: string;
proxyId?: string;
vpnId?: string;
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
@@ -105,10 +96,6 @@ interface BrowserOption {
}
const browserOptions: BrowserOption[] = [
{
value: "camoufox",
label: "Camoufox",
},
{
value: "wayfern",
label: "Wayfern",
@@ -126,28 +113,24 @@ export function CreateProfileDialog({
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState("");
// Camoufox is deprecated: only Wayfern profiles can be created, so the dialog
// opens straight into the Wayfern config step (no browser-selection screen).
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
>("browser-selection");
>("browser-config");
const [activeTab, setActiveTab] = useState("anti-detect");
// Browser selection states
// Browser selection states. Defaults to Wayfern — the only creatable browser.
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>(null);
useState<BrowserTypeString>("wayfern");
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
const [launchHook, setLaunchHook] = useState("");
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
geoip: true, // Default to automatic geoip
os: getCurrentOS(), // Default to current OS
}));
// Wayfern anti-detect states
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
os: getCurrentOS() as WayfernOS, // Default to current OS
os: getCurrentOS(), // Default to current OS
}));
// Handle browser selection from the initial screen
@@ -156,22 +139,23 @@ export function CreateProfileDialog({
setCurrentStep("browser-config");
};
// Handle back button
const handleBack = () => {
setCurrentStep("browser-selection");
setSelectedBrowser(null);
// Reset the form fields without leaving the Wayfern config step — Camoufox is
// deprecated, so there is no browser-selection screen to go back to.
const resetForm = () => {
setSelectedBrowser("wayfern");
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
// Handle back button
const handleBack = () => {
resetForm();
};
const handleTabChange = (value: string) => {
setActiveTab(value);
setCurrentStep("browser-selection");
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
resetForm();
};
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
@@ -307,16 +291,15 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
// Load downloaded versions for both anti-detect browsers up front so the
// selection-screen availability gate is accurate before either is picked.
// Load downloaded Wayfern versions up front so the availability gate is
// accurate. Camoufox is deprecated and no longer creatable.
void loadDownloadedVersions("wayfern");
void loadDownloadedVersions("camoufox");
// Load release types when a browser is selected
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
}
// Check and download GeoIP database if needed for Camoufox or Wayfern
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
// Wayfern needs the GeoIP database for fingerprint generation.
if (selectedBrowser === "wayfern") {
void checkAndDownloadGeoIPDatabase();
}
}
@@ -417,66 +400,34 @@ export function CreateProfileDialog({
: undefined;
try {
if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected
if (selectedBrowser === "wayfern") {
const bestWayfernVersion = getCreatableVersion("wayfern");
if (!bestWayfernVersion) {
console.error("No Wayfern version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
const finalWayfernConfig = { ...wayfernConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "wayfern" as BrowserTypeString,
version: bestWayfernVersion.version,
releaseType: bestWayfernVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
} else {
// Default to Camoufox
const bestCamoufoxVersion = getCreatableVersion("camoufox");
if (!bestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
// We don't need to generate it here during profile creation
const finalCamoufoxConfig = { ...camoufoxConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
// Camoufox is deprecated — only Wayfern anti-detect profiles are created.
const bestWayfernVersion = getCreatableVersion("wayfern");
if (!bestWayfernVersion) {
console.error("No Wayfern version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
const finalWayfernConfig = { ...wayfernConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "wayfern" as BrowserTypeString,
version: bestWayfernVersion.version,
releaseType: bestWayfernVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
} else {
// Regular browser
if (!selectedBrowser) {
@@ -519,22 +470,19 @@ export function CreateProfileDialog({
// Cancel any ongoing loading
loadingBrowserRef.current = null;
// Reset all states
// Reset all states. Stay on the Wayfern config step — Camoufox is
// deprecated, so the browser-selection screen is gone.
setProfileName("");
setCurrentStep("browser-selection");
setCurrentStep("browser-config");
setActiveTab("anti-detect");
setSelectedBrowser(null);
setSelectedBrowser("wayfern");
setSelectedProxyId(undefined);
setLaunchHook("");
setReleaseTypes({});
setIsLoadingReleaseTypes(false);
setReleaseTypesError(null);
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
os: getCurrentOS(), // Reset to current OS
});
setWayfernConfig({
os: getCurrentOS() as WayfernOS, // Reset to current OS
os: getCurrentOS(), // Reset to current OS
});
setEphemeral(false);
setEnablePassword(false);
@@ -544,10 +492,6 @@ export function CreateProfileDialog({
onClose();
};
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
};
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
};
@@ -590,7 +534,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
@@ -613,7 +557,7 @@ export function CreateProfileDialog({
<ScrollArea className="overflow-y-auto flex-1">
<div className="flex flex-col justify-center items-center w-full">
<div className="py-4 space-y-6 w-full max-w-md">
<div className="py-4 space-y-6 w-full">
{currentStep === "browser-selection" ? (
<>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
@@ -652,46 +596,14 @@ export function CreateProfileDialog({
</div>
</Button>
{/* Camoufox (Firefox) - Second */}
<Button
onClick={() => {
handleBrowserSelect("camoufox");
}}
disabled={!getCreatableVersion("camoufox")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{isBrowserCurrentlyDownloading("camoufox") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent =
getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-sm text-muted-foreground">
{isBrowserCurrentlyDownloading("camoufox")
? t("createProfile.downloadingSubtitle")
: t("createProfile.firefoxSubtitle")}
</div>
</div>
</Button>
{/* Camoufox is deprecated no longer offered for new
profiles. Only Wayfern can be created. */}
{!getCreatableVersion("wayfern") &&
!getCreatableVersion("camoufox") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
{!getCreatableVersion("wayfern") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
</div>
</TabsContent>
@@ -996,162 +908,9 @@ export function CreateProfileDialog({
profileBrowser="wayfern"
/>
</div>
) : selectedBrowser === "camoufox" ? (
// Camoufox Configuration
<div className="space-y-6">
{/* Camoufox Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
</div>
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
<p className="flex-1 text-sm text-destructive">
{releaseTypesError}
</p>
<RippleButton
onClick={() =>
selectedBrowser &&
loadReleaseTypes(selectedBrowser)
}
size="sm"
variant="outline"
>
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
{t("createProfile.platformUnavailable", {
browser: "Camoufox",
})}
</p>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
!getCreatableVersion("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{t("createProfile.version.needsDownload", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
getCreatableVersion("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Camoufox",
version:
getCreatableVersion("camoufox")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
getCreatableVersion("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</div>
)}
{crossOsUnlocked && (
<Alert className="border-warning/50 bg-warning/10">
<AlertDescription className="text-sm">
{t("createProfile.camoufoxWarning")}
</AlertDescription>
</Alert>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getCreatableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
</div>
) : (
// Regular Browser Configuration (should not happen in anti-detect tab)
// Regular Browser Configuration (should not happen in
// the anti-detect tab; Camoufox creation is removed).
<div className="space-y-4">
{selectedBrowser && (
<div className="space-y-3">
+19 -51
View File
@@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps {
interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
stage?: "downloading" | "extracting" | "verifying" | "completed";
progress?: {
percentage: number;
speed?: string;
@@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps {
browserName?: string;
}
interface TwilightUpdateToastProps extends BaseToastProps {
type: "twilight-update";
browserName?: string;
hasUpdate?: boolean;
}
interface SyncProgressToastProps extends BaseToastProps {
type: "sync-progress";
progress?: {
@@ -138,7 +127,6 @@ type ToastProps =
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps
| TwilightUpdateToastProps
| SyncProgressToastProps;
function formatBytesCompact(bytes: number): string {
@@ -191,10 +179,6 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
@@ -217,7 +201,7 @@ export function UnifiedToast(props: ToastProps) {
const progress = "progress" in props ? props.progress : undefined;
return (
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="flex items-start p-3 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
@@ -246,7 +230,8 @@ export function UnifiedToast(props: ToastProps) {
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
{progress.eta &&
`${t("toasts.progress.remaining", { time: progress.eta })}`}
</p>
</div>
<div className="w-full bg-muted rounded-full h-1.5">
@@ -264,9 +249,10 @@ export function UnifiedToast(props: ToastProps) {
"current_browser" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground">
{progress.current_browser && (
<>Looking for updates for {progress.current_browser}</>
)}
{progress.current_browser &&
t("versionUpdater.toast.lookingForUpdates", {
browser: progress.current_browser,
})}
</p>
<div className="flex items-center gap-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
@@ -293,7 +279,10 @@ export function UnifiedToast(props: ToastProps) {
{progress.phase === "uploading"
? t("appUpdate.toast.uploading")
: t("appUpdate.toast.downloading")}{" "}
{progress.completed_files}/{progress.total_files} files
{t("toasts.progress.filesProgress", {
completed: progress.completed_files,
total: progress.total_files,
})}
{" \u2022 "}
{formatBytesCompact(progress.completed_bytes)} /{" "}
{formatBytesCompact(progress.total_bytes)}
@@ -304,37 +293,21 @@ export function UnifiedToast(props: ToastProps) {
</>
)}
{progress.eta_seconds > 0 &&
progress.completed_files < progress.total_files && (
<>
{" \u2022 ~"}
{formatEtaCompact(progress.eta_seconds)} remaining
</>
)}
progress.completed_files < progress.total_files &&
` \u2022 ${t("toasts.progress.remaining", {
time: `~${formatEtaCompact(progress.eta_seconds)}`,
})}`}
</p>
{progress.failed_count > 0 && (
<p className="text-xs text-destructive mt-0.5">
{progress.failed_count} file(s) failed
{t("toasts.progress.filesFailed", {
count: progress.failed_count,
})}
</p>
)}
</div>
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
<p className="text-xs text-muted-foreground">
{"hasUpdate" in props && props.hasUpdate
? "New twilight build available for download"
: "Checking for twilight updates..."}
</p>
{props.browserName && (
<p className="mt-1 text-xs text-muted-foreground">
{props.browserName} Rolling Release
</p>
)}
</div>
)}
{/* Description */}
{description && (
<p className="mt-1 text-xs leading-tight text-muted-foreground">
@@ -355,11 +328,6 @@ export function UnifiedToast(props: ToastProps) {
{t("browserDownload.toast.verifying")}
</p>
)}
{stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-muted-foreground">
{t("browserDownload.toast.downloadingRolling")}
</p>
)}
</>
)}
{action &&
+1 -1
View File
@@ -65,7 +65,7 @@ function DataTableActionBar<TData>({
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn(
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
className,
)}
{...props}
@@ -57,7 +57,10 @@ export function DeleteConfirmationDialog({
const profile = profiles.find((p) => p.id === id);
const displayName = profile ? profile.name : id;
return (
<li key={id} className="text-sm text-muted-foreground">
<li
key={id}
className="text-sm text-muted-foreground truncate"
>
{displayName}
</li>
);
+2 -2
View File
@@ -136,10 +136,10 @@ export function DeleteGroupDialog({
count: associatedProfiles.length,
})}
</Label>
<ScrollArea className="h-32 w-full border rounded-md p-3">
<ScrollArea className="max-h-[min(8rem,25vh)] overflow-y-auto w-full border rounded-md p-3">
<div className="space-y-1">
{associatedProfiles.map((profile) => (
<div key={profile.id} className="text-sm">
<div key={profile.id} className="text-sm truncate">
{profile.name}
</div>
))}
+1 -1
View File
@@ -87,7 +87,7 @@ export function DnsBlocklistDialog({
{t("dnsBlocklist.settingsDescription")}
</p>
<div className="space-y-3">
<div className="space-y-3 overflow-y-auto min-h-0 max-h-[40vh]">
{statuses.map((status) => (
<div
key={status.level}
@@ -110,7 +110,7 @@ export function ExtensionGroupAssignmentDialog({
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("extensions.assignTitle")}:</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-[min(8rem,20vh)] overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => {
const profile = profiles.find(
+94 -43
View File
@@ -75,6 +75,7 @@ import {
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { Extension, ExtensionGroup } from "@/types";
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
import { RippleButton } from "./ui/ripple";
@@ -770,6 +771,7 @@ export function ExtensionManagementDialog({
},
{
id: "compat",
size: 56,
enableSorting: false,
header: () => null,
cell: ({ row }) =>
@@ -821,6 +823,7 @@ export function ExtensionManagementDialog({
},
{
id: "actions",
size: 80,
enableSorting: false,
header: () => null,
cell: ({ row }) => {
@@ -942,6 +945,7 @@ export function ExtensionManagementDialog({
},
{
id: "extensions",
size: 120,
enableSorting: false,
header: () => null,
cell: ({ row }) => {
@@ -952,7 +956,7 @@ export function ExtensionManagementDialog({
const visibleExts = groupExts.slice(0, MAX_VISIBLE_ICONS);
const overflowCount = groupExts.length - MAX_VISIBLE_ICONS;
return (
<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-1 min-w-0">
{visibleExts.map((ext) => (
<Tooltip key={ext.id}>
<TooltipTrigger asChild>
@@ -985,7 +989,7 @@ export function ExtensionManagementDialog({
</Tooltip>
)}
{groupExts.length === 0 && (
<span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground truncate min-w-0">
{t("extensions.noExtensionsInGroup")}
</span>
)}
@@ -1043,6 +1047,7 @@ export function ExtensionManagementDialog({
},
{
id: "actions",
size: 80,
enableSorting: false,
header: () => null,
cell: ({ row }) => {
@@ -1111,7 +1116,7 @@ export function ExtensionManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
{!subPage && (
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -1125,7 +1130,7 @@ export function ExtensionManagementDialog({
</DialogHeader>
)}
<div className="relative flex-1 min-h-0 flex flex-col">
<div className="@container relative w-full flex-1 min-h-0 flex flex-col">
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
@@ -1150,7 +1155,7 @@ export function ExtensionManagementDialog({
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
className="flex-1 min-h-0 flex flex-col"
>
<div className="flex items-center justify-between gap-3 shrink-0">
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
<AnimatedTabsList>
<AnimatedTabsTrigger
value="extensions"
@@ -1170,27 +1175,45 @@ export function ExtensionManagementDialog({
</AnimatedTabsList>
<div className="flex items-center gap-2">
{activeTab === "extensions" && (
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
>
<LuUpload className="size-4" />
{t("extensions.upload")}
</RippleButton>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
aria-label={t("extensions.upload")}
>
<LuUpload className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.upload")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>{t("extensions.upload")}</TooltipContent>
</Tooltip>
)}
{activeTab === "groups" && (
<RippleButton
size="sm"
disabled={limitedMode}
onClick={() => setShowCreateGroup(true)}
>
<GoPlus className="size-4" />
{t("extensions.newGroup")}
</RippleButton>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
disabled={limitedMode}
onClick={() => setShowCreateGroup(true)}
aria-label={t("extensions.newGroup")}
>
<GoPlus className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.newGroup")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
{t("extensions.newGroup")}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
@@ -1267,14 +1290,20 @@ export function ExtensionManagementDialog({
</div>
) : (
<FadingScrollArea
className="flex-1 min-h-0"
className={cn(
"flex-1 min-h-0",
selectedExtensions.length > 0 && "pb-16",
)}
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
{extTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -1282,10 +1311,14 @@ export function ExtensionManagementDialog({
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
width:
header.column.id === "name"
? undefined
: `${header.column.getSize()}px`,
}}
className={cn(
header.column.id === "name" && "max-w-0",
)}
>
{header.isPlaceholder
? null
@@ -1308,10 +1341,14 @@ export function ExtensionManagementDialog({
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
width:
cell.column.id === "name"
? undefined
: `${cell.column.getSize()}px`,
}}
className={cn(
cell.column.id === "name" && "max-w-0",
)}
>
{flexRender(
cell.column.columnDef.cell,
@@ -1374,14 +1411,20 @@ export function ExtensionManagementDialog({
</div>
) : (
<FadingScrollArea
className="flex-1 min-h-0"
className={cn(
"flex-1 min-h-0",
selectedGroups.length > 0 && "pb-16",
)}
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
{groupTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -1389,10 +1432,14 @@ export function ExtensionManagementDialog({
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
width:
header.column.id === "name"
? undefined
: `${header.column.getSize()}px`,
}}
className={cn(
header.column.id === "name" && "max-w-0",
)}
>
{header.isPlaceholder
? null
@@ -1415,10 +1462,14 @@ export function ExtensionManagementDialog({
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
width:
cell.column.id === "name"
? undefined
: `${cell.column.getSize()}px`,
}}
className={cn(
cell.column.id === "name" && "max-w-0",
)}
>
{flexRender(
cell.column.columnDef.cell,
@@ -1515,7 +1566,7 @@ export function ExtensionManagementDialog({
{t("extensions.noExtensionsInGroup")}
</div>
) : (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
<div className="space-y-1 max-h-[min(40vh,320px)] overflow-y-auto">
{editGroupExtensionIds.map((extId) => {
const ext = extensions.find((e) => e.id === extId);
if (!ext) return null;
@@ -1612,7 +1663,7 @@ export function ExtensionManagementDialog({
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
{t("extensions.metadata")}
</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 && (
<>
<span className="text-muted-foreground">
@@ -1660,7 +1711,7 @@ export function ExtensionManagementDialog({
href={editingExtension.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1 truncate"
className="text-primary hover:underline flex items-center gap-1 min-w-0"
>
<span className="truncate">
{editingExtension.homepage_url}
+1 -1
View File
@@ -134,7 +134,7 @@ export function GroupAssignmentDialog({
<div className="space-y-4">
<div className="space-y-2">
<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-[min(8rem,20vh)] overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => {
// Find the profile name for display
-195
View File
@@ -1,195 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import type { GroupWithCount } from "@/types";
interface GroupBadgesProps {
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
refreshTrigger?: number;
groups: GroupWithCount[];
isLoading: boolean;
}
export function GroupBadges({
selectedGroupId,
onGroupSelect,
groups,
isLoading,
}: GroupBadgesProps) {
const { t } = useTranslation();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftFade, setShowLeftFade] = useState(false);
const [showRightFade, setShowRightFade] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null);
const hasMovedRef = useRef(false);
const clickBlockedRef = useRef(false);
const checkScrollPosition = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollLeft, scrollWidth, clientWidth } = container;
setShowLeftFade(scrollLeft > 0);
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const container = scrollContainerRef.current;
if (!container) return;
e.preventDefault();
dragStartRef.current = {
x: e.clientX,
scrollLeft: container.scrollLeft,
};
hasMovedRef.current = false;
setIsDragging(true);
container.style.cursor = "grabbing";
container.style.userSelect = "none";
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !dragStartRef.current) return;
const container = scrollContainerRef.current;
if (!container) return;
const deltaX = e.clientX - dragStartRef.current.x;
const distance = Math.abs(deltaX);
if (distance > 5) {
hasMovedRef.current = true;
}
container.scrollLeft = dragStartRef.current.scrollLeft - deltaX;
checkScrollPosition();
},
[isDragging, checkScrollPosition],
);
const handleMouseUp = useCallback(() => {
if (!isDragging) return;
const container = scrollContainerRef.current;
if (container) {
container.style.cursor = "";
container.style.userSelect = "";
}
clickBlockedRef.current = hasMovedRef.current;
setIsDragging(false);
dragStartRef.current = null;
setTimeout(() => {
hasMovedRef.current = false;
clickBlockedRef.current = false;
}, 100);
}, [isDragging]);
useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, [checkScrollPosition]);
useEffect(() => {
if (groups.length === 0) {
setShowLeftFade(false);
setShowRightFade(false);
return;
}
const container = scrollContainerRef.current;
if (!container) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
checkScrollPosition();
});
});
}, [groups, checkScrollPosition]);
if (isLoading && !groups.length) {
return (
<div className="flex gap-2 mb-4">
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
{t("groups.loading")}
</div>
</div>
);
}
return (
<div className="relative mb-4">
{showLeftFade && (
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
)}
{showRightFade && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
)}
<div
ref={scrollContainerRef}
role="region"
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"}`}
onScroll={checkScrollPosition}
onMouseDown={handleMouseDown}
>
{groups.map((group) => (
<Badge
key={group.id}
variant={selectedGroupId === group.id ? "default" : "secondary"}
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
onClick={(e) => {
if (hasMovedRef.current || clickBlockedRef.current) {
e.preventDefault();
e.stopPropagation();
return;
}
onGroupSelect(
selectedGroupId === group.id ? "default" : group.id,
);
}}
onMouseDown={(e) => {
if (isDragging) {
e.preventDefault();
e.stopPropagation();
}
}}
>
<span>{group.name}</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
</Badge>
))}
</div>
</div>
);
}
+28 -13
View File
@@ -59,6 +59,7 @@ import {
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { GroupWithCount, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -345,7 +346,7 @@ export function GroupManagementDialog({
groupSyncErrors[group.id],
);
return (
<div className="flex items-center gap-2 font-medium">
<div className="flex items-center gap-2 font-medium min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<div
@@ -358,8 +359,8 @@ export function GroupManagementDialog({
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
<LuFolder className="size-4 text-muted-foreground" />
{group.name}
<LuFolder className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{group.name}</span>
</div>
);
},
@@ -552,7 +553,7 @@ export function GroupManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-[min(60rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
{!subPage && (
<DialogHeader>
<DialogTitle>{t("groups.management")}</DialogTitle>
@@ -562,7 +563,7 @@ export function GroupManagementDialog({
</DialogHeader>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="w-full flex flex-col gap-4 flex-1 min-h-0">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-semibold">
@@ -601,14 +602,20 @@ export function GroupManagementDialog({
</div>
) : (
<FadingScrollArea
className="flex-1 min-h-0"
className={cn(
"flex-1 min-h-0",
selectedGroupsForBulk.length > 0 && "pb-16",
)}
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -616,10 +623,14 @@ export function GroupManagementDialog({
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
width:
header.column.id === "name"
? undefined
: `${header.column.getSize()}px`,
}}
className={cn(
header.column.id === "name" && "max-w-0",
)}
>
{header.isPlaceholder
? null
@@ -642,10 +653,14 @@ export function GroupManagementDialog({
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
width:
cell.column.id === "name"
? undefined
: `${cell.column.getSize()}px`,
}}
className={cn(
cell.column.id === "name" && "max-w-0",
)}
>
{flexRender(
cell.column.columnDef.cell,
+21 -8
View File
@@ -131,6 +131,16 @@ const HomeHeader = ({
[clearHold],
);
const handleDoubleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (isTextInputTarget(e.target)) return;
if (e.target instanceof Element && e.target.closest("button")) return;
clearHold();
void getCurrentWindow().toggleMaximize();
},
[clearHold],
);
// Horizontal scroll fades for the group filter strip — when the user
// has more groups than fit, the right edge fades to hint at overflow.
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
@@ -156,20 +166,22 @@ const HomeHeader = ({
const isWindows = platform === "windows";
return (
// biome-ignore lint/a11y/noStaticElementInteractions: titlebar drag surface; the interactive controls inside are real buttons/inputs
<div
ref={dragRootRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn(
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
// Windows: WindowDragArea renders two 44px native-style controls
// (minimize + close) fixed at top-right with z-50, total 88px wide.
// Reserve 100px on the right edge so the "+ New" button and search
// input clear them with a few pixels of breathing room — issues
// #358, #361, #362 all reported the same overlap before this fix.
isWindows ? "pr-[100px]" : "pr-3",
// Windows: WindowDragArea renders three 44px native-style controls
// (minimize + maximize/restore + close) fixed at top-right with
// z-50, total 132px wide. Reserve 144px on the right edge so the
// "+ New" button and search input clear them with a few pixels of
// breathing room and never sit underneath the controls.
isWindows ? "pr-[144px]" : "pr-3",
)}
>
{isMacOS && (
@@ -248,6 +260,7 @@ const HomeHeader = ({
<button
key={group.id}
type="button"
title={group.name}
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
@@ -258,7 +271,7 @@ const HomeHeader = ({
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{group.name}</span>
<span className="max-w-40 truncate">{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
@@ -297,7 +310,7 @@ const HomeHeader = ({
onChange={(e) => {
onSearchQueryChange(e.target.value);
}}
className="pr-7 pl-8 w-52 h-7 text-xs"
className="pr-7 pl-8 w-36 min-[860px]:w-52 h-7 text-xs"
/>
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
{searchQuery ? (
+39 -37
View File
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
@@ -34,9 +33,10 @@ import {
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import type { DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
@@ -70,7 +70,6 @@ export function ImportProfileDialog({
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
"select",
);
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
@@ -91,7 +90,11 @@ export function ImportProfileDialog({
useBrowserSupport();
const { storedProxies } = useProxyEvents();
const importableBrowsers = supportedBrowsers;
// Firefox-based browsers map to the deprecated Camoufox and can no longer be
// imported (the backend rejects them); only offer Chromium-family sources.
const importableBrowsers = supportedBrowsers.filter(
(browser) => getMappedBrowser(browser) === "wayfern",
);
const loadDetectedProfiles = useCallback(async () => {
setIsLoading(true);
@@ -176,7 +179,7 @@ export function ImportProfileDialog({
const mappedBrowser =
importMode === "auto-detect" && selectedProfile
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
? getMappedBrowser(selectedProfile.mapped_browser)
: getMappedBrowser(browserType);
setIsImporting(true);
@@ -186,7 +189,8 @@ export function ImportProfileDialog({
browserType,
newProfileName,
proxyId: selectedProxyId ?? null,
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
// Camoufox import is deprecated/blocked; only Wayfern configs are sent.
camoufoxConfig: null,
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
});
@@ -199,7 +203,10 @@ export function ImportProfileDialog({
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes("No downloaded versions found")) {
if (parseBackendError(error)) {
// Structured backend error (e.g. CAMOUFOX_IMPORT_DEPRECATED) — localize.
toast.error(translateBackendError(t, error));
} else if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(browserType);
toast.error(
t("importProfile.notInstalled", { browser: browserDisplayName }),
@@ -222,7 +229,6 @@ export function ImportProfileDialog({
manualProfilePath,
manualProfileName,
selectedProxyId,
camoufoxConfig,
wayfernConfig,
onClose,
selectedProfile,
@@ -231,7 +237,6 @@ export function ImportProfileDialog({
const handleClose = () => {
setCurrentStep("select");
setCamoufoxConfig({});
setWayfernConfig({});
setSelectedProxyId(undefined);
setSelectedDetectedProfile(null);
@@ -262,10 +267,10 @@ export function ImportProfileDialog({
const currentMappedBrowser = useMemo(() => {
if (importMode === "auto-detect" && selectedProfile) {
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
return getMappedBrowser(selectedProfile.mapped_browser);
}
if (importMode === "manual" && manualBrowserType) {
return manualBrowserType as "camoufox" | "wayfern";
return getMappedBrowser(manualBrowserType);
}
return null;
}, [importMode, selectedProfile, manualBrowserType]);
@@ -301,14 +306,19 @@ export function ImportProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
<div
className={cn(
"overflow-y-auto flex-1 space-y-6 min-h-0",
subPage && "mx-auto w-full max-w-2xl",
)}
>
{currentStep === "select" && (
<AnimatedTabs
value={importMode}
@@ -404,7 +414,7 @@ export function ImportProfileDialog({
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<p className="text-sm break-all">
<span className="font-medium">
{t("importProfile.pathLabel")}
</span>{" "}
@@ -508,7 +518,7 @@ export function ImportProfileDialog({
<FaFolder className="size-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-xs text-muted-foreground break-all">
{t("importProfile.examplePaths")}
<br />
macOS: ~/Library/Application
@@ -577,27 +587,17 @@ export function ImportProfileDialog({
</Select>
</div>
{currentMappedBrowser === "camoufox" ? (
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={(key, value) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
) : (
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
)}
{/* Only Wayfern profiles are importable now (Camoufox/Firefox
import is deprecated and blocked). */}
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</div>
)}
</div>
@@ -605,7 +605,9 @@ export function ImportProfileDialog({
<div
className={cn(
"shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
subPage
? "pt-2 border-t border-border mx-auto w-full max-w-2xl"
: undefined,
)}
>
{currentStep === "select" ? (
+12 -6
View File
@@ -32,6 +32,7 @@ import { Label } from "@/components/ui/label";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
interface AppSettings {
@@ -307,14 +308,19 @@ export function IntegrationsDialog({
}}
subPage={subPage}
>
<DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
<DialogContent className="max-w-3xl max-h-[calc(100vh-5rem)] flex flex-col">
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 min-h-0">
<div
className={cn(
"overflow-y-auto flex-1 min-h-0",
subPage && "w-full max-w-3xl mx-auto",
)}
>
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
@@ -327,7 +333,7 @@ export function IntegrationsDialog({
<AnimatedTabsContent
value="api"
className="mt-4 flex flex-col gap-4"
className="mt-4 flex flex-col gap-4 @container"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
@@ -364,7 +370,7 @@ export function IntegrationsDialog({
{settings.api_enabled && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @2xl:grid-cols-2 gap-4">
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiPortLabel")}
@@ -581,11 +587,11 @@ export function IntegrationsDialog({
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 @container">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.clientsLabel")}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-1 @2xl:grid-cols-2 gap-3">
{agents.map((agent) => {
const busy = busyAgentIds.has(agent.id);
return (
+1 -1
View File
@@ -233,7 +233,7 @@ export function LocationProxyDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-4 overflow-y-auto min-h-0 max-h-[calc(100vh-16rem)] pr-1">
{/* Country - always visible */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
+31 -4
View File
@@ -194,8 +194,16 @@ const MultipleSelector = React.forwardRef<
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [dropUp, setDropUp] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const updateDropUp = React.useCallback(() => {
const rect = inputRef.current?.getBoundingClientRect();
if (!rect) return;
const spaceBelow = window.innerHeight - rect.bottom;
setDropUp(spaceBelow < 240 && rect.top > spaceBelow);
}, []);
const [selected, setSelected] = React.useState<Option[]>(value ?? []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
@@ -203,6 +211,19 @@ const MultipleSelector = React.forwardRef<
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
// Re-evaluate the flip while the list is open: selecting options grows
// the badge row (moving the input down) and window resizes change the
// space below — both can invalidate the side chosen on focus.
React.useLayoutEffect(() => {
if (!open) return;
void selected.length;
updateDropUp();
window.addEventListener("resize", updateDropUp);
return () => {
window.removeEventListener("resize", updateDropUp);
};
}, [open, selected.length, updateDropUp]);
React.useImperativeHandle(
ref,
() => ({
@@ -377,7 +398,7 @@ const MultipleSelector = React.forwardRef<
commandProps?.onKeyDown?.(e);
}}
className={cn(
"h-auto overflow-visible bg-transparent",
"relative h-auto overflow-visible bg-transparent",
commandProps?.className,
)}
shouldFilter={
@@ -488,6 +509,7 @@ const MultipleSelector = React.forwardRef<
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
updateDropUp();
setOpen(true);
if (triggerSearchOnFocus && onSearch) {
void onSearch(debouncedSearchTerm);
@@ -511,9 +533,14 @@ const MultipleSelector = React.forwardRef<
/>
</div>
</div>
<div className="relative">
<div>
{open && hasAvailableOptions && (
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
<CommandList
className={cn(
"absolute z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in",
dropUp ? "bottom-full mb-1" : "top-full mt-1",
)}
>
{isLoading ? (
loadingIndicator
) : (
@@ -527,7 +554,7 @@ const MultipleSelector = React.forwardRef<
<CommandGroup
key={key}
heading={key}
className="overflow-auto h-24"
className="overflow-auto max-h-48"
>
{dropdowns.map((option) => {
return (
+199 -70
View File
@@ -5,9 +5,11 @@ import {
flexRender,
getCoreRowModel,
getSortedRowModel,
type RowData,
type RowSelectionState,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { invoke } from "@tauri-apps/api/core";
@@ -81,7 +83,6 @@ import {
isCrossOsProfile,
} from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
@@ -105,6 +106,15 @@ import { TrafficDetailsDialog } from "./traffic-details-dialog";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
// Emit no width for this column so table-fixed hands it all remaining
// space. Checking columnDef.size alone can't express this: TanStack
// resolves an unspecified size to its 150px default.
flexWidth?: boolean;
}
}
// Stable table meta type to pass volatile state/handlers into TanStack Table without
// causing column definitions to be recreated on every render.
interface TableMeta {
@@ -822,6 +832,96 @@ const NonHoverableTooltip = React.memo<{
NonHoverableTooltip.displayName = "NonHoverableTooltip";
// CSS-truncated text whose tooltip only appears when the text actually
// overflows its column (measured on hover, so it tracks live resizes).
const OverflowTooltipText = React.memo<{
text: string;
className?: string;
}>(({ text, className }) => {
const textRef = React.useRef<HTMLSpanElement | null>(null);
const [isOverflowing, setIsOverflowing] = React.useState(false);
return (
<Tooltip
onOpenChange={(open) => {
if (!open) return;
const el = textRef.current;
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
}}
>
<TooltipTrigger asChild>
<span
ref={textRef}
className={cn("block min-w-0 max-w-full truncate", className)}
>
{text}
</span>
</TooltipTrigger>
{isOverflowing && <TooltipContent>{text}</TooltipContent>}
</Tooltip>
);
});
OverflowTooltipText.displayName = "OverflowTooltipText";
// Must be rendered inside a <Popover>; the tooltip shows the full assignment
// name only when it is truncated in the cell.
const ProxyCellTrigger = React.memo<{
displayName: string;
hasAssignment: boolean;
vpnBadge: string | null;
isDisabled: boolean;
}>(({ displayName, hasAssignment, vpnBadge, isDisabled }) => {
const textRef = React.useRef<HTMLSpanElement | null>(null);
const [isOverflowing, setIsOverflowing] = React.useState(false);
return (
<Tooltip
onOpenChange={(open) => {
if (!open) return;
const el = textRef.current;
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
}}
>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"flex gap-2 items-center px-2 py-1 rounded min-w-0 max-w-full",
isDisabled
? "opacity-60 cursor-not-allowed pointer-events-none"
: "cursor-pointer hover:bg-accent/50",
)}
>
{vpnBadge && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight shrink-0"
>
{vpnBadge}
</Badge>
)}
<span
ref={textRef}
className={cn(
"text-sm min-w-0 truncate",
!hasAssignment && "text-muted-foreground",
)}
>
{displayName}
</span>
</span>
</PopoverTrigger>
</TooltipTrigger>
{hasAssignment && isOverflowing && (
<TooltipContent>{displayName}</TooltipContent>
)}
</Tooltip>
);
});
ProxyCellTrigger.displayName = "ProxyCellTrigger";
const NoteCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
@@ -2039,12 +2139,12 @@ export function ProfilesDataTable({
if (isDisabled) {
const tooltipMessage = isRunning
? "Can't modify running profile"
? t("profiles.table.cantModifyRunning")
: isLaunching
? "Can't modify profile while launching"
? t("profiles.table.cantModifyLaunching")
: isStopping
? "Can't modify profile while stopping"
: "Can't modify profile while browser is updating";
? t("profiles.table.cantModifyStopping")
: t("profiles.table.cantModifyUpdating");
return (
<Tooltip>
@@ -2276,7 +2376,9 @@ export function ProfilesDataTable({
},
{
accessorKey: "name",
size: 130,
// The only column without a fixed width: table-fixed hands it all
// remaining space as the window grows or shrinks.
meta: { flexWidth: true },
header: ({ column, table }) => {
const meta = table.options.meta as TableMeta;
return (
@@ -2341,27 +2443,18 @@ export function ProfilesDataTable({
meta.setRenameError(null);
}
}}
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
className="w-full min-w-0 max-w-full h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
/>
</div>
);
}
const display =
name.length < 14 ? (
<div className="font-medium text-left leading-none truncate">
{name}
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="leading-none block truncate">
{trimName(name, 14)}
</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
const display = (
<OverflowTooltipText
text={name}
className="font-medium text-left leading-none"
/>
);
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs;
@@ -2528,7 +2621,6 @@ export function ProfilesDataTable({
? effectiveProxy.name
: meta.t("profiles.table.notSelected");
const vpnBadge = effectiveVpn ? "WG" : null;
const tooltipText = hasAssignment ? displayName : null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
@@ -2562,42 +2654,12 @@ export function ProfilesDataTable({
meta.setOpenProxySelectorFor(open ? profile.id : null);
}}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"flex gap-2 items-center px-2 py-1 rounded",
isDisabled
? "opacity-60 cursor-not-allowed pointer-events-none"
: "cursor-pointer hover:bg-accent/50",
)}
>
{vpnBadge && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight"
>
{vpnBadge}
</Badge>
)}
<span
className={cn(
"text-sm",
!hasAssignment && "text-muted-foreground",
)}
>
{hasAssignment
? trimName(displayName, 10)
: displayName}
</span>
</span>
</PopoverTrigger>
</TooltipTrigger>
{tooltipText && (
<TooltipContent>{tooltipText}</TooltipContent>
)}
</Tooltip>
<ProxyCellTrigger
displayName={displayName}
hasAssignment={hasAssignment}
vpnBadge={vpnBadge}
isDisabled={isDisabled}
/>
{!isDisabled && (
<PopoverContent
@@ -2861,15 +2923,29 @@ export function ProfilesDataTable({
[t, setProfileForInfoDialog],
);
// Low-priority columns leave the table as the container narrows (most
// expendable first); their data stays reachable via the profile info
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
// Content columns grow proportionally with the container but never drop
// below the compact-layout floor; the name column takes the remainder.
// Computed in px from the observed container width because fixed table
// layout ignores max()/calc() column widths.
const [containerWidth, setContainerWidth] = React.useState(0);
const table = useReactTable({
data: profiles,
columns,
state: {
sorting,
rowSelection,
columnVisibility,
},
onSortingChange: handleSortingChange,
onRowSelectionChange: handleRowSelectionChange,
onColumnVisibilityChange: setColumnVisibility,
enableRowSelection: (row) => {
const profile = row.original;
const isRunning =
@@ -2885,9 +2961,50 @@ export function ProfilesDataTable({
});
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
const columnWidth = React.useCallback(
(id: string, sizePx: number) => {
const proportions: Record<string, { pct: number; floor: number }> = {
tags: { pct: 0.12, floor: 100 },
note: { pct: 0.1, floor: 80 },
proxy: { pct: 0.13, floor: 110 },
ext: { pct: 0.11, floor: 95 },
dns: { pct: 0.11, floor: 95 },
};
const p = proportions[id];
if (!p) return `${sizePx}px`;
return `${Math.max(p.floor, Math.round(containerWidth * p.pct))}px`;
},
[containerWidth],
);
const sortedRows = table.getRowModel().rows;
useScrollFade(scrollParentRef);
React.useEffect(() => {
const el = scrollParentRef.current;
if (!el) return;
const update = () => {
const w = el.clientWidth;
setContainerWidth(Math.round(w / 8) * 8);
setColumnVisibility((prev) => {
const next: VisibilityState = {
dns: w >= 768,
ext: w >= 672,
note: w >= 576,
tags: w >= 512,
};
return Object.keys(next).every((k) => prev[k] === next[k])
? prev
: next;
});
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
ro.disconnect();
};
}, []);
// Compact 36px row from the redesign spec; estimateSize must match the
// actual rendered row height or virtualizer placement drifts under scroll.
const ROW_HEIGHT = 36;
@@ -2912,7 +3029,13 @@ export function ProfilesDataTable({
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollParentRef}
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
className={cn(
"overflow-auto relative flex-1 min-h-0 scroll-fade",
// Clearance for the floating selection action bar (bottom-6 +
// ~46px tall) so the last rows can scroll out from behind it.
// Same predicate DataTableActionBar uses for its visibility.
table.getFilteredSelectedRowModel().rows.length > 0 && "pb-20",
)}
style={
{
// Sticky table header is 32px tall (h-8); shift the top
@@ -2922,7 +3045,7 @@ export function ProfilesDataTable({
} as React.CSSProperties
}
>
<Table className="table-fixed">
<Table className="table-fixed" containerClassName="overflow-visible">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
@@ -2934,9 +3057,12 @@ export function ProfilesDataTable({
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
width: header.column.columnDef.meta?.flexWidth
? undefined
: columnWidth(
header.column.id,
header.column.getSize(),
),
}}
>
{header.isPlaceholder
@@ -2955,7 +3081,7 @@ export function ProfilesDataTable({
{sortedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
colSpan={table.getVisibleLeafColumns().length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
@@ -2965,7 +3091,7 @@ export function ProfilesDataTable({
<>
{paddingTop > 0 && (
<tr style={{ height: `${paddingTop}px` }}>
<td colSpan={columns.length} />
<td colSpan={table.getVisibleLeafColumns().length} />
</tr>
)}
{virtualRows.map((virtualRow) => {
@@ -2997,9 +3123,12 @@ export function ProfilesDataTable({
key={cell.id}
className="overflow-visible py-0"
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
width: cell.column.columnDef.meta?.flexWidth
? undefined
: columnWidth(
cell.column.id,
cell.column.getSize(),
),
}}
>
{flexRender(
@@ -3013,7 +3142,7 @@ export function ProfilesDataTable({
})}
{paddingBottom > 0 && (
<tr style={{ height: `${paddingBottom}px` }}>
<td colSpan={columns.length} />
<td colSpan={table.getVisibleLeafColumns().length} />
</tr>
)}
</>
+115 -20
View File
@@ -2,6 +2,8 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
@@ -11,6 +13,7 @@ import {
LuClipboardCheck,
LuCookie,
LuCopy,
LuDownload,
LuFingerprint,
LuGlobe,
LuGroup,
@@ -39,6 +42,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -263,9 +272,9 @@ export function ProfileInfoDialog({
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
: null;
const networkLabel = vpnName
? `VPN: ${vpnName}`
? t("profileInfo.network.vpnLabel", { name: vpnName })
: proxyName
? `Proxy: ${proxyName}`
? t("profileInfo.network.proxyLabel", { name: proxyName })
: t("profileInfo.values.none");
const syncStatus = syncStatuses[profile.id];
@@ -299,6 +308,10 @@ export function ProfileInfoDialog({
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
// a navigation hub.
interface ActionItem {
// Stable, language-independent key used to map sidebar sections to actions.
// The sidebar must NOT match on `label` — labels are translated, so English
// substring matching hides sections for every non-English user.
id?: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
@@ -311,6 +324,7 @@ export function ProfileInfoDialog({
const actions: ActionItem[] = [
{
id: "network",
icon: <LuGlobe className="size-4" />,
label: t("profiles.actions.viewNetwork"),
onClick: () => {
@@ -319,6 +333,7 @@ export function ProfileInfoDialog({
disabled: isCrossOs,
},
{
id: "sync",
icon: <LuRefreshCw className="size-4" />,
label: t("profiles.actions.syncSettings"),
onClick: () => {
@@ -337,6 +352,7 @@ export function ProfileInfoDialog({
runningBadge: isRunning,
},
{
id: "fingerprint",
icon: <LuFingerprint className="size-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => {
@@ -359,6 +375,7 @@ export function ProfileInfoDialog({
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
id: "cookiesCopy",
icon: <LuCopy className="size-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => {
@@ -372,6 +389,7 @@ export function ProfileInfoDialog({
!onCopyCookiesToProfile,
},
{
id: "cookiesManage",
icon: <LuCookie className="size-4" />,
label: t("profileInfo.actions.manageCookies"),
onClick: () => {
@@ -395,6 +413,7 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
id: "extension",
icon: <LuPuzzle className="size-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => {
@@ -419,6 +438,7 @@ export function ProfileInfoDialog({
},
},
{
id: "hook",
icon: <LuLink className="size-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
@@ -461,6 +481,7 @@ export function ProfileInfoDialog({
destructive: true,
},
{
id: "delete",
icon: <LuTrash2 className="size-4" />,
label: t("profiles.actions.delete"),
onClick: () => {
@@ -482,7 +503,7 @@ export function ProfileInfoDialog({
>
<DialogContent
hideClose
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
className="max-w-[min(60rem,calc(100%-4rem))] h-[min(clamp(30rem,80vh,48rem),calc(100vh-3rem))] flex flex-col p-0 gap-0 overflow-hidden"
>
{/* The dialog renders its own custom header, so the accessible title is
visually hidden but present for screen readers (Radix requires it). */}
@@ -534,6 +555,7 @@ interface ProfileInfoLayoutProps {
onCloneProfile?: (profile: BrowserProfile) => void;
onKillProfile?: (profile: BrowserProfile) => void;
visibleActions: {
id?: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
@@ -579,22 +601,23 @@ function ProfileInfoLayout({
}: ProfileInfoLayoutProps) {
const [section, setSection] = React.useState<ProfileSection>("overview");
// Map sidebar items to existing action labels, so clicking a section
// simply triggers the existing dialog handler.
// Map sidebar items to existing actions by their stable, language-independent
// `id`, so clicking a section triggers the existing dialog handler. Matching
// on `label` would break for every non-English locale (the labels are
// translated) and hide whole sections.
const findAction = React.useCallback(
(substr: string) =>
visibleActions.find((a) => a.label.toLowerCase().includes(substr)),
(id: string) => visibleActions.find((a) => a.id === id),
[visibleActions],
);
const deleteAction = findAction("delete");
const fingerprintAction = findAction("fingerprint");
const cookiesManageAction = findAction("manage cookies");
const cookiesCopyAction = findAction("copy cookies");
const cookiesManageAction = findAction("cookiesManage");
const cookiesCopyAction = findAction("cookiesCopy");
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
const extensionAction = findAction("extension");
const syncAction = findAction("sync");
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
const _launchHookAction = findAction("hook");
const _networkAction = findAction("network");
// Password actions are no longer routed via the legacy action handlers —
// SecuritySectionInline writes directly to the backend instead.
@@ -1149,7 +1172,7 @@ function SyncSectionInline({
syncMode: mode,
});
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1192,7 +1215,9 @@ function SyncSectionInline({
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("profileInfo.fields.syncStatus")}
</p>
<p className="text-sm mt-0.5">{syncStatus.status}</p>
<p className="text-sm mt-0.5">
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
</p>
{syncStatus.error && (
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
)}
@@ -1246,7 +1271,7 @@ function NetworkSectionInline({
setProxyId(nextId);
if (nextId !== null) setVpnId(null);
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1264,7 +1289,7 @@ function NetworkSectionInline({
setVpnId(nextId);
if (nextId !== null) setProxyId(null);
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1370,7 +1395,7 @@ function ExtensionsSectionInline({
);
if (mounted) setGroups(data);
} catch (e) {
if (mounted) setError(String(e));
if (mounted) setError(translateBackendError(t as never, e));
}
};
void load();
@@ -1384,7 +1409,7 @@ function ExtensionsSectionInline({
mounted = false;
unlisten?.();
};
}, []);
}, [t]);
const onChange = async (value: string) => {
const next = value === "__none__" ? null : value;
@@ -1397,7 +1422,7 @@ function ExtensionsSectionInline({
});
setGroupId(next);
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
@@ -1495,6 +1520,41 @@ function CookiesSectionInline({
};
}, [profile.id, isRunning, t]);
const [isExporting, setIsExporting] = React.useState(false);
// Export all of this profile's cookies in one of the same formats import
// accepts (JSON or Netscape). The backend formats every cookie; we just pick
// a destination file.
const handleExport = React.useCallback(
async (format: "json" | "netscape") => {
setIsExporting(true);
try {
const content = await invoke<string>("export_profile_cookies", {
profileId: profile.id,
format,
});
const ext = format === "json" ? "json" : "txt";
const filePath = await save({
defaultPath: `${profile.name}_cookies.${ext}`,
filters: [
{
name: format === "json" ? "JSON" : "Text",
extensions: [ext],
},
],
});
if (!filePath) return;
await writeTextFile(filePath, content);
showSuccessToast(t("cookies.export.success"));
} catch (e) {
showErrorToast(translateBackendError(t as never, e));
} finally {
setIsExporting(false);
}
},
[profile.id, profile.name, t],
);
const domains = stats?.domains ?? [];
return (
@@ -1505,6 +1565,41 @@ function CookiesSectionInline({
{t("profileInfo.sections.cookies")}
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
disabled={
isDisabled ||
isRunning ||
isExporting ||
!stats ||
stats.total_count === 0
}
>
<LuDownload className="size-3.5" />
{t("common.buttons.export")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
void handleExport("json");
}}
>
{t("cookies.export.json")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
void handleExport("netscape");
}}
>
{t("cookies.export.netscape")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onImportCookies && (
<Button
variant="outline"
@@ -1514,7 +1609,7 @@ function CookiesSectionInline({
onClick={onImportCookies}
>
<LuUpload className="size-3.5" />
{t("cookies.import.title")}
{t("common.buttons.import")}
</Button>
)}
{onCopyCookies && (
@@ -1526,7 +1621,7 @@ function CookiesSectionInline({
onClick={onCopyCookies}
>
<LuCopy className="size-3.5" />
{t("profiles.actions.copyCookies")}
{t("common.buttons.copy")}
</Button>
)}
</div>
@@ -1684,7 +1779,7 @@ function FingerprintSectionInline({
// Close the dialog once the fingerprint is saved.
onSaved();
} catch (e) {
setError(String(e));
setError(translateBackendError(t as never, e));
} finally {
setIsSaving(false);
}
+2 -2
View File
@@ -193,7 +193,7 @@ export function ProfilePasswordDialog({
if (!open) onClose();
}}
>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t(titleKey)}</DialogTitle>
<DialogDescription>
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
<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">
<p className="font-medium text-warning">
{t("profilePassword.warnings.forgetWarningTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
+1 -1
View File
@@ -180,7 +180,7 @@ export function ProfileSelectorDialog({
successMessage={t("profileSelector.urlCopied")}
/>
</div>
<div className="p-2 text-sm break-all rounded bg-muted">
<div className="p-2 text-sm break-all rounded bg-muted max-h-24 overflow-y-auto">
{url}
</div>
</div>
+114 -115
View File
@@ -17,6 +17,7 @@ import {
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
import { isSyncEnabled } from "@/types";
@@ -36,11 +37,7 @@ export function ProfileSyncDialog({
}: ProfileSyncDialogProps) {
const { t } = useTranslation();
const { user: cloudUser } = useCloudAuth();
const isCloudSyncEligible =
cloudUser != null &&
cloudUser.plan !== "free" &&
(cloudUser.subscriptionStatus === "active" ||
cloudUser.planPeriod === "lifetime");
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
// Encryption available to everyone except team members who aren't owners
const canUseEncryption =
cloudUser == null ||
@@ -175,8 +172,8 @@ export function ProfileSyncDialog({
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogContent className="max-w-md flex flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
<DialogDescription>
{t("sync.mode.description", {
@@ -186,115 +183,117 @@ export function ProfileSyncDialog({
</DialogDescription>
</DialogHeader>
{isCheckingConfig ? (
<div className="flex justify-center py-8">
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
{!hasConfig && (
<div className="p-3 text-sm rounded-md bg-muted">
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
onSyncConfigOpen();
onClose();
}}
>
{t("sync.mode.configureService")}
</Button>
</div>
)}
{hasConfig && (
<>
<RadioGroup
value={syncMode}
onValueChange={handleModeChange}
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.disabled")}
</span>
<p className="text-sm text-muted-foreground">
{t("sync.mode.disabledDescription")}
</p>
</Label>
</div>
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.regular")}
</span>
<p className="text-sm text-muted-foreground">
{t("sync.mode.regularDescription")}
</p>
</Label>
</div>
<div className="flex items-start gap-x-3">
<RadioGroupItem
value="Encrypted"
id="sync-encrypted"
disabled={!canUseEncryption}
/>
<Label
htmlFor="sync-encrypted"
className={
canUseEncryption
? "cursor-pointer"
: "cursor-not-allowed opacity-50"
}
>
<span className="font-medium">
{t("sync.mode.encrypted")}
</span>
<p className="text-sm text-muted-foreground">
{canUseEncryption
? t("sync.mode.encryptedDescription")
: t("settings.encryption.requiresProOrOwner")}
</p>
</Label>
</div>
</RadioGroup>
{syncMode === "Encrypted" &&
!hasE2ePassword &&
userChangedMode && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t("sync.mode.noPasswordWarning")}
</div>
)}
<div className="space-y-2">
<Label>{t("sync.mode.lastSynced")}</Label>
<div className="flex gap-2 items-center">
<Badge variant="outline">
{formatLastSync(profile.last_sync)}
</Badge>
{isSyncEnabled(profile) && (
<Badge
variant={profile.last_sync ? "default" : "secondary"}
>
{profile.last_sync
? t("common.status.synced")
: t("common.status.pending")}
</Badge>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{isCheckingConfig ? (
<div className="flex justify-center py-8">
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
{!hasConfig && (
<div className="p-3 text-sm rounded-md bg-muted">
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
onSyncConfigOpen();
onClose();
}}
>
{t("sync.mode.configureService")}
</Button>
</div>
</>
)}
</div>
)}
)}
{hasConfig && (
<>
<RadioGroup
value={syncMode}
onValueChange={handleModeChange}
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.disabled")}
</span>
<p className="text-sm text-muted-foreground">
{t("sync.mode.disabledDescription")}
</p>
</Label>
</div>
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.regular")}
</span>
<p className="text-sm text-muted-foreground">
{t("sync.mode.regularDescription")}
</p>
</Label>
</div>
<div className="flex items-start gap-x-3">
<RadioGroupItem
value="Encrypted"
id="sync-encrypted"
disabled={!canUseEncryption}
/>
<Label
htmlFor="sync-encrypted"
className={
canUseEncryption
? "cursor-pointer"
: "cursor-not-allowed opacity-50"
}
>
<span className="font-medium">
{t("sync.mode.encrypted")}
</span>
<p className="text-sm text-muted-foreground">
{canUseEncryption
? t("sync.mode.encryptedDescription")
: t("settings.encryption.requiresProOrOwner")}
</p>
</Label>
</div>
</RadioGroup>
{syncMode === "Encrypted" &&
!hasE2ePassword &&
userChangedMode && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t("sync.mode.noPasswordWarning")}
</div>
)}
<div className="space-y-2">
<Label>{t("sync.mode.lastSynced")}</Label>
<div className="flex gap-2 items-center">
<Badge variant="outline">
{formatLastSync(profile.last_sync)}
</Badge>
{isSyncEnabled(profile) && (
<Badge
variant={profile.last_sync ? "default" : "secondary"}
>
{profile.last_sync
? t("common.status.synced")
: t("common.status.pending")}
</Badge>
)}
</div>
</div>
</>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
+2 -2
View File
@@ -157,7 +157,7 @@ export function ProxyAssignmentDialog({
<div className="space-y-4">
<div className="space-y-2">
<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-[min(8rem,20vh)] overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => {
const profile = profiles.find(
@@ -206,7 +206,7 @@ export function ProxyAssignmentDialog({
</PopoverTrigger>
<PopoverContent
id={proxyListboxId}
className="w-[240px] p-0"
className="w-[var(--radix-popover-trigger-width)] p-0"
sideOffset={8}
>
<Command>
+2 -2
View File
@@ -90,7 +90,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
<DialogDescription>
@@ -125,7 +125,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
<div className="space-y-2">
<Label>{t("proxies.exportDialog.preview")}</Label>
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md bg-muted/30">
{isLoading ? (
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
{t("common.buttons.loading")}
+4 -6
View File
@@ -158,7 +158,7 @@ export function ProxyFormDialog({
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-4 py-4 @container">
<div className="grid gap-2">
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
<Input
@@ -228,12 +228,12 @@ export function ProxyFormDialog({
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{form.proxy_type === "ss"
? t("proxies.form.cipher")
: `${t("proxies.form.username")} (${t("proxies.form.usernamePlaceholder")})`}
: t("proxies.form.username")}
</Label>
<Input
id="proxy-username"
@@ -252,9 +252,7 @@ export function ProxyFormDialog({
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{form.proxy_type === "ss"
? t("proxies.form.password")
: `${t("proxies.form.password")} (${t("proxies.form.passwordPlaceholder")})`}
{t("proxies.form.password")}
</Label>
<Input
id="proxy-password"
+5 -5
View File
@@ -280,7 +280,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
<DialogDescription>
@@ -376,12 +376,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
</span>
)}
</Label>
<ScrollArea className="h-[200px] border rounded-md">
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md">
<div className="p-2 space-y-1">
{parsedProxies.map((proxy, i) => (
<div
key={`${proxy.original_line}-${i}`}
className="text-xs font-mono p-2 bg-muted/30 rounded"
className="text-xs font-mono p-2 bg-muted/30 rounded break-all"
>
<span className="text-primary">
{proxy.proxy_type}://
@@ -407,14 +407,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<p className="text-sm text-muted-foreground">
{t("proxies.importDialog.ambiguousIntro")}
</p>
<ScrollArea className="h-[250px] border rounded-md">
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
<div className="p-3 space-y-4">
{ambiguousProxies.map((proxy, i) => (
<div
key={`${proxy.line}-${i}`}
className="space-y-2 pb-3 border-b last:border-0"
>
<code className="text-xs bg-muted px-2 py-1 rounded block">
<code className="text-xs bg-muted px-2 py-1 rounded block break-all">
{proxy.line}
</code>
<div className="flex flex-col gap-2">
+371 -245
View File
@@ -504,6 +504,7 @@ export function ProxyManagementDialog({
},
{
id: "status",
size: 28,
enableSorting: false,
header: () => null,
cell: ({ row }) => {
@@ -551,11 +552,14 @@ export function ProxyManagementDialog({
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
<span className="font-medium block truncate">
{row.original.name}
</span>
),
},
{
id: "protocol",
size: 96,
enableSorting: false,
header: () => t("proxies.management.protocolCol"),
cell: ({ row }) => (
@@ -564,8 +568,20 @@ export function ProxyManagementDialog({
</span>
),
},
{
id: "hostPort",
enableSorting: false,
header: () => t("proxies.management.hostPort"),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground block truncate">
{row.original.proxy_settings.host}:
{row.original.proxy_settings.port}
</span>
),
},
{
id: "usage",
size: 80,
enableSorting: false,
header: () => t("proxies.management.usage"),
cell: ({ row }) => (
@@ -574,6 +590,7 @@ export function ProxyManagementDialog({
},
{
id: "sync",
size: 96,
enableSorting: false,
header: () => t("proxies.management.syncCol"),
cell: ({ row }) => {
@@ -607,6 +624,7 @@ export function ProxyManagementDialog({
},
{
id: "actions",
size: 144,
enableSorting: false,
header: () => t("common.labels.actions"),
cell: ({ row }) => {
@@ -775,7 +793,7 @@ export function ProxyManagementDialog({
vpnSyncErrors[vpn.id],
);
return (
<div className="flex items-center gap-2 font-medium">
<div className="flex items-center gap-2 font-medium min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<div
@@ -788,19 +806,21 @@ export function ProxyManagementDialog({
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{vpn.name}
<span className="truncate">{vpn.name}</span>
</div>
);
},
},
{
id: "type",
size: 96,
enableSorting: false,
header: () => t("common.labels.type"),
cell: () => <Badge variant="outline">WG</Badge>,
},
{
id: "usage",
size: 80,
enableSorting: false,
header: () => t("proxies.management.usage"),
cell: ({ row }) => (
@@ -809,6 +829,7 @@ export function ProxyManagementDialog({
},
{
id: "sync",
size: 96,
enableSorting: false,
header: () => t("proxies.management.syncCol"),
cell: ({ row }) => {
@@ -842,6 +863,7 @@ export function ProxyManagementDialog({
},
{
id: "actions",
size: 144,
enableSorting: false,
header: () => t("common.labels.actions"),
cell: ({ row }) => {
@@ -1068,7 +1090,7 @@ export function ProxyManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[85vh] flex flex-col">
{!subPage && (
<DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
@@ -1078,251 +1100,355 @@ export function ProxyManagementDialog({
</DialogHeader>
)}
<AnimatedTabs
key={initialTab}
defaultValue={initialTab}
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
className="flex-1 min-h-0 flex flex-col"
>
<div className="flex items-center justify-between gap-3 shrink-0">
<AnimatedTabsList>
<AnimatedTabsTrigger value="proxies">
<span>{t("proxies.management.tabProxies")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{storedProxies.length}
</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="vpns">
<span>{t("proxies.management.tabVpns")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{vpnConfigs.length}
</span>
</AnimatedTabsTrigger>
</AnimatedTabsList>
<div className="flex items-center gap-2">
{activeTab === "proxies" && (
<>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowImportDialog(true);
}}
className="flex gap-2 items-center"
>
<LuUpload className="size-4" />
{t("common.buttons.import")}
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowExportDialog(true);
}}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="size-4" />
{t("common.buttons.export")}
</RippleButton>
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="size-4" />
{t("proxies.management.newProxy")}
</RippleButton>
</>
)}
{activeTab === "vpns" && (
<>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowVpnImportDialog(true);
}}
className="flex gap-2 items-center"
>
<LuUpload className="size-4" />
{t("common.buttons.import")}
</RippleButton>
<RippleButton
size="sm"
onClick={handleCreateVpn}
className="flex gap-2 items-center"
>
<GoPlus className="size-4" />
{t("proxies.management.newVpn")}
</RippleButton>
</>
)}
</div>
</div>
<AnimatedTabsContent
value="proxies"
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
<div className="@container w-full flex-1 min-h-0 flex flex-col">
<AnimatedTabs
key={initialTab}
defaultValue={initialTab}
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
className="flex-1 min-h-0 flex flex-col"
>
<div className="flex flex-col gap-4 flex-1 min-h-0">
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("proxies.management.loading")}
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("proxies.management.noneCreated")}
</div>
) : (
<FadingScrollArea
className="flex-1 min-h-0"
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background">
{proxiesTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
className={cn(
header.column.id !== "name" &&
header.column.id !== "select" &&
"whitespace-nowrap w-px",
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{proxiesTable.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
<AnimatedTabsList>
<AnimatedTabsTrigger value="proxies">
<span>{t("proxies.management.tabProxies")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{storedProxies.length}
</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="vpns">
<span>{t("proxies.management.tabVpns")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{vpnConfigs.length}
</span>
</AnimatedTabsTrigger>
</AnimatedTabsList>
<div className="flex items-center gap-2">
{activeTab === "proxies" && (
<>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowImportDialog(true);
}}
className="flex gap-2 items-center"
aria-label={t("common.buttons.import")}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
<LuUpload className="size-4" />
<span className="hidden @2xl:inline">
{t("common.buttons.import")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
<p>{t("common.buttons.import")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowExportDialog(true);
}}
className="flex gap-2 items-center"
aria-label={t("common.buttons.export")}
disabled={storedProxies.length === 0}
>
<LuDownload className="size-4" />
<span className="hidden @2xl:inline">
{t("common.buttons.export")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
<p>{t("common.buttons.export")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
aria-label={t("proxies.management.newProxy")}
>
<GoPlus className="size-4" />
<span className="hidden @2xl:inline">
{t("proxies.management.newProxy")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
<p>{t("proxies.management.newProxy")}</p>
</TooltipContent>
</Tooltip>
</>
)}
{activeTab === "vpns" && (
<>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowVpnImportDialog(true);
}}
className="flex gap-2 items-center"
aria-label={t("common.buttons.import")}
>
<LuUpload className="size-4" />
<span className="hidden @2xl:inline">
{t("common.buttons.import")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
<p>{t("common.buttons.import")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
onClick={handleCreateVpn}
className="flex gap-2 items-center"
aria-label={t("proxies.management.newVpn")}
>
<GoPlus className="size-4" />
<span className="hidden @2xl:inline">
{t("proxies.management.newVpn")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
<p>{t("proxies.management.newVpn")}</p>
</TooltipContent>
</Tooltip>
</>
)}
</div>
</div>
</AnimatedTabsContent>
<AnimatedTabsContent
value="vpns"
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
>
<div className="flex flex-col gap-4 flex-1 min-h-0">
{isLoadingVpns ? (
<div className="text-sm text-muted-foreground">
{t("vpns.management.loading")}
</div>
) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("vpns.management.noneCreated")}
</div>
) : (
<FadingScrollArea
className="flex-1 min-h-0"
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background">
{vpnsTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
className={cn(
header.column.id !== "name" &&
header.column.id !== "select" &&
"whitespace-nowrap w-px",
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{vpnsTable.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
</div>
</AnimatedTabsContent>
</AnimatedTabs>
<AnimatedTabsContent
value="proxies"
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
>
<div className="flex flex-col gap-4 flex-1 min-h-0">
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("proxies.management.loading")}
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("proxies.management.noneCreated")}
</div>
) : (
<FadingScrollArea
className={cn(
"flex-1 min-h-0",
selectedProxies.length > 0 && "pb-16",
)}
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
{proxiesTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{
width:
header.column.id === "name" ||
header.column.id === "hostPort"
? undefined
: `${header.column.getSize()}px`,
}}
className={cn(
// name and hostPort emit no width, so
// fixed layout splits the remaining
// space evenly between them (hostPort
// hides below @2xl, leaving name all
// of it).
header.column.id === "name" && "max-w-0",
header.column.id === "hostPort" &&
"hidden @2xl:table-cell max-w-0",
(header.column.id === "protocol" ||
header.column.id === "type") &&
"hidden @2xl:table-cell",
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{proxiesTable.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width:
cell.column.id === "name" ||
cell.column.id === "hostPort"
? undefined
: `${cell.column.getSize()}px`,
}}
className={cn(
cell.column.id === "name" && "max-w-0",
cell.column.id === "hostPort" &&
"hidden @2xl:table-cell max-w-0",
(cell.column.id === "protocol" ||
cell.column.id === "type") &&
"hidden @2xl:table-cell",
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
</div>
</AnimatedTabsContent>
<AnimatedTabsContent
value="vpns"
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
>
<div className="flex flex-col gap-4 flex-1 min-h-0">
{isLoadingVpns ? (
<div className="text-sm text-muted-foreground">
{t("vpns.management.loading")}
</div>
) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("vpns.management.noneCreated")}
</div>
) : (
<FadingScrollArea
className={cn(
"flex-1 min-h-0",
selectedVpns.length > 0 && "pb-16",
)}
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
{vpnsTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{
width:
header.column.id === "name" ||
header.column.id === "hostPort"
? undefined
: `${header.column.getSize()}px`,
}}
className={cn(
// name and hostPort emit no width, so
// fixed layout splits the remaining
// space evenly between them (hostPort
// hides below @2xl, leaving name all
// of it).
header.column.id === "name" && "max-w-0",
header.column.id === "hostPort" &&
"hidden @2xl:table-cell max-w-0",
(header.column.id === "protocol" ||
header.column.id === "type") &&
"hidden @2xl:table-cell",
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{vpnsTable.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width:
cell.column.id === "name" ||
cell.column.id === "hostPort"
? undefined
: `${cell.column.getSize()}px`,
}}
className={cn(
cell.column.id === "name" && "max-w-0",
cell.column.id === "hostPort" &&
"hidden @2xl:table-cell max-w-0",
(cell.column.id === "protocol" ||
cell.column.id === "type") &&
"hidden @2xl:table-cell",
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
</div>
</AnimatedTabsContent>
</AnimatedTabs>
</div>
{!subPage && (
<DialogFooter>
+43 -39
View File
@@ -74,8 +74,6 @@ function useLogoEasterEgg({
const rect = el.getBoundingClientRect();
const startX = rect.left;
const startY = rect.top;
const floorY = window.innerHeight;
const rightWall = window.innerWidth;
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "fixed";
@@ -99,6 +97,10 @@ function useLogoEasterEgg({
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
// Read live so a mid-animation window resize moves the floor/wall.
const floorY = window.innerHeight;
const rightWall = window.innerWidth;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
@@ -294,7 +296,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent shrink-0"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
@@ -331,43 +333,45 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
</span>
</button>
) : (
<div className="size-7" />
<div className="size-7 shrink-0" />
)}
<div className="w-5 h-px bg-border my-1" />
<div className="w-5 h-px bg-border my-1 shrink-0" />
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
const active = currentPage === page;
return (
<Tooltip key={page} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate(page);
}}
aria-label={t(labelKey)}
aria-current={active ? "page" : undefined}
className={cn(
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
active
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
</Tooltip>
);
})}
<div className="flex flex-col items-center gap-1 w-full min-h-0 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
const active = currentPage === page;
return (
<Tooltip key={page} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate(page);
}}
aria-label={t(labelKey)}
aria-current={active ? "page" : undefined}
className={cn(
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
active
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
</Tooltip>
);
})}
</div>
<div className="flex-1" />
@@ -381,7 +385,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
aria-label={t("rail.more.label")}
aria-expanded={moreOpen}
className={cn(
"grid place-items-center size-7 rounded-md transition-colors duration-100",
"grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
moreOpen
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
@@ -403,7 +407,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
aria-label={t("rail.settings")}
aria-current={currentPage === "settings" ? "page" : undefined}
className={cn(
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
currentPage === "settings"
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
+4 -4
View File
@@ -633,7 +633,7 @@ export function SettingsDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogContent className="max-w-md max-h-[calc(100vh-5rem)] flex flex-col">
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("settings.title")}</DialogTitle>
@@ -643,7 +643,7 @@ export function SettingsDialog({
<div
className={cn(
"grid overflow-y-auto flex-1 gap-6 min-h-0",
subPage ? "py-2" : "py-4",
subPage ? "py-2 w-full max-w-2xl mx-auto" : "py-4",
)}
>
{/* Appearance Section */}
@@ -748,7 +748,7 @@ export function SettingsDialog({
<div className="text-sm font-medium">
{t("settings.appearance.customColors")}
</div>
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-3">
{THEME_VARIABLES.map(({ key, label }) => {
const colorValue =
customThemeState.colors[key] ?? "#000000";
@@ -1314,7 +1314,7 @@ export function SettingsDialog({
</div>
{subPage ? (
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border w-full max-w-2xl mx-auto">
<LoadingButton
size="sm"
isLoading={isSaving}
@@ -410,7 +410,7 @@ export function SharedCamoufoxConfigForm({
{/* Navigator Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.navigatorProperties")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
<Input
@@ -566,7 +566,7 @@ export function SharedCamoufoxConfigForm({
{/* Screen Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.screenProperties")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-width">
{t("fingerprint.screenWidth")}
@@ -687,7 +687,7 @@ export function SharedCamoufoxConfigForm({
{/* Window Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.windowProperties")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="outer-width">
{t("fingerprint.outerWidth")}
@@ -800,7 +800,7 @@ export function SharedCamoufoxConfigForm({
{/* Geolocation */}
<div className="space-y-3">
<Label>{t("fingerprint.geolocation")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="latitude">{t("fingerprint.latitude")}</Label>
<Input
@@ -860,7 +860,7 @@ export function SharedCamoufoxConfigForm({
{/* Locale */}
<div className="space-y-3">
<Label>{t("fingerprint.locale")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="locale-language">
{t("fingerprint.language")}
@@ -917,7 +917,7 @@ export function SharedCamoufoxConfigForm({
{/* WebGL Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.webglProperties")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">
{t("fingerprint.webglVendor")}
@@ -1065,7 +1065,7 @@ export function SharedCamoufoxConfigForm({
{/* Battery */}
<div className="space-y-3">
<Label>{t("fingerprint.battery")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-x-2">
<Checkbox
@@ -1158,7 +1158,7 @@ export function SharedCamoufoxConfigForm({
);
return (
<div className={`space-y-6 ${className}`}>
<div className={`@container space-y-6 ${className}`}>
{forceAdvanced ? (
// Advanced mode only (for editing)
renderAdvancedForm()
@@ -1265,7 +1265,7 @@ export function SharedCamoufoxConfigForm({
className="space-y-3"
>
<Label>{t("fingerprint.screenResolution")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-max-width">
{t("fingerprint.maxWidth")}
+13 -3
View File
@@ -21,7 +21,7 @@ interface ShortcutsPageProps {
function Tokens({ tokens }: { tokens: string[] }) {
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
{tokens.map((tok, i) => (
<kbd
key={i}
@@ -72,7 +72,12 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
key={s.id}
className="flex items-center justify-between gap-4 px-3 py-2"
>
<span className="text-sm">{t(s.labelKey)}</span>
<span
className="text-sm truncate min-w-0"
title={t(s.labelKey)}
>
{t(s.labelKey)}
</span>
<ShortcutTokens shortcut={s} />
</div>
))}
@@ -92,7 +97,12 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
key={target.id}
className="flex items-center justify-between gap-4 px-3 py-2"
>
<span className="text-sm">{target.name}</span>
<span
className="text-sm truncate min-w-0"
title={target.name}
>
{target.name}
</span>
<Tokens tokens={formatGroupShortcut(i + 1)} />
</div>
))}
+1 -1
View File
@@ -137,7 +137,7 @@ export function SyncFollowerDialog({
</div>
<div className="border rounded-md">
<ScrollArea className="h-[150px]">
<ScrollArea className="h-[clamp(120px,30vh,20rem)]">
<div className="space-y-1 p-2">
{eligibleProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
+6 -6
View File
@@ -127,7 +127,7 @@ const TruncatedDomain = React.memo<{ domain: string }>(({ domain }) => {
}, [checkTruncation]);
const content = (
<span ref={ref} className="truncate max-w-[200px] block">
<span ref={ref} className="truncate block min-w-0 flex-1">
{domain}
</span>
);
@@ -257,7 +257,7 @@ export function TrafficDetailsDialog({
if (!open) onClose();
}}
>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-[min(56rem,calc(100%-4rem))]">
<DialogHeader>
<DialogTitle>
{t("traffic.title")}
@@ -303,7 +303,7 @@ export function TrafficDetailsDialog({
</Select>
</div>
<div className="h-[200px] w-full">
<div className="h-[clamp(200px,28vh,360px)] w-full">
<ResponsiveContainer
width="100%"
height="100%"
@@ -509,7 +509,7 @@ export function TrafficDetailsDialog({
{t("traffic.columnReceived")}
</span>
</div>
<div className="max-h-[180px] overflow-y-auto">
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
{topDomainsByTraffic.map((domain, index) => (
<div
key={domain.domain}
@@ -558,7 +558,7 @@ export function TrafficDetailsDialog({
{t("traffic.columnTotal")}
</span>
</div>
<div className="max-h-[180px] overflow-y-auto">
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
{topDomainsByRequests.map((domain, index) => (
<div
key={domain.domain}
@@ -591,7 +591,7 @@ export function TrafficDetailsDialog({
<h3 className="text-sm font-medium mb-2">
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
</h3>
<FadingScrollArea className="p-3 max-h-[120px]">
<FadingScrollArea className="p-3 max-h-[clamp(120px,15vh,240px)]">
<div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => (
<span
+1 -1
View File
@@ -78,7 +78,7 @@ function AnimatedTabsList({
<TabsPrimitive.List
data-slot="animated-tabs-list"
className={cn(
"relative inline-flex items-center gap-1 rounded-md p-0",
"relative inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-md p-0 [scrollbar-width:none]",
className,
)}
onMouseLeave={(event) => {
+4 -2
View File
@@ -42,12 +42,14 @@ function AutoHeight({
return (
<Comp
style={{ overflow: "hidden", ...style }}
style={{ overflow: "hidden", maxHeight: "100%", ...style }}
animate={{ height, ...animate }}
transition={transition}
{...props}
>
<div ref={ref}>{children}</div>
<div ref={ref} className="min-h-0">
{children}
</div>
</Comp>
);
}
+1 -1
View File
@@ -61,7 +61,7 @@ const ChartContainer = React.forwardRef<
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
"flex aspect-video max-h-[min(45vh,20rem)] w-full justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
+12 -7
View File
@@ -64,13 +64,18 @@ export function Combobox({
disabled={disabled}
className={cn("w-full justify-between", className)}
>
{value
? options.find((option) => option.value === value)?.label
: resolvedPlaceholder}
<span className="truncate">
{value
? options.find((option) => option.value === value)?.label
: resolvedPlaceholder}
</span>
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent id={listboxId} className="w-full p-0">
<PopoverContent
id={listboxId}
className="w-(--radix-popover-trigger-width) p-0"
>
<Command>
<CommandInput placeholder={resolvedSearchPlaceholder} />
<CommandList>
@@ -91,10 +96,10 @@ export function Combobox({
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{option.label}</span>
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description && (
<span className="text-sm text-muted-foreground">
<span className="truncate text-sm text-muted-foreground">
{option.description}
</span>
)}
+2 -2
View File
@@ -53,7 +53,7 @@ function CommandDialog({
<DialogTitle>{resolvedTitle}</DialogTitle>
<DialogDescription>{resolvedDescription}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<DialogContent className="overflow-hidden p-0 sm:max-w-xl">
<Command
filter={filter}
shouldFilter={shouldFilter}
@@ -96,7 +96,7 @@ function CommandList({
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
"max-h-[min(50vh,500px)] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
+7 -3
View File
@@ -179,6 +179,7 @@ function SubPageContent({
gap: 12,
overflow: "auto",
background: "var(--background)",
containerType: "inline-size",
}}
>
{children}
@@ -254,7 +255,10 @@ function DialogContent({
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
}
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",
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
// gutter even when callers override max-w-*: tailwind-merge drops
// a base max-w in favor of the caller's, but leaves width alone.
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg max-h-[calc(100vh-3rem)] overflow-y-auto",
className,
)}
{...props}
@@ -282,7 +286,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn("flex flex-col gap-2 text-left pr-8", className)}
{...props}
/>
);
@@ -293,7 +297,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"flex flex-row flex-wrap justify-end gap-2 shrink-0",
className,
)}
{...props}
+3 -1
View File
@@ -224,13 +224,15 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({
className,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
collisionPadding={collisionPadding}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border p-1 shadow-lg",
className,
)}
{...props}
+3 -1
View File
@@ -21,6 +21,7 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
@@ -29,8 +30,9 @@ function PopoverContent({
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
+9 -2
View File
@@ -4,9 +4,16 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
function Table({
className,
containerClassName,
...props
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
return (
<div data-slot="table-container" className="overflow-visible w-full">
<div
data-slot="table-container"
className={cn("relative w-full overflow-x-auto", containerClassName)}
>
<table
data-slot="table"
className={cn("w-full text-sm caption-bottom", className)}
+5 -1
View File
@@ -78,7 +78,7 @@ const TabsList = React.forwardRef<
ref={ref}
data-slot="tabs-list"
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
"inline-flex h-10 max-w-full items-center justify-center overflow-x-auto rounded-md bg-muted p-1 text-muted-foreground [scrollbar-width:none]",
className,
)}
{...props}
@@ -168,6 +168,10 @@ function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
return !("mode" in props) || props.mode === "auto-height";
}
// Auto-height mode animates to a measured pixel height; in a
// height-constrained parent (e.g. a dialog capped at the viewport) the pane
// itself must carry "overflow-y-auto min-h-0" so overflow scrolls instead of
// clipping.
function TabsContents(props: TabsContentsProps) {
const { value } = useTabs();
+1 -1
View File
@@ -51,7 +51,7 @@ function TooltipContent({
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit max-w-[min(24rem,calc(100vw-2rem))] origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
+1 -1
View File
@@ -194,7 +194,7 @@ export function VpnFormDialog({
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] pr-4">
<ScrollArea className="max-h-[min(60vh,calc(100vh-15rem))] overflow-y-auto pr-4">
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
+1 -1
View File
@@ -275,7 +275,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
<div className="space-y-2">
<Label>{t("vpns.import.configPreview")}</Label>
<ScrollArea className="h-[150px] border rounded-md">
<ScrollArea className="h-[min(150px,25vh)] border rounded-md">
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
{vpnPreview.content.slice(0, 1000)}
{vpnPreview.content.length > 1000 && "..."}
+13 -13
View File
@@ -290,8 +290,8 @@ export function WayfernConfigForm({
{/* User Agent and Platform */}
<div className="space-y-3">
<Label>{t("fingerprint.userAgentAndPlatform")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 col-span-2">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2 col-span-full">
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
<Input
id="user-agent"
@@ -381,7 +381,7 @@ export function WayfernConfigForm({
{/* Hardware Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.hardwareProperties")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="hardware-concurrency">
{t("fingerprint.hardwareConcurrency")}
@@ -439,7 +439,7 @@ export function WayfernConfigForm({
{/* Screen Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.screenProperties")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-width">
{t("fingerprint.screenWidth")}
@@ -561,7 +561,7 @@ export function WayfernConfigForm({
{/* Window Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.windowProperties")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="window-outer-width">
{t("fingerprint.outerWidth")}
@@ -674,7 +674,7 @@ export function WayfernConfigForm({
{/* Language & Locale */}
<div className="space-y-3">
<Label>{t("fingerprint.languageAndLocale")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="language">
{t("fingerprint.primaryLanguage")}
@@ -756,7 +756,7 @@ export function WayfernConfigForm({
<p className="text-sm text-muted-foreground">
{t("fingerprint.timezoneGeolocationDescription")}
</p>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="timezone">
{t("fingerprint.timezoneIana")}
@@ -853,7 +853,7 @@ export function WayfernConfigForm({
{/* WebGL Properties */}
<div className="space-y-3">
<Label>{t("fingerprint.webglProperties")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">
{t("fingerprint.webglVendor")}
@@ -951,7 +951,7 @@ export function WayfernConfigForm({
{/* Audio */}
<div className="space-y-3">
<Label>{t("fingerprint.audioProperties")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="audio-sample-rate">
{t("fingerprint.sampleRate")}
@@ -994,7 +994,7 @@ export function WayfernConfigForm({
{/* Battery */}
<div className="space-y-3">
<Label>{t("fingerprint.battery")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-x-2">
<Checkbox
@@ -1040,7 +1040,7 @@ export function WayfernConfigForm({
{/* Vendor Info */}
<div className="space-y-3">
<Label>{t("fingerprint.vendorInfo")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
<Input
@@ -1114,7 +1114,7 @@ export function WayfernConfigForm({
);
return (
<div className={`space-y-6 ${className}`}>
<div className={`@container space-y-6 ${className}`}>
{forceAdvanced ? (
renderAdvancedForm()
) : (
@@ -1228,7 +1228,7 @@ export function WayfernConfigForm({
className="space-y-3"
>
<Label>{t("fingerprint.screenResolution")}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-max-width">
{t("fingerprint.maxWidth")}

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