mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-24 07:29:56 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 931d02fefd | |||
| 7b39c5dea9 | |||
| 8588a44fb5 | |||
| fe3ae13928 | |||
| 94cccc3702 | |||
| 9edc154397 | |||
| f29b161cf4 | |||
| 4007dedcf0 | |||
| 50d2834634 | |||
| f8791a9ec5 | |||
| 4598b22af1 | |||
| 4ac4c6e8a9 | |||
| 5a82b18fb8 | |||
| 5fada3f929 | |||
| 828a604c9d | |||
| 02328e59a2 | |||
| 577ab79fd0 | |||
| 8c221d02fe | |||
| e1b79037bf | |||
| 57036bdc95 | |||
| d3169ad7a9 | |||
| e1fcfd5403 | |||
| 9dc9e13182 | |||
| c5a168ae0f | |||
| 168b7ac6d4 | |||
| e5910ad5cf | |||
| 202f2c852b | |||
| 5a8864654d |
@@ -31,10 +31,10 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@76c631d198f9ff620e15468e45f3457d50481b57 #v1.16.2
|
||||
uses: anomalyco/opencode/github@11e47f91496005aab4d7c5a2d0a7da5d2651b4ac #v1.17.8
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -105,10 +105,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -148,12 +148,12 @@ jobs:
|
||||
- name: Verify frontend dist exists
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "Error: dist directory not found after build"
|
||||
ls -la
|
||||
if [ ! -f "dist/index.html" ]; then
|
||||
echo "Error: dist/index.html not found after build (static export incomplete)"
|
||||
ls -la dist 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
echo "Frontend dist directory verified at $(pwd)/dist"
|
||||
echo "Frontend dist verified at $(pwd)/dist (index.html present)"
|
||||
echo "Checking from src-tauri perspective:"
|
||||
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
|
||||
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -454,7 +454,7 @@ jobs:
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -552,7 +552,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.27.0 (2026-06-17)
|
||||
|
||||
### Features
|
||||
|
||||
- amek window resizable
|
||||
|
||||
### Refactoring
|
||||
|
||||
- better tray icon
|
||||
- simplify socks connection
|
||||
- switch local proxy from http to socks
|
||||
|
||||
### Documentation
|
||||
|
||||
- readme
|
||||
- readme
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- ci(deps): bump anomalyco/opencode in the github-actions group (#437)
|
||||
- chore: update flake.nix for v0.26.0 [skip ci] (#428)
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Contributing to Donut Browser
|
||||
|
||||
Contributions are welcome! To start working on an issue, leave a comment indicating you're taking it on.
|
||||
Contributions are welcome! Please do not create PRs for the sake of being added to the contributors list. Reviewing PRs takes time, so please create PRs only if you believe that your change will improve Donut for yourself and others. If you are thinking of making a significant change, please get in touch with the maintainer first.
|
||||
|
||||
## Before Starting
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## Features
|
||||
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), 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.3/Donut_0.25.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.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.3/Donut_0.25.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut-0.25.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut-0.25.3-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -178,6 +178,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<br />
|
||||
<sub><b>Thiago Mafra</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/liasica">
|
||||
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
|
||||
<br />
|
||||
<sub><b>liasica</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
|
||||
+10
-10
@@ -18,30 +18,30 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@aws-sdk/client-s3": "^3.1073.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1073.0",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/platform-express": "^11.1.27",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@nestjs/testing": "^11.1.27",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-loader": "^9.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.25.3";
|
||||
releaseVersion = "0.27.0";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.AppImage";
|
||||
hash = "sha256-GB+HMfMQuZj0YYibiyCD64u6o943anSI/1jyD36YJq4=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage";
|
||||
hash = "sha256-b9jY+SPw+5UvvTKgXmvxLJjIbrLW6kHTVeZywJA6DFE=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.AppImage";
|
||||
hash = "sha256-IKpz8AI3uM4+VxiF+8fwhj/mLn0KZW1KQMo3lGCTO8g=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage";
|
||||
hash = "sha256-UyK3p88kx3JkJmQ9Jv1hQGmfLbG1YZDuF2pZ1h529sQ=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+32
-32
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.26.0",
|
||||
"version": "0.27.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -32,22 +32,22 @@
|
||||
"precargo": "pnpm copy-proxy-binary"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-portal": "^1.1.10",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.3.5",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-portal": "^1.1.12",
|
||||
"@radix-ui/react-progress": "^1.1.10",
|
||||
"@radix-ui/react-radio-group": "^1.4.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.12",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "~2.11.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@tauri-apps/api": "~2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
@@ -61,17 +61,17 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"i18next": "^26.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "^16.2.6",
|
||||
"framer-motion": "^12.40.0",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"motion": "^12.40.0",
|
||||
"next": "^16.2.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"onborda": "^1.2.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -79,17 +79,17 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tauri-apps/cli": "~2.11.1",
|
||||
"@biomejs/biome": "2.5.0",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tauri-apps/cli": "~2.11.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3"
|
||||
|
||||
Generated
+2577
-2927
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,10 @@ overrides:
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
multer@>=2.0.0 <2.2.0: '>=2.2.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0 <5'
|
||||
'@babel/core@<7.29.6': '>=7.29.6 <8'
|
||||
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
|
||||
@@ -113,8 +113,11 @@ for arch in amd64 arm64; do
|
||||
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
|
||||
|
||||
# dpkg-scanpackages needs to run from the repo root
|
||||
# and needs paths relative to that root
|
||||
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
|
||||
# and needs paths relative to that root.
|
||||
# -m / --multiversion keeps every version present in the pool in the index
|
||||
# (without it only the newest is listed, making older releases uninstallable
|
||||
# via apt — createrepo_c already keeps all versions for the RPM repo).
|
||||
(cd "$DEB_DIR" && dpkg-scanpackages -m --arch "$arch" pool/main) \
|
||||
> "$BINARY_DIR/Packages"
|
||||
|
||||
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
|
||||
|
||||
@@ -44,7 +44,17 @@ if (!cmd) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
|
||||
// On Windows, npm-installed bins (e.g. `tauri`) are `.cmd` shims that cannot be
|
||||
// launched with `shell: false` — Node refuses to exec a batch file directly and
|
||||
// the spawn fails with ENOENT/EINVAL. Run through the shell on Windows (cmd.exe
|
||||
// resolves `tauri.cmd`); macOS/Linux keep `shell: false`, where the bin is a
|
||||
// directly-executable script. Under the Windows shell, quote args containing
|
||||
// whitespace so paths with spaces aren't split into multiple arguments.
|
||||
const isWindows = process.platform === "win32";
|
||||
const spawnArgs = isWindows
|
||||
? args.map((a) => (/\s/.test(a) ? `"${a}"` : a))
|
||||
: args;
|
||||
const child = spawn(cmd, spawnArgs, { stdio: "inherit", shell: isWindows });
|
||||
child.on("error", (err) => {
|
||||
console.error(`Failed to spawn ${cmd}:`, err.message);
|
||||
process.exit(1);
|
||||
|
||||
Generated
+296
-412
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.26.0"
|
||||
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"
|
||||
|
||||
@@ -72,7 +73,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
rand = "0.10.1"
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.2"
|
||||
@@ -144,7 +145,7 @@ hyper = { version = "1.10", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tower-http = { version = "0.7", features = ["fs", "trace"] }
|
||||
futures-util = "0.3"
|
||||
serial_test = "3"
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -244,6 +244,52 @@ struct ImportCookiesResponse {
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchRunRequest {
|
||||
/// Profile IDs to launch.
|
||||
profile_ids: Vec<String>,
|
||||
/// Optional URL to open in every launched profile.
|
||||
url: Option<String>,
|
||||
/// Launch headless. Defaults to false.
|
||||
headless: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchRunResult {
|
||||
profile_id: String,
|
||||
/// Whether this profile launched successfully.
|
||||
ok: bool,
|
||||
/// Remote debugging port if launched, otherwise null.
|
||||
remote_debugging_port: Option<u16>,
|
||||
/// Failure reason if not launched, otherwise null.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchRunResponse {
|
||||
results: Vec<BatchRunResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchStopRequest {
|
||||
/// Profile IDs to stop.
|
||||
profile_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchStopResult {
|
||||
profile_id: String,
|
||||
/// Whether this profile was stopped successfully.
|
||||
ok: bool,
|
||||
/// Failure reason if not stopped, otherwise null.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchStopResponse {
|
||||
results: Vec<BatchStopResult>,
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
@@ -255,6 +301,8 @@ struct ImportCookiesResponse {
|
||||
run_profile,
|
||||
open_url_in_profile,
|
||||
kill_profile,
|
||||
batch_run_profiles,
|
||||
batch_stop_profiles,
|
||||
import_profile_cookies,
|
||||
get_groups,
|
||||
get_group,
|
||||
@@ -297,6 +345,12 @@ struct ImportCookiesResponse {
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
RunProfileRequest,
|
||||
BatchRunRequest,
|
||||
BatchRunResult,
|
||||
BatchRunResponse,
|
||||
BatchStopRequest,
|
||||
BatchStopResult,
|
||||
BatchStopResponse,
|
||||
OpenUrlRequest,
|
||||
ImportCookiesRequest,
|
||||
ImportCookiesResponse,
|
||||
@@ -396,6 +450,8 @@ impl ApiServer {
|
||||
.routes(routes!(run_profile))
|
||||
.routes(routes!(open_url_in_profile))
|
||||
.routes(routes!(kill_profile))
|
||||
.routes(routes!(batch_run_profiles))
|
||||
.routes(routes!(batch_stop_profiles))
|
||||
.routes(routes!(import_profile_cookies))
|
||||
.routes(routes!(get_groups, create_group))
|
||||
.routes(routes!(get_group, update_group, delete_group))
|
||||
@@ -1951,6 +2007,170 @@ async fn kill_profile(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// API Handler - Batch run profiles (paid: browser automation). Mirrors the
|
||||
// single `/run` gate; never breaks the batch on a single profile's failure —
|
||||
// each profile gets its own result entry.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/batch/run",
|
||||
request_body = BatchRunRequest,
|
||||
responses(
|
||||
(status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "profiles"
|
||||
)]
|
||||
async fn batch_run_profiles(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<BatchRunRequest>,
|
||||
) -> Result<Json<BatchRunResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let headless = request.headless.unwrap_or(false);
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
for profile_id in &request.profile_ids {
|
||||
let fail = |error: &str| BatchRunResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
remote_debugging_port: None,
|
||||
error: Some(error.to_string()),
|
||||
};
|
||||
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
results.push(fail("profile not found"));
|
||||
continue;
|
||||
};
|
||||
if profile.is_cross_os() {
|
||||
results.push(fail("cross-OS profiles cannot be launched"));
|
||||
continue;
|
||||
}
|
||||
if crate::team_lock::acquire_team_lock_if_needed(profile)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
results.push(fail("profile is locked by another team member"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let port = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
||||
Ok(listener) => match listener.local_addr() {
|
||||
Ok(addr) => addr.port(),
|
||||
Err(_) => {
|
||||
results.push(fail("failed to allocate debugging port"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
results.push(fail("failed to allocate debugging port"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
request.url.clone(),
|
||||
Some(port),
|
||||
headless,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => results.push(BatchRunResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: true,
|
||||
remote_debugging_port: Some(port),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => results.push(fail(&format!("launch failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(BatchRunResponse { results }))
|
||||
}
|
||||
|
||||
// API Handler - Batch stop profiles (paid: browser automation).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/batch/stop",
|
||||
request_body = BatchStopRequest,
|
||||
responses(
|
||||
(status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "profiles"
|
||||
)]
|
||||
async fn batch_stop_profiles(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<BatchStopRequest>,
|
||||
) -> Result<Json<BatchStopResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
for profile_id in &request.profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
error: Some("profile not found".to_string()),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
match browser_runner
|
||||
.kill_browser_process(state.app_handle.clone(), profile)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: true,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
error: Some(format!("stop failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(BatchStopResponse { results }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/cookies/import",
|
||||
|
||||
@@ -1492,7 +1492,7 @@ impl AppAutoUpdater {
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/bin/sh
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
@@ -1521,7 +1521,7 @@ rm "{}"
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
@@ -1668,7 +1668,7 @@ rm "{}"
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/bin/sh
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
@@ -1697,7 +1697,7 @@ rm "{}"
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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| {
|
||||
@@ -404,6 +409,10 @@ impl BrowserRunner {
|
||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||
}
|
||||
|
||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||
// when this browser dies, even after the GUI exits/restarts.
|
||||
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
|
||||
|
||||
// Save the updated profile (includes new fingerprint if randomize is enabled)
|
||||
log::info!(
|
||||
"Saving profile {} with camoufox_config fingerprint length: {}",
|
||||
@@ -527,6 +536,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 +549,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);
|
||||
@@ -685,6 +700,10 @@ impl BrowserRunner {
|
||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||
}
|
||||
|
||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||
// when this browser dies, even after the GUI exits/restarts.
|
||||
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
|
||||
|
||||
// Save the updated profile
|
||||
log::info!(
|
||||
"Saving profile {} with wayfern_config fingerprint length: {}",
|
||||
|
||||
@@ -85,7 +85,11 @@ impl GroupManager {
|
||||
|
||||
// Check if group with this name already exists
|
||||
if groups_data.groups.iter().any(|g| g.name == name) {
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
@@ -131,14 +135,18 @@ impl GroupManager {
|
||||
.iter()
|
||||
.any(|g| g.name == name && g.id != id)
|
||||
{
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let group = groups_data
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == id)
|
||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?;
|
||||
|
||||
group.name = name;
|
||||
group.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
@@ -204,7 +212,11 @@ impl GroupManager {
|
||||
let initial_len = groups_data.groups.len();
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_NOT_FOUND" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(())
|
||||
@@ -229,7 +241,11 @@ impl GroupManager {
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_NOT_FOUND" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
@@ -334,7 +350,7 @@ pub async fn create_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(&app_handle, name)
|
||||
.map_err(|e| format!("Failed to create group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -346,7 +362,7 @@ pub async fn update_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(&app_handle, group_id, name)
|
||||
.map_err(|e| format!("Failed to update group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -357,7 +373,7 @@ pub async fn delete_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(&app_handle, group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+62
-6
@@ -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
|
||||
|
||||
@@ -564,6 +564,44 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "batch_run_profiles".to_string(),
|
||||
description: "Launch multiple browser profiles at once with an optional URL. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "UUIDs of the profiles to launch"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Optional URL to open in every launched profile"
|
||||
},
|
||||
"headless": {
|
||||
"type": "boolean",
|
||||
"description": "Run the browsers in headless mode"
|
||||
}
|
||||
},
|
||||
"required": ["profile_ids"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "batch_stop_profiles".to_string(),
|
||||
description: "Stop multiple running browser profiles at once. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "UUIDs of the profiles to stop"
|
||||
}
|
||||
},
|
||||
"required": ["profile_ids"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "create_profile".to_string(),
|
||||
description: "Create a new browser profile".to_string(),
|
||||
@@ -1676,6 +1714,22 @@ impl McpServer {
|
||||
.await?;
|
||||
self.handle_kill_profile(arguments).await
|
||||
}
|
||||
"batch_run_profiles" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_batch_run_profiles(arguments).await
|
||||
}
|
||||
"batch_stop_profiles" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_batch_stop_profiles(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,
|
||||
@@ -2062,6 +2116,169 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_batch_run_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch launching profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = arguments
|
||||
.get("profile_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_ids array".to_string(),
|
||||
})?;
|
||||
|
||||
let url = arguments.get("url").and_then(|v| v.as_str());
|
||||
let headless = arguments
|
||||
.get("headless")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
// Clone the app handle and release the lock before the launch loop so we
|
||||
// never hold the inner mutex across the per-profile awaits.
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut launched = 0usize;
|
||||
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
|
||||
for profile_id in &profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
lines.push(format!("{profile_id}: not found"));
|
||||
continue;
|
||||
};
|
||||
if profile.browser != "wayfern" && profile.browser != "camoufox" {
|
||||
lines.push(format!(
|
||||
"{profile_id}: unsupported browser (MCP supports Wayfern/Camoufox)"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = crate::team_lock::acquire_team_lock_if_needed(profile).await {
|
||||
lines.push(format!("{profile_id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
app_handle.clone(),
|
||||
profile.clone(),
|
||||
url.map(|s| s.to_string()),
|
||||
None,
|
||||
headless,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
launched += 1;
|
||||
lines.push(format!("{}: launched", profile.name));
|
||||
}
|
||||
Err(e) => lines.push(format!("{}: launch failed: {e}", profile.name)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Launched {}/{} profile(s):\n{}", launched, profile_ids.len(), lines.join("\n"))
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_batch_stop_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch stopping profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = arguments
|
||||
.get("profile_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_ids array".to_string(),
|
||||
})?;
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut stopped = 0usize;
|
||||
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
|
||||
for profile_id in &profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
lines.push(format!("{profile_id}: not found"));
|
||||
continue;
|
||||
};
|
||||
match crate::browser_runner::BrowserRunner::instance()
|
||||
.kill_browser_process(app_handle.clone(), profile)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
stopped += 1;
|
||||
lines.push(format!("{}: stopped", profile.name));
|
||||
}
|
||||
Err(e) => lines.push(format!("{}: stop failed: {e}", profile.name)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Stopped {}/{} profile(s):\n{}", stopped, profile_ids.len(), lines.join("\n"))
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_create_profile(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
|
||||
@@ -634,19 +634,25 @@ pub mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Linux-specific environment variables for better compatibility
|
||||
cmd.env(
|
||||
"DISPLAY",
|
||||
std::env::var("DISPLAY").unwrap_or(":0".to_string()),
|
||||
);
|
||||
// Propagate DISPLAY only when this session actually has an X11 display.
|
||||
// Forcing DISPLAY=:0 breaks Wayland-only sessions (there is no X server on
|
||||
// :0, so any X11 client launched with it set will fail to connect). When
|
||||
// DISPLAY is set the child already inherits it from our environment, so
|
||||
// setting it explicitly here is purely defensive; when it's unset we leave
|
||||
// it unset and let the browser use Wayland.
|
||||
if let Ok(display) = std::env::var("DISPLAY") {
|
||||
cmd.env("DISPLAY", display);
|
||||
}
|
||||
|
||||
// Set MOZ_ENABLE_WAYLAND for better Wayland support
|
||||
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
||||
cmd.env("MOZ_ENABLE_WAYLAND", "1");
|
||||
}
|
||||
|
||||
// Disable GPU acceleration if running in headless environments
|
||||
if std::env::var("DISPLAY").is_err() || std::env::var("WAYLAND_DISPLAY").is_err() {
|
||||
// Warn only when running truly headless — i.e. NEITHER X11 nor Wayland is
|
||||
// available. Using OR here would fire on every normal Wayland-only session
|
||||
// (DISPLAY unset) or X11-only session (WAYLAND_DISPLAY unset).
|
||||
if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() {
|
||||
log::info!("No display detected, browser may fail to start");
|
||||
}
|
||||
|
||||
|
||||
@@ -1478,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,
|
||||
@@ -1486,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
|
||||
@@ -1519,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,
|
||||
@@ -1559,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,
|
||||
@@ -1618,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
|
||||
@@ -1709,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,
|
||||
@@ -1852,6 +1860,38 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the real browser PID onto the worker's on-disk config so the
|
||||
/// detached worker can self-terminate when that browser dies, independent of
|
||||
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the
|
||||
/// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during
|
||||
/// launch that collides across concurrent launches, which could tag a live
|
||||
/// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse
|
||||
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
|
||||
/// of 0 (launch failed to report a PID) is ignored so the worker never
|
||||
/// self-exits against a bogus PID.
|
||||
pub fn set_browser_pid_for_profile(&self, profile_id: &str, browser_pid: u32) {
|
||||
if browser_pid == 0 {
|
||||
return;
|
||||
}
|
||||
let proxy_id = {
|
||||
let map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
match map.get(profile_id) {
|
||||
Some(id) => id.clone(),
|
||||
None => return, // No local worker for this profile — nothing to tag.
|
||||
}
|
||||
};
|
||||
if let Some(mut cfg) = crate::proxy_storage::get_proxy_config(&proxy_id) {
|
||||
cfg.browser_pid = Some(browser_pid);
|
||||
if crate::proxy_storage::update_proxy_config(&cfg) {
|
||||
log::info!(
|
||||
"Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping"
|
||||
);
|
||||
} else {
|
||||
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up proxies for dead browser processes
|
||||
// Only clean up orphaned config files where the proxy process itself is dead
|
||||
pub async fn cleanup_dead_proxies(
|
||||
@@ -2885,6 +2925,8 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2896,6 +2938,8 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2935,6 +2979,8 @@ 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,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3253,6 +3299,8 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+511
-75
@@ -7,13 +7,13 @@ use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use regex_lite::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpStream;
|
||||
@@ -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 {
|
||||
@@ -326,19 +326,15 @@ async fn handle_connect(
|
||||
let port = upstream.port().unwrap_or(1080);
|
||||
let socks_addr = format!("{}:{}", host, port);
|
||||
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
|
||||
|
||||
match connect_via_socks(
|
||||
&socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
scheme == "socks5",
|
||||
if !username.is_empty() {
|
||||
Some((username, password))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
auth,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -378,7 +374,12 @@ async fn connect_via_http_proxy(
|
||||
) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let proxy_port = upstream.port().unwrap_or(8080);
|
||||
let mut stream = TcpStream::connect((proxy_host, proxy_port)).await?;
|
||||
let mut stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
TcpStream::connect((proxy_host, proxy_port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out"))??;
|
||||
|
||||
// Add proxy authentication if provided
|
||||
let mut connect_req = format!(
|
||||
@@ -386,10 +387,9 @@ async fn connect_via_http_proxy(
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
|
||||
if !upstream.username().is_empty() {
|
||||
let (username, password) = upstream_userpass(upstream);
|
||||
if !username.is_empty() {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
|
||||
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
|
||||
}
|
||||
@@ -399,7 +399,9 @@ async fn connect_via_http_proxy(
|
||||
stream.write_all(connect_req.as_bytes()).await?;
|
||||
|
||||
let mut buffer = [0u8; 4096];
|
||||
let n = stream.read(&mut buffer).await?;
|
||||
let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, stream.read(&mut buffer))
|
||||
.await
|
||||
.map_err(|_| "upstream proxy CONNECT response timed out")??;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") {
|
||||
@@ -409,6 +411,96 @@ async fn connect_via_http_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract percent-decoded (username, password) from the upstream URL.
|
||||
///
|
||||
/// `url::Url::username()` / `Url::password()` return percent-encoded ASCII
|
||||
/// strings per the WHATWG spec. `build_proxy_url` on the producer side
|
||||
/// already percent-encodes the credentials with `urlencoding::encode`, so
|
||||
/// we must decode here — otherwise the upstream SOCKS5 / HTTP CONNECT
|
||||
/// receives `%40` instead of `@`, breaking RFC1929 user/password
|
||||
/// authentication or HTTP Basic-Auth
|
||||
fn upstream_userpass(upstream: &Url) -> (String, String) {
|
||||
let username = urlencoding::decode(upstream.username())
|
||||
.map(|cow| cow.into_owned())
|
||||
.unwrap_or_default();
|
||||
let password = urlencoding::decode(upstream.password().unwrap_or(""))
|
||||
.map(|cow| cow.into_owned())
|
||||
.unwrap_or_default();
|
||||
(username, password)
|
||||
}
|
||||
|
||||
/// Transparent AsyncRead/AsyncWrite wrapper that logs every read/write
|
||||
/// byte of the SOCKS5 handshake. Used only during the handshake — the
|
||||
/// inner stream is taken back via `into_inner` once the handshake
|
||||
/// completes, so the tunnel phase pays no overhead
|
||||
struct SocksHandshakeLogger<S> {
|
||||
inner: S,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl<S> SocksHandshakeLogger<S> {
|
||||
fn new(inner: S, label: String) -> Self {
|
||||
Self { inner, label }
|
||||
}
|
||||
|
||||
fn into_inner(self) -> S {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for SocksHandshakeLogger<S> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let before = buf.filled().len();
|
||||
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &result {
|
||||
let after = buf.filled().len();
|
||||
if after > before {
|
||||
let bytes = &buf.filled()[before..after];
|
||||
log::trace!(
|
||||
"[socks-handshake:{}] <- {} byte(s): {:02x?}",
|
||||
self.label,
|
||||
bytes.len(),
|
||||
bytes
|
||||
);
|
||||
} else {
|
||||
log::trace!("[socks-handshake:{}] <- EOF (peer closed)", self.label);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for SocksHandshakeLogger<S> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
|
||||
if let Poll::Ready(Ok(n)) = &result {
|
||||
log::trace!(
|
||||
"[socks-handshake:{}] -> {} byte(s): {:02x?}",
|
||||
self.label,
|
||||
n,
|
||||
&buf[..*n]
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_via_socks(
|
||||
socks_addr: &str,
|
||||
target_host: &str,
|
||||
@@ -416,7 +508,9 @@ async fn connect_via_socks(
|
||||
is_socks5: bool,
|
||||
auth: Option<(&str, &str)>,
|
||||
) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
let mut stream = TcpStream::connect(socks_addr).await?;
|
||||
let stream = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, TcpStream::connect(socks_addr))
|
||||
.await
|
||||
.map_err(|_| format!("SOCKS upstream connect to {socks_addr} timed out"))??;
|
||||
|
||||
if is_socks5 {
|
||||
// SOCKS5 connection using async_socks5
|
||||
@@ -433,9 +527,52 @@ async fn connect_via_socks(
|
||||
password: pass.to_string(),
|
||||
});
|
||||
|
||||
connect(&mut stream, target, auth_info).await?;
|
||||
Ok(stream)
|
||||
let has_auth = auth_info.is_some();
|
||||
log::trace!(
|
||||
"[socks-handshake] dialing {} (target={}:{}, has_auth={})",
|
||||
socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
has_auth
|
||||
);
|
||||
|
||||
// Disable Nagle so the kernel doesn't further delay/coalesce the
|
||||
// syscalls issued when BufStream flushes
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
// BufStream wrapping is required: async_socks5 calls write_u8 for every
|
||||
// single-byte SOCKS5 / RFC1929 field, and on a raw TcpStream each call
|
||||
// becomes its own TCP segment. Some upstream SOCKS5 implementations
|
||||
// treat such a "fragmented auth submission" as a misbehaving client
|
||||
// and silently FIN instead of returning an RFC1929 status. BufStream
|
||||
// coalesces those small writes into one syscall on flush — this is
|
||||
// the usage pattern shown in the async_socks5 README
|
||||
let label = format!("{socks_addr}->{target_host}:{target_port}");
|
||||
let logged = SocksHandshakeLogger::new(stream, label);
|
||||
let mut buffered = tokio::io::BufStream::new(logged);
|
||||
let handshake = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
connect(&mut buffered, target, auth_info),
|
||||
)
|
||||
.await;
|
||||
// Unwrap the layered stream: BufStream → SocksHandshakeLogger → TcpStream
|
||||
let stream = buffered.into_inner().into_inner();
|
||||
match handshake {
|
||||
Ok(Ok(_)) => {
|
||||
log::trace!("[socks-handshake] handshake completed ok");
|
||||
Ok(stream)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::trace!("[socks-handshake] handshake failed: {:?}", e);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(_) => {
|
||||
log::trace!("[socks-handshake] handshake timed out");
|
||||
Err("SOCKS5 upstream handshake timed out".into())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut stream = stream;
|
||||
// SOCKS4 - simplified implementation
|
||||
let ip: std::net::IpAddr = target_host.parse()?;
|
||||
|
||||
@@ -1140,7 +1277,16 @@ pub async fn handle_proxy_connection(
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("CONNECT tunnel ended with error: {e}");
|
||||
let msg = e.to_string();
|
||||
if let Some(suppressed) = log_throttle(&msg) {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"CONNECT tunnel ended with error: {msg} ({suppressed} more suppressed in last 30s)"
|
||||
);
|
||||
} else {
|
||||
log::warn!("CONNECT tunnel ended with error: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1247,10 +1393,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");
|
||||
@@ -1350,6 +1505,48 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
}
|
||||
});
|
||||
|
||||
// Self-reaping supervisor. The worker is a detached process that outlives the
|
||||
// GUI, so it cannot rely on the GUI's in-memory death-monitor (which is lost
|
||||
// when the GUI restarts). Once the GUI records the browser PID this worker
|
||||
// serves, poll it and exit when that browser is gone — never while it is
|
||||
// alive, and never before a PID is recorded (covers the launch window and
|
||||
// pre-upgrade configs lacking the field). A 2-miss debounce avoids exiting on
|
||||
// a transient sysinfo false-negative under load / sleep-wake.
|
||||
{
|
||||
let watch_id = config.id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(15));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut consecutive_misses: u32 = 0;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::proxy_storage::get_proxy_config(&watch_id) {
|
||||
Some(cfg) => match cfg.browser_pid {
|
||||
Some(bpid) if bpid != 0 => {
|
||||
if crate::proxy_storage::is_process_running(bpid) {
|
||||
consecutive_misses = 0;
|
||||
} else {
|
||||
consecutive_misses += 1;
|
||||
if consecutive_misses >= 2 {
|
||||
log::info!("Browser PID {bpid} for config {watch_id} is gone; worker exiting");
|
||||
crate::proxy_storage::delete_proxy_config(&watch_id);
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No browser PID recorded yet (launch window / old config): keep running.
|
||||
_ => consecutive_misses = 0,
|
||||
},
|
||||
// Our own config was removed (e.g. GUI stopped us): nothing to serve.
|
||||
None => {
|
||||
log::info!("Proxy config {watch_id} was removed; worker exiting");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
|
||||
let blocklist_matcher = if let Some(ref path) = config.blocklist_file {
|
||||
match BlocklistMatcher::from_file(path) {
|
||||
@@ -1363,17 +1560,40 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
BlocklistMatcher::new()
|
||||
};
|
||||
|
||||
// Bound concurrent connection handlers. A client retry-storm (e.g. a browser
|
||||
// hammering CONNECT requests while DNS is failing) must not spawn unbounded
|
||||
// tasks,
|
||||
// each of which parks a Tokio blocking thread inside getaddrinfo — that is
|
||||
// what exhausted the resolver pool and pegged the CPU on long-lived workers.
|
||||
// A real browser never approaches this ceiling; waiting for a permit
|
||||
// backpressures a storm instead of amplifying it.
|
||||
let conn_semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
|
||||
|
||||
// Keep the runtime alive with an infinite loop
|
||||
// This ensures the process doesn't exit even if there are no active connections
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _peer_addr)) => {
|
||||
// The semaphore is never closed, so acquire cannot fail.
|
||||
let permit = conn_semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("connection semaphore is never closed");
|
||||
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 {
|
||||
let _permit = permit;
|
||||
crate::socks5_local::handle_socks5_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
} else {
|
||||
tokio::task::spawn(async move {
|
||||
let _permit = permit;
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error accepting connection: {:?}", e);
|
||||
@@ -1436,7 +1656,7 @@ async fn handle_connect_from_buffer(
|
||||
tracker.record_request(&domain, 0, 0);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
log::debug!(
|
||||
"CONNECT {}:{} (upstream={})",
|
||||
target_host,
|
||||
target_port,
|
||||
@@ -1444,29 +1664,186 @@ 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(())
|
||||
}
|
||||
|
||||
/// Upper bound on concurrent connection handlers per worker. A real browser
|
||||
/// never holds anywhere near this many simultaneous tunnels; the cap stops a
|
||||
/// client retry-storm from spawning unbounded tasks (each of which parks a
|
||||
/// Tokio blocking thread inside getaddrinfo).
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
|
||||
|
||||
/// Connect timeout for the direct (no-upstream) dial path. Bounds a wedged
|
||||
/// `getaddrinfo` so a broken resolver can't park a blocking thread for the
|
||||
/// full OS timeout.
|
||||
const DIRECT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Overall timeout for dialing an UPSTREAM proxy (TCP connect + CONNECT/SOCKS/SS
|
||||
/// handshake). Without it, an upstream that accepts TCP but stalls before
|
||||
/// replying hangs the worker task forever and holds a connection slot; under
|
||||
/// load (e.g. two profiles sharing one proxy) the slots exhaust and the browser
|
||||
/// sees `ERR_PROXY_CONNECTION_FAILED` until the profile is restarted (issue
|
||||
/// #439). A bounded dial fails fast and releases the slot.
|
||||
const UPSTREAM_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Per-host failure state (last failure instant, consecutive failure count) for
|
||||
/// the direct dial path. Process-global — each worker is its own process.
|
||||
fn direct_dial_failures() -> &'static Mutex<HashMap<String, (std::time::Instant, u32)>> {
|
||||
static M: OnceLock<Mutex<HashMap<String, (std::time::Instant, u32)>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// If `host` is inside its failure backoff window, return the remaining time so
|
||||
/// the caller can short-circuit without a fresh getaddrinfo/connect. Never
|
||||
/// mutates state, so the window always expires and the path self-heals once
|
||||
/// DNS recovers.
|
||||
fn direct_backoff_remaining(host: &str) -> Option<std::time::Duration> {
|
||||
let map = direct_dial_failures();
|
||||
let guard = map.lock().unwrap();
|
||||
let (last, fails) = guard.get(host).copied()?;
|
||||
// Exponential window capped at 30s: 2, 4, 8, 16, 30, 30, ...
|
||||
let window = std::time::Duration::from_secs((1u64 << fails.min(5)).min(30));
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed < window {
|
||||
Some(window - elapsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a direct-dial failure for `host`, growing its backoff window.
|
||||
fn direct_backoff_record(host: &str) {
|
||||
let map = direct_dial_failures();
|
||||
let mut guard = map.lock().unwrap();
|
||||
// Bound memory against a page that emits many distinct failing hosts.
|
||||
if guard.len() > 2048 {
|
||||
guard.retain(|_, (last, _)| last.elapsed() < std::time::Duration::from_secs(60));
|
||||
}
|
||||
let entry = guard
|
||||
.entry(host.to_string())
|
||||
.or_insert_with(|| (std::time::Instant::now(), 0));
|
||||
entry.0 = std::time::Instant::now();
|
||||
entry.1 = entry.1.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Clear `host`'s failure state after a successful dial.
|
||||
fn direct_backoff_clear(host: &str) {
|
||||
direct_dial_failures().lock().unwrap().remove(host);
|
||||
}
|
||||
|
||||
/// Dial a target directly (no upstream) with a connect timeout and per-host
|
||||
/// failure backoff. This is the server-side counterpart to the browser's
|
||||
/// instant client-side retry: when a host's DNS/connect is failing (e.g. the
|
||||
/// macOS resolver wedges after sleep/wake), repeated CONNECT requests
|
||||
/// short-circuit
|
||||
/// here instead of each spawning a fresh blocking getaddrinfo — which is what
|
||||
/// let a retry-storm exhaust the blocking thread pool and peg the CPU.
|
||||
async fn dial_direct(host: &str, port: u16) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
if let Some(remaining) = direct_backoff_remaining(host) {
|
||||
return Err(
|
||||
format!(
|
||||
"skipping direct dial to {host}: backing off ~{}s after repeated connect failures",
|
||||
remaining.as_secs().max(1)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match tokio::time::timeout(DIRECT_CONNECT_TIMEOUT, TcpStream::connect((host, port))).await {
|
||||
Ok(Ok(stream)) => {
|
||||
let _ = stream.set_nodelay(true);
|
||||
direct_backoff_clear(host);
|
||||
Ok(stream)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
direct_backoff_record(host);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(_) => {
|
||||
direct_backoff_record(host);
|
||||
Err(
|
||||
format!(
|
||||
"direct connect to {host}:{port} timed out after {}s",
|
||||
DIRECT_CONNECT_TIMEOUT.as_secs()
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate-limit a repetitive log line keyed by `key`: returns `Some(suppressed)`
|
||||
/// when the caller should emit (first time or after a 30s window, with the
|
||||
/// count dropped since the last emit), or `None` to skip. Stops a connect/DNS
|
||||
/// storm from writing the same WARN millions of times (the line that grew
|
||||
/// worker logs to 100MB).
|
||||
pub(crate) fn log_throttle(key: &str) -> Option<u64> {
|
||||
fn throttle_map() -> &'static Mutex<HashMap<String, (std::time::Instant, u64)>> {
|
||||
static M: OnceLock<Mutex<HashMap<String, (std::time::Instant, u64)>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
let map = throttle_map();
|
||||
let mut guard = map.lock().unwrap();
|
||||
if guard.len() > 2048 {
|
||||
guard.retain(|_, (last, _)| last.elapsed() < std::time::Duration::from_secs(60));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
match guard.get_mut(key) {
|
||||
Some((last, suppressed)) => {
|
||||
if now.duration_since(*last) >= std::time::Duration::from_secs(30) {
|
||||
let dropped = *suppressed;
|
||||
*last = now;
|
||||
*suppressed = 0;
|
||||
Some(dropped)
|
||||
} else {
|
||||
*suppressed += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
guard.insert(key.to_string(), (now, 0));
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
None => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
Some(url) if url == "DIRECT" => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
_ if should_bypass => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
let target_stream: BoxedAsyncStream = match upstream_url {
|
||||
None | Some("DIRECT") => Box::new(dial_direct(target_host, target_port).await?),
|
||||
_ if should_bypass => Box::new(dial_direct(target_host, target_port).await?),
|
||||
Some(upstream_url_str) => {
|
||||
let upstream = Url::parse(upstream_url_str)?;
|
||||
let scheme = upstream.scheme();
|
||||
@@ -1475,7 +1852,14 @@ async fn handle_connect_from_buffer(
|
||||
"http" | "https" => {
|
||||
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let proxy_port = upstream.port().unwrap_or(8080);
|
||||
let mut proxy_stream = TcpStream::connect((proxy_host, proxy_port)).await?;
|
||||
let mut proxy_stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
TcpStream::connect((proxy_host, proxy_port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out")
|
||||
})??;
|
||||
configure_tcp(&proxy_stream);
|
||||
|
||||
let mut connect_req = format!(
|
||||
@@ -1483,10 +1867,9 @@ async fn handle_connect_from_buffer(
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
|
||||
if !upstream.username().is_empty() {
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
if !username.is_empty() {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
|
||||
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
|
||||
}
|
||||
@@ -1496,7 +1879,9 @@ async fn handle_connect_from_buffer(
|
||||
proxy_stream.write_all(connect_req.as_bytes()).await?;
|
||||
|
||||
let mut buffer = [0u8; 4096];
|
||||
let n = proxy_stream.read(&mut buffer).await?;
|
||||
let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, proxy_stream.read(&mut buffer))
|
||||
.await
|
||||
.map_err(|_| "upstream proxy CONNECT response timed out")??;
|
||||
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||
let status_line = response_full.lines().next().unwrap_or("").to_string();
|
||||
|
||||
@@ -1544,19 +1929,15 @@ async fn handle_connect_from_buffer(
|
||||
let socks_port = upstream.port().unwrap_or(1080);
|
||||
let socks_addr = format!("{}:{}", socks_host, socks_port);
|
||||
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
|
||||
|
||||
let stream = connect_via_socks(
|
||||
&socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
scheme == "socks5",
|
||||
if !username.is_empty() {
|
||||
Some((username, password))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
auth,
|
||||
)
|
||||
.await?;
|
||||
Box::new(stream)
|
||||
@@ -1599,12 +1980,16 @@ async fn handle_connect_from_buffer(
|
||||
let target_addr =
|
||||
shadowsocks::relay::Address::DomainNameAddress(target_host.to_string(), target_port);
|
||||
|
||||
let stream = shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
|
||||
context,
|
||||
&svr_cfg,
|
||||
target_addr,
|
||||
let stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
|
||||
context,
|
||||
&svr_cfg,
|
||||
target_addr,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Shadowsocks connection timed out".to_string())?
|
||||
.map_err(|e| format!("Shadowsocks connection failed: {e}"))?;
|
||||
|
||||
Box::new(stream)
|
||||
@@ -1616,20 +2001,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);
|
||||
@@ -1692,8 +2075,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)]
|
||||
@@ -1701,6 +2082,61 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// Build an upstream URL with `urlencoding::encode`-d user/pass,
|
||||
/// mirroring what `proxy_manager::build_proxy_url` actually emits
|
||||
fn parse_encoded_upstream(scheme: &str, user: &str, pass: &str) -> Url {
|
||||
let s = format!(
|
||||
"{}://{}:{}@127.0.0.1:1080",
|
||||
scheme,
|
||||
urlencoding::encode(user),
|
||||
urlencoding::encode(pass),
|
||||
);
|
||||
Url::parse(&s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_handles_plain_ascii() {
|
||||
let u = parse_encoded_upstream("socks5", "alice", "secret123");
|
||||
assert_eq!(upstream_userpass(&u), ("alice".into(), "secret123".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_decodes_special_chars() {
|
||||
// These characters all get percent-encoded by build_proxy_url before
|
||||
// landing in the URL, and must be decoded back to the original literal
|
||||
// before being handed off to the upstream
|
||||
let cases = [
|
||||
("alice", "p@ssw0rd"),
|
||||
("alice", "p:assw0rd"),
|
||||
("alice", "p ass word"),
|
||||
("alice", "abc/d+e=f"),
|
||||
("alice", "100%off!"),
|
||||
("alice", "测试密码"),
|
||||
("u@name", "v@lue"),
|
||||
];
|
||||
for (user, pass) in cases {
|
||||
let u = parse_encoded_upstream("socks5", user, pass);
|
||||
assert_eq!(
|
||||
upstream_userpass(&u),
|
||||
(user.to_string(), pass.to_string()),
|
||||
"decode failed: user={user:?} pass={pass:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_empty_when_no_credentials() {
|
||||
let u = Url::parse("socks5://127.0.0.1:1080").unwrap();
|
||||
assert_eq!(upstream_userpass(&u), (String::new(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_handles_username_only() {
|
||||
let s = format!("socks5://{}@127.0.0.1:1080", urlencoding::encode("u@name"));
|
||||
let u = Url::parse(&s).unwrap();
|
||||
assert_eq!(upstream_userpass(&u), ("u@name".into(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_exact_match() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
|
||||
@@ -16,6 +16,19 @@ 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>,
|
||||
/// PID of the browser process this worker serves, recorded by the GUI after
|
||||
/// launch. The detached worker watches this and self-terminates when the
|
||||
/// browser dies, so it dies with its browser even if the GUI has exited or
|
||||
/// restarted. `None` until launch completes (the worker keeps running while
|
||||
/// it is `None`).
|
||||
#[serde(default)]
|
||||
pub browser_pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -30,6 +43,8 @@ impl ProxyConfig {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +62,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 {
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
//! 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::debug!(
|
||||
"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) => {
|
||||
let key = format!("socks5-connect:{host}:{port}");
|
||||
if let Some(suppressed) = crate::proxy_server::log_throttle(&key) {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"SOCKS5 CONNECT to {host}:{port} failed: {e} ({suppressed} more suppressed in last 30s)"
|
||||
);
|
||||
} else {
|
||||
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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,24 +29,35 @@ pub enum SyncWorkItem {
|
||||
Tombstone(String, String),
|
||||
}
|
||||
|
||||
/// Where a subscription's sync token comes from, so reconnects can re-fetch a
|
||||
/// fresh one (tokens are short-lived, ~15 min).
|
||||
#[derive(Clone, Copy)]
|
||||
enum TokenSource {
|
||||
Cloud,
|
||||
SelfHosted,
|
||||
}
|
||||
|
||||
pub struct SyncSubscription {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
token: String,
|
||||
source: TokenSource,
|
||||
running: Arc<AtomicBool>,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
}
|
||||
|
||||
impl SyncSubscription {
|
||||
pub fn new(
|
||||
fn new(
|
||||
base_url: String,
|
||||
token: String,
|
||||
source: TokenSource,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token,
|
||||
source,
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
work_tx,
|
||||
}
|
||||
@@ -66,7 +77,7 @@ impl SyncSubscription {
|
||||
let Some(token) = token else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(Some(Self::new(url, token, work_tx)));
|
||||
return Ok(Some(Self::new(url, token, TokenSource::Cloud, work_tx)));
|
||||
}
|
||||
|
||||
// Fall back to self-hosted settings
|
||||
@@ -88,7 +99,12 @@ impl SyncSubscription {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(Self::new(server_url, token, work_tx)))
|
||||
Ok(Some(Self::new(
|
||||
server_url,
|
||||
token,
|
||||
TokenSource::SelfHosted,
|
||||
work_tx,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
@@ -106,9 +122,10 @@ impl SyncSubscription {
|
||||
|
||||
let running = self.running.clone();
|
||||
let base_url = self.base_url.clone();
|
||||
let token = self.token.clone();
|
||||
let source = self.source;
|
||||
let work_tx = self.work_tx.clone();
|
||||
let client = self.client.clone();
|
||||
let mut token = self.token.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while running.load(Ordering::SeqCst) {
|
||||
@@ -126,6 +143,20 @@ impl SyncSubscription {
|
||||
|
||||
if running.load(Ordering::SeqCst) {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
// Refresh the sync token before reconnecting. The token may have
|
||||
// expired while the stream was open (tokens last ~15 min); reusing
|
||||
// the construction-time token otherwise produces an endless 401
|
||||
// reconnect loop until the app is restarted (issue #440).
|
||||
match Self::fetch_sync_token(source, &app_handle).await {
|
||||
Ok(Some(fresh)) => token = fresh,
|
||||
Ok(None) => {
|
||||
log::info!("Sync token no longer available; stopping subscription");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to refresh sync token: {e}; retrying with the current token");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +164,24 @@ impl SyncSubscription {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch a current sync token from the same source the subscription was
|
||||
/// created from, so reconnects never reuse a stale (expired) token.
|
||||
async fn fetch_sync_token(
|
||||
source: TokenSource,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Option<String>, String> {
|
||||
match source {
|
||||
TokenSource::Cloud => crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh cloud sync token: {e}")),
|
||||
TokenSource::SelfHosted => SettingsManager::instance()
|
||||
.get_sync_token(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh self-hosted sync token: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_listen(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -728,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());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.26.0",
|
||||
"version": "0.27.0",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+194
-35
@@ -228,6 +228,10 @@ export default function Home() {
|
||||
// Cloud auth for cross-OS unlock
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||
// Bulk run/stop is a paid (browser automation) feature, matching the
|
||||
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
|
||||
// actions disabled with a Pro badge.
|
||||
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
|
||||
|
||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||
useState(false);
|
||||
@@ -708,49 +712,67 @@ export default function Home() {
|
||||
);
|
||||
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
// Collect every listener we register so that — whether setup completes or
|
||||
// throws partway through — we tear down exactly what was registered.
|
||||
// Previously the Tauri unlisten handles were discarded (so re-runs stacked
|
||||
// duplicate handlers and a single URL was handled N times), and a failing
|
||||
// listen() call would leak the listeners that had already succeeded.
|
||||
const unlisteners: Array<() => void> = [];
|
||||
let handleLogoUrlEvent: ((event: CustomEvent) => void) | undefined;
|
||||
const teardown = () => {
|
||||
for (const unlisten of unlisteners) unlisten();
|
||||
if (handleLogoUrlEvent) {
|
||||
window.removeEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Listen for URL open events from the deep link handler (when app is already running)
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for show profile selector events
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for show create profile dialog events
|
||||
await listen<string>("show-create-profile-dialog", (event) => {
|
||||
console.log(
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("show-create-profile-dialog", (event) => {
|
||||
console.log(
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for custom logo click events
|
||||
const handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
console.log("Received logo URL event:", event.detail);
|
||||
handleUrlOpen(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
};
|
||||
return teardown;
|
||||
} catch (error) {
|
||||
console.error("Failed to setup URL listener:", error);
|
||||
// Tear down whatever did register before the failure so nothing leaks.
|
||||
teardown();
|
||||
}
|
||||
}, [handleUrlOpen, t]);
|
||||
|
||||
@@ -1128,6 +1150,75 @@ export default function Home() {
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, [selectedProfiles, profiles, t]);
|
||||
|
||||
const [pendingBulkAction, setPendingBulkAction] = useState<{
|
||||
action: "run" | "stop";
|
||||
profiles: BrowserProfile[];
|
||||
} | null>(null);
|
||||
const [isBulkActing, setIsBulkActing] = useState(false);
|
||||
|
||||
const executeBulkRun = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => launchProfile(p)));
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[launchProfile],
|
||||
);
|
||||
|
||||
const executeBulkStop = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => handleKillProfile(p)));
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[handleKillProfile],
|
||||
);
|
||||
|
||||
// Bulk run/stop only touch eligible profiles (run: not already running;
|
||||
// stop: currently running). An empty result shows a toast instead of a silent
|
||||
// no-op (guard), and 10+ targets require confirmation before launching/stopping.
|
||||
const handleBulkRun = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
const targets = profiles.filter(
|
||||
(p) => selectedProfiles.includes(p.id) && !runningProfiles.has(p.id),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showErrorToast(t("profiles.bulkRun.noneToRun"));
|
||||
return;
|
||||
}
|
||||
if (targets.length >= 10) {
|
||||
setPendingBulkAction({ action: "run", profiles: targets });
|
||||
return;
|
||||
}
|
||||
void executeBulkRun(targets);
|
||||
}, [selectedProfiles, profiles, runningProfiles, executeBulkRun, t]);
|
||||
|
||||
const handleBulkStop = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
const targets = profiles.filter(
|
||||
(p) => selectedProfiles.includes(p.id) && runningProfiles.has(p.id),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showErrorToast(t("profiles.bulkStop.noneToStop"));
|
||||
return;
|
||||
}
|
||||
if (targets.length >= 10) {
|
||||
setPendingBulkAction({ action: "stop", profiles: targets });
|
||||
return;
|
||||
}
|
||||
void executeBulkStop(targets);
|
||||
}, [selectedProfiles, profiles, runningProfiles, executeBulkStop, t]);
|
||||
|
||||
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
||||
setSelectedProfilesForCookies([profile.id]);
|
||||
setCookieCopyDialogOpen(true);
|
||||
@@ -1184,6 +1275,7 @@ export default function Home() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlistenStatus: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
const profilesWithTransfer = new Set<string>();
|
||||
@@ -1260,25 +1352,35 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
});
|
||||
// If the effect was torn down while we were awaiting the listeners,
|
||||
// unlisten immediately — the cleanup below already ran and would have
|
||||
// missed these handles. (Tauri unlisten is safe to call more than once.)
|
||||
if (disposed) {
|
||||
unlistenStatus?.();
|
||||
unlistenProgress?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync events:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (unlistenStatus) unlistenStatus();
|
||||
if (unlistenProgress) unlistenProgress();
|
||||
};
|
||||
}, [profiles, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for URL open events and get cleanup function
|
||||
const setupListeners = async () => {
|
||||
const cleanup = await listenForUrlEvents();
|
||||
return cleanup;
|
||||
};
|
||||
|
||||
// Listen for URL open events. Guard against the effect tearing down (or
|
||||
// re-running) before the async listener setup resolves: if that happens,
|
||||
// run the cleanup as soon as it's available so the listeners never leak.
|
||||
let cleanup: (() => void) | undefined;
|
||||
void setupListeners().then((cleanupFn) => {
|
||||
let disposed = false;
|
||||
void listenForUrlEvents().then((cleanupFn) => {
|
||||
if (disposed) {
|
||||
cleanupFn?.();
|
||||
return;
|
||||
}
|
||||
cleanup = cleanupFn;
|
||||
});
|
||||
|
||||
@@ -1306,10 +1408,9 @@ export default function Home() {
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(updateInterval);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
cleanup?.();
|
||||
};
|
||||
}, [
|
||||
checkForUpdates,
|
||||
@@ -1323,6 +1424,7 @@ export default function Home() {
|
||||
// E2E encryption listeners — surface password-required prompts and rollover
|
||||
// progress so the user isn't left guessing whether sealing finished.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlistenRequired: (() => void) | undefined;
|
||||
let unlistenStarted: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
@@ -1399,9 +1501,20 @@ export default function Home() {
|
||||
duration: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
// If the effect was torn down mid-setup, the cleanup below already ran
|
||||
// before these handles existed — unlisten them now so nothing leaks.
|
||||
if (disposed) {
|
||||
unlistenRequired?.();
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
unlistenCompleted?.();
|
||||
unlistenWayfernBlocked?.();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlistenRequired?.();
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
@@ -1569,6 +1682,9 @@ export default function Home() {
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onBulkCopyCookies={handleBulkCopyCookies}
|
||||
onBulkRun={handleBulkRun}
|
||||
onBulkStop={handleBulkStop}
|
||||
bulkActionsUnlocked={automationUnlocked}
|
||||
onBulkExtensionGroupAssignment={
|
||||
handleBulkExtensionGroupAssignment
|
||||
}
|
||||
@@ -1868,6 +1984,49 @@ export default function Home() {
|
||||
profile={currentProfileForCookieManagement}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={pendingBulkAction !== null}
|
||||
onClose={() => {
|
||||
setPendingBulkAction(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (!pendingBulkAction) return;
|
||||
if (pendingBulkAction.action === "run") {
|
||||
void executeBulkRun(pendingBulkAction.profiles);
|
||||
} else {
|
||||
void executeBulkStop(pendingBulkAction.profiles);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmTitle", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmTitle", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
description={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmDescription", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmDescription", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
confirmButtonText={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmButton", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmButton", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
confirmButtonVariant="default"
|
||||
isLoading={isBulkActing}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => {
|
||||
|
||||
@@ -27,6 +27,7 @@ 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 {
|
||||
@@ -197,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -50,8 +51,7 @@ export function CreateGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.createFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -534,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"
|
||||
@@ -557,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">
|
||||
|
||||
@@ -201,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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -19,6 +19,12 @@ interface DeleteConfirmationDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
confirmButtonVariant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost";
|
||||
isLoading?: boolean;
|
||||
profileIds?: string[];
|
||||
profiles?: { id: string; name: string }[];
|
||||
@@ -31,6 +37,7 @@ export function DeleteConfirmationDialog({
|
||||
title,
|
||||
description,
|
||||
confirmButtonText,
|
||||
confirmButtonVariant = "destructive",
|
||||
isLoading = false,
|
||||
profileIds,
|
||||
profiles = [],
|
||||
@@ -57,7 +64,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>
|
||||
);
|
||||
@@ -76,7 +86,7 @@ export function DeleteConfirmationDialog({
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
variant={confirmButtonVariant}
|
||||
onClick={() => void handleConfirm()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -97,8 +98,7 @@ export function DeleteGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -61,8 +62,7 @@ export function EditGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.updateFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
@@ -494,9 +495,15 @@ export function GroupManagementDialog({
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((groupId) => invoke("delete_profile_group", { groupId })),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected");
|
||||
if (failed.length > 0) {
|
||||
showErrorToast(t("groups.deleteFailed"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("groups.deleteFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("groups.deleteSuccess"));
|
||||
}
|
||||
@@ -506,9 +513,7 @@ export function GroupManagementDialog({
|
||||
onGroupManagementComplete();
|
||||
} catch (err) {
|
||||
console.error("Bulk group delete failed:", err);
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed"),
|
||||
);
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
@@ -552,7 +557,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 +567,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 +606,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 +627,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 +657,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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -306,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}
|
||||
@@ -409,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>{" "}
|
||||
@@ -513,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
|
||||
@@ -600,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" ? (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
@@ -49,11 +51,18 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -81,7 +90,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 +113,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 +839,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;
|
||||
@@ -1034,6 +1141,9 @@ interface ProfilesDataTableProps {
|
||||
onBulkGroupAssignment?: () => void;
|
||||
onBulkProxyAssignment?: () => void;
|
||||
onBulkCopyCookies?: () => void;
|
||||
onBulkRun?: () => void;
|
||||
onBulkStop?: () => void;
|
||||
bulkActionsUnlocked?: boolean;
|
||||
onBulkExtensionGroupAssignment?: () => void;
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
@@ -1079,6 +1189,9 @@ export function ProfilesDataTable({
|
||||
onBulkGroupAssignment,
|
||||
onBulkProxyAssignment,
|
||||
onBulkCopyCookies,
|
||||
onBulkRun,
|
||||
onBulkStop,
|
||||
bulkActionsUnlocked = false,
|
||||
onBulkExtensionGroupAssignment,
|
||||
onAssignExtensionGroup,
|
||||
onOpenProfileSyncDialog,
|
||||
@@ -1137,14 +1250,16 @@ export function ProfilesDataTable({
|
||||
(id) => newSelection[id],
|
||||
);
|
||||
|
||||
// Only update external state if selection actually changed
|
||||
const prevIds = Object.keys(prevSelection).filter(
|
||||
(id) => prevSelection[id],
|
||||
// Only update external state if selection actually changed.
|
||||
// A Set gives O(1) membership; Array.includes() inside .every() would
|
||||
// be O(n*m) over large selections.
|
||||
const prevIdSet = new Set(
|
||||
Object.keys(prevSelection).filter((id) => prevSelection[id]),
|
||||
);
|
||||
|
||||
if (
|
||||
selectedIds.length !== prevIds.length ||
|
||||
!selectedIds.every((id) => prevIds.includes(id))
|
||||
selectedIds.length !== prevIdSet.size ||
|
||||
!selectedIds.every((id) => prevIdSet.has(id))
|
||||
) {
|
||||
onSelectedProfilesChange(selectedIds);
|
||||
}
|
||||
@@ -1446,10 +1561,13 @@ export function ProfilesDataTable({
|
||||
"get_all_traffic_snapshots",
|
||||
);
|
||||
const newSnapshots: Record<string, TrafficSnapshot> = {};
|
||||
// O(1) membership; runningProfileIds.includes() in this loop would be
|
||||
// O(snapshots * runningProfiles).
|
||||
const runningSet = new Set(runningProfileIds);
|
||||
for (const snapshot of allSnapshots) {
|
||||
if (snapshot.profile_id) {
|
||||
// Only keep snapshots for profiles that are currently running
|
||||
if (runningProfileIds.includes(snapshot.profile_id)) {
|
||||
if (runningSet.has(snapshot.profile_id)) {
|
||||
const existing = newSnapshots[snapshot.profile_id];
|
||||
if (!existing || snapshot.last_update > existing.last_update) {
|
||||
newSnapshots[snapshot.profile_id] = snapshot;
|
||||
@@ -1478,9 +1596,10 @@ export function ProfilesDataTable({
|
||||
|
||||
setTrafficSnapshots((prev) => {
|
||||
const cleaned: Record<string, TrafficSnapshot> = {};
|
||||
const runningSet = new Set(runningProfileIds);
|
||||
for (const [profileId, snapshot] of Object.entries(prev)) {
|
||||
// Only keep snapshots for profiles that are currently running
|
||||
if (runningProfileIds.includes(profileId)) {
|
||||
if (runningSet.has(profileId)) {
|
||||
cleaned[profileId] = snapshot;
|
||||
}
|
||||
}
|
||||
@@ -2274,26 +2393,89 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hidden, sort-only column so profiles can be sorted by creation date
|
||||
// without showing a Created column in the table (issue #454). Kept
|
||||
// hidden via columnVisibility; sorting still works on hidden columns.
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.created_at ?? 0,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
sortingFn: "basic",
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
size: 130,
|
||||
header: ({ column, table }) => {
|
||||
// The only column without a fixed width: table-fixed hands it all
|
||||
// remaining space as the window grows or shrinks.
|
||||
meta: { flexWidth: true },
|
||||
// The Name header doubles as the sort control: clicking opens a menu to
|
||||
// sort by name (A–Z / Z–A) or by creation date (newest / oldest), so
|
||||
// creation-date sorting needs no visible column.
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const sort = table.getState().sorting[0];
|
||||
const isActive = (id: string, desc: boolean) =>
|
||||
sort?.id === id && !!sort.desc === desc;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : null}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{isActive("name", false) ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : isActive("name", true) ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : (
|
||||
<LuChevronDown className="ml-2 size-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "name", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("name", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameAsc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => table.setSorting([{ id: "name", desc: true }])}
|
||||
>
|
||||
{isActive("name", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameDesc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: true }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.newest")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.oldest")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
@@ -2341,27 +2523,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 +2701,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 +2734,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 +3003,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>({ created_at: false });
|
||||
|
||||
// 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 +3041,52 @@ 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 = {
|
||||
// Always hidden — sort-only column (issue #454).
|
||||
created_at: false,
|
||||
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 +3111,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 +3127,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 +3139,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 +3163,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 +3173,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 +3205,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 +3224,7 @@ export function ProfilesDataTable({
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<tr style={{ height: `${paddingBottom}px` }}>
|
||||
<td colSpan={columns.length} />
|
||||
<td colSpan={table.getVisibleLeafColumns().length} />
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
@@ -3094,6 +3305,44 @@ export function ProfilesDataTable({
|
||||
})()}
|
||||
<DataTableActionBar table={table}>
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkRun && (
|
||||
<span className="relative inline-flex">
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
bulkActionsUnlocked
|
||||
? t("profiles.actionBar.runSelected")
|
||||
: t("profiles.actionBar.proRequired")
|
||||
}
|
||||
onClick={bulkActionsUnlocked ? onBulkRun : undefined}
|
||||
disabled={!bulkActionsUnlocked}
|
||||
size="icon"
|
||||
>
|
||||
<LuPlay className="fill-current" />
|
||||
</DataTableActionBarAction>
|
||||
{!bulkActionsUnlocked && (
|
||||
<ProBadge className="absolute -top-2 -right-2 pointer-events-none" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onBulkStop && (
|
||||
<span className="relative inline-flex">
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
bulkActionsUnlocked
|
||||
? t("profiles.actionBar.stopSelected")
|
||||
: t("profiles.actionBar.proRequired")
|
||||
}
|
||||
onClick={bulkActionsUnlocked ? onBulkStop : undefined}
|
||||
disabled={!bulkActionsUnlocked}
|
||||
size="icon"
|
||||
>
|
||||
<LuSquare className="fill-current" />
|
||||
</DataTableActionBarAction>
|
||||
{!bulkActionsUnlocked && (
|
||||
<ProBadge className="absolute -top-2 -right-2 pointer-events-none" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
|
||||
@@ -503,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). */}
|
||||
@@ -878,6 +878,17 @@ function ProfileInfoLayout({
|
||||
{t("profileInfo.sections.activity")}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.created")}
|
||||
value={
|
||||
profile.created_at
|
||||
? new Date(profile.created_at * 1000).toLocaleString(
|
||||
undefined,
|
||||
{ dateStyle: "medium", timeStyle: "short" },
|
||||
)
|
||||
: t("profileInfo.values.unknown")
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.lastLaunched")}
|
||||
value={
|
||||
|
||||
@@ -193,7 +193,7 @@ export function ProfilePasswordDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(titleKey)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -172,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", {
|
||||
@@ -183,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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -127,9 +128,11 @@ export function ProxyFormDialog({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
|
||||
toast.error(
|
||||
t("proxies.form.saveFailed", {
|
||||
error: translateBackendError(t, error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -158,7 +161,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 +231,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 +255,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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
+44
-40
@@ -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 cursor-pointer 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 cursor-pointer 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 cursor-pointer transition-colors duration-100 shrink-0",
|
||||
currentPage === "settings"
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
@@ -440,7 +444,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
setMoreOpen(false);
|
||||
onNavigate(page);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent transition-colors duration-100 text-left"
|
||||
>
|
||||
<span className="grid place-items-center size-5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
<Icon className="size-3" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
@@ -153,7 +153,7 @@ function CommandItem({
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square size-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"cursor-pointer aspect-square size-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user