mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-24 15:39:56 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e54bc1192d | |||
| 9061e4db8f | |||
| 0da8529e07 | |||
| b3373924e6 | |||
| 19e50324c4 | |||
| 931d02fefd | |||
| 7b39c5dea9 | |||
| 8588a44fb5 | |||
| fe3ae13928 | |||
| 94cccc3702 | |||
| 9edc154397 | |||
| f29b161cf4 | |||
| 4007dedcf0 | |||
| 50d2834634 | |||
| f8791a9ec5 | |||
| 4598b22af1 | |||
| 4ac4c6e8a9 | |||
| 5a82b18fb8 | |||
| 5fada3f929 | |||
| 828a604c9d | |||
| 02328e59a2 | |||
| 577ab79fd0 | |||
| 8c221d02fe |
@@ -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@abda3515f444c4d28a98953d153c5a3e1892d3d4 #v1.17.4
|
||||
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,6 +1,30 @@
|
||||
# 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
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
@@ -49,12 +49,12 @@ pnpm format && pnpm lint && pnpm test
|
||||
|
||||
This runs:
|
||||
|
||||
- **Biome** — JS/TS linting and formatting
|
||||
- **Clippy + rustfmt** — Rust linting and formatting
|
||||
- **typos** — Spellcheck (allowlist in `_typos.toml`)
|
||||
- **CodeQL** — Security analysis (JS, Actions, Rust) — runs in CI
|
||||
- **Unit tests** — 330+ Rust tests
|
||||
- **Integration tests** — proxy, sync e2e
|
||||
- **Biome**: JS/TS linting and formatting
|
||||
- **Clippy + rustfmt**: Rust linting and formatting
|
||||
- **typos**: Spellcheck (allowlist in `_typos.toml`)
|
||||
- **CodeQL**: Security analysis (JS, Actions, Rust), runs in CI
|
||||
- **Unit tests**: 330+ Rust tests
|
||||
- **Integration tests**: proxy, sync e2e
|
||||
|
||||
### Running CodeQL locally
|
||||
|
||||
@@ -88,10 +88,10 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: Next.js (React) — `src/`
|
||||
- **Backend**: Tauri (Rust) — `src-tauri/src/`
|
||||
- **Proxy Worker**: Detached process for proxy tunneling — `src-tauri/src/bin/proxy_server.rs`
|
||||
- **Sync**: Cloud sync via S3-compatible storage — `src-tauri/src/sync/`, `donut-sync/`
|
||||
- **Frontend**: Next.js (React), `src/`
|
||||
- **Backend**: Tauri (Rust), `src-tauri/src/`
|
||||
- **Proxy Worker**: Detached process for proxy tunneling, `src-tauri/src/bin/proxy_server.rs`
|
||||
- **Sync**: Cloud sync via S3-compatible storage, `src-tauri/src/sync/`, `donut-sync/`
|
||||
- **Browsers**: Camoufox (Firefox-based) and Wayfern (Chromium-based)
|
||||
|
||||
## Getting Help
|
||||
|
||||
@@ -25,19 +25,19 @@
|
||||
|
||||
## 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), 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.
|
||||
- **Unlimited browser profiles**: each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **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
|
||||
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups** — organize profiles and apply bulk settings
|
||||
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
- **Cookie & extension management** — import/export cookies, manage extensions per profile
|
||||
- **Default browser** — set Donut as your default browser and choose which profile opens each link
|
||||
- **Cloud sync** — sync profiles, proxies, and groups across devices (self-hostable)
|
||||
- **E2E encryption** — optional end-to-end encrypted sync with a password only you know
|
||||
- **Zero telemetry** — no tracking or device fingerprinting
|
||||
- **Proxy support**: HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support**: WireGuard configs per profile
|
||||
- **Local API & MCP**: REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups**: organize profiles and apply bulk settings
|
||||
- **Import profiles**: migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
- **Cookie & extension management**: import/export cookies, manage extensions per profile
|
||||
- **Default browser**: set Donut as your default browser and choose which profile opens each link
|
||||
- **Cloud sync**: sync profiles, proxies, and groups across devices (self-hostable)
|
||||
- **E2E encryption**: optional end-to-end encrypted sync with a password only you know
|
||||
- **Zero telemetry**: no tracking or device fingerprinting
|
||||
|
||||
## Install
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64.dmg) |
|
||||
| **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.26.0/Donut_0.26.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-portable.zip)
|
||||
[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.26.0/Donut_0.26.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage) |
|
||||
| **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:
|
||||
@@ -94,7 +94,7 @@ nix run github:zhom/donutbrowser#release-start
|
||||
|
||||
## Self-Hosting Sync
|
||||
|
||||
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server. See the [Self-Hosting Guide](docs/self-hosting-donut-sync.md) for Docker-based setup instructions.
|
||||
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server, which makes sync completely free. See the [Self-Hosting Donut Sync guide](https://donutbrowser.com/docs/self-hosting) for Docker-based setup instructions.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# Self-Hosting Donut Sync
|
||||
|
||||
Donut Sync is the synchronization server for Donut Browser. It allows you to sync your profiles, proxies, and groups across multiple devices. This guide covers how to self-host it using Docker.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- An S3-compatible object storage (MinIO included by default, or use AWS S3, Cloudflare R2, etc.)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
donut-sync:
|
||||
image: donutbrowser/donut-sync:latest
|
||||
ports:
|
||||
- "3929:3929"
|
||||
environment:
|
||||
- SYNC_TOKEN=your-secret-token-here
|
||||
- PORT=3929
|
||||
- S3_ENDPOINT=http://minio:9000
|
||||
- S3_REGION=us-east-1
|
||||
- S3_ACCESS_KEY_ID=minioadmin
|
||||
- S3_SECRET_ACCESS_KEY=minioadmin
|
||||
- S3_BUCKET=donut-sync
|
||||
- S3_FORCE_PATH_STYLE=true
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
```
|
||||
|
||||
### 2. Start the services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Verify the server is running
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3929/health
|
||||
# Expected: {"status":"ok"}
|
||||
|
||||
# Readiness check (verifies S3 connectivity)
|
||||
curl http://localhost:3929/readyz
|
||||
# Expected: {"status":"ready","s3":true}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SYNC_TOKEN` | Yes | - | Bearer token used to authenticate requests from Donut Browser clients |
|
||||
| `PORT` | No | `3929` | Port the sync server listens on |
|
||||
| `S3_ENDPOINT` | No | - | S3-compatible endpoint URL (e.g., `http://minio:9000` or `https://s3.amazonaws.com`) |
|
||||
| `S3_REGION` | No | `us-east-1` | S3 region |
|
||||
| `S3_ACCESS_KEY_ID` | Yes | - | S3 access key |
|
||||
| `S3_SECRET_ACCESS_KEY` | Yes | - | S3 secret key |
|
||||
| `S3_BUCKET` | No | `donut-sync` | S3 bucket name for storing sync data |
|
||||
| `S3_FORCE_PATH_STYLE` | No | `false` | Set to `true` for MinIO and other S3-compatible services that use path-style URLs |
|
||||
|
||||
## Using External S3 Storage
|
||||
|
||||
Instead of running MinIO, you can use any S3-compatible storage service. Remove the `minio` service from `docker-compose.yml` and update the environment variables:
|
||||
|
||||
### AWS S3
|
||||
|
||||
```yaml
|
||||
services:
|
||||
donut-sync:
|
||||
image: donutbrowser/donut-sync:latest
|
||||
ports:
|
||||
- "3929:3929"
|
||||
environment:
|
||||
- SYNC_TOKEN=your-secret-token-here
|
||||
- S3_REGION=us-east-1
|
||||
- S3_ACCESS_KEY_ID=your-aws-access-key
|
||||
- S3_SECRET_ACCESS_KEY=your-aws-secret-key
|
||||
- S3_BUCKET=your-bucket-name
|
||||
```
|
||||
|
||||
### Cloudflare R2
|
||||
|
||||
```yaml
|
||||
services:
|
||||
donut-sync:
|
||||
image: donutbrowser/donut-sync:latest
|
||||
ports:
|
||||
- "3929:3929"
|
||||
environment:
|
||||
- SYNC_TOKEN=your-secret-token-here
|
||||
- S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
||||
- S3_REGION=auto
|
||||
- S3_ACCESS_KEY_ID=your-r2-access-key
|
||||
- S3_SECRET_ACCESS_KEY=your-r2-secret-key
|
||||
- S3_BUCKET=your-bucket-name
|
||||
- S3_FORCE_PATH_STYLE=true
|
||||
```
|
||||
|
||||
### Other S3-Compatible Services
|
||||
|
||||
Any service that implements the S3 API (e.g., Backblaze B2, DigitalOcean Spaces, Wasabi) can be used. Set `S3_ENDPOINT` to the service's endpoint URL and `S3_FORCE_PATH_STYLE=true` if required by the provider.
|
||||
|
||||
## Configuring the Donut Browser Client
|
||||
|
||||
1. Open Donut Browser
|
||||
2. Click the sync icon in the header to open the Sync Configuration dialog
|
||||
3. Enter the **Server URL** (e.g., `http://your-server:3929`)
|
||||
4. Enter the **Sync Token** (the value you set for `SYNC_TOKEN`)
|
||||
5. Click **Save**
|
||||
|
||||
Once configured, you can enable sync on individual profiles, proxies, and groups.
|
||||
|
||||
## Health Check Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /health` | Basic health check. Returns `{"status":"ok"}` if the server is running. |
|
||||
| `GET /readyz` | Readiness check. Verifies S3 connectivity. Returns `{"status":"ready","s3":true}` or HTTP 503 if S3 is unreachable. |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Use a strong `SYNC_TOKEN`**: Generate a random token (e.g., `openssl rand -hex 32`) and keep it secret.
|
||||
- **HTTPS**: In production, place a reverse proxy (e.g., Nginx, Caddy, Traefik) in front of Donut Sync to terminate TLS. The sync token is sent as a Bearer token in the `Authorization` header and should not be transmitted over plain HTTP.
|
||||
- **Network isolation**: If running on a VPS, consider restricting access to the sync port using firewall rules or binding only to localhost behind a reverse proxy.
|
||||
- **S3 credentials**: Use dedicated IAM credentials with minimal permissions (read/write to the sync bucket only).
|
||||
|
||||
### Example: Caddy Reverse Proxy
|
||||
|
||||
```
|
||||
sync.yourdomain.com {
|
||||
reverse_proxy localhost:3929
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Nginx Reverse Proxy
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sync.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3929;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
+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.26.0";
|
||||
releaseVersion = "0.27.0";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage";
|
||||
hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
|
||||
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.26.0/Donut_0.26.0_aarch64.AppImage";
|
||||
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
|
||||
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.27.0",
|
||||
"version": "0.27.1",
|
||||
"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
+262
-409
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -73,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"
|
||||
@@ -145,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"
|
||||
|
||||
|
||||
+256
-7
@@ -57,6 +57,9 @@ pub struct ApiProfileResponse {
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
/// Browser engine. Must be `"wayfern"` (anti-detect Chromium) or `"camoufox"`
|
||||
/// (anti-detect Firefox). Any other value (e.g. `"chromium"`) is rejected with
|
||||
/// 400.
|
||||
pub browser: String,
|
||||
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
|
||||
/// version of the chosen browser. A concrete version must already be
|
||||
@@ -244,6 +247,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 +304,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 +348,12 @@ struct ImportCookiesResponse {
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
RunProfileRequest,
|
||||
BatchRunRequest,
|
||||
BatchRunResult,
|
||||
BatchRunResponse,
|
||||
BatchStopRequest,
|
||||
BatchStopResult,
|
||||
BatchStopResponse,
|
||||
OpenUrlRequest,
|
||||
ImportCookiesRequest,
|
||||
ImportCookiesResponse,
|
||||
@@ -396,6 +453,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))
|
||||
@@ -759,7 +818,7 @@ async fn get_profile(
|
||||
async fn create_profile(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
) -> Result<Json<ApiProfileResponse>, (StatusCode, String)> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
|
||||
@@ -768,7 +827,13 @@ async fn create_profile(
|
||||
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
|
||||
// create_profile validation.
|
||||
if request.browser != "wayfern" && request.browser != "camoufox" {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!(
|
||||
"Invalid browser \"{}\". Must be \"wayfern\" (anti-detect Chromium) or \"camoufox\" (anti-detect Firefox).",
|
||||
request.browser
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Resolve the version. Omitted, empty, or "latest" means "newest version
|
||||
@@ -785,7 +850,15 @@ async fn create_profile(
|
||||
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
||||
match versions.into_iter().next() {
|
||||
Some(v) => v,
|
||||
None => return Err(StatusCode::BAD_REQUEST),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!(
|
||||
"No downloaded version of \"{}\" is available. Download the browser in Donut Browser first — this endpoint does not download browsers.",
|
||||
request.browser
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -810,9 +883,15 @@ async fn create_profile(
|
||||
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
|
||||
{
|
||||
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
|
||||
StatusCode::PAYMENT_REQUIRED
|
||||
(
|
||||
StatusCode::PAYMENT_REQUIRED,
|
||||
"The selected proxy requires an active subscription.".to_string(),
|
||||
)
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Profile network validation failed: {err}"),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -842,7 +921,10 @@ async fn create_profile(
|
||||
.update_profile_tags(&state.app_handle, &profile.name, tags.clone())
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Profile created but failed to apply tags.".to_string(),
|
||||
));
|
||||
}
|
||||
profile.tags = tags.clone();
|
||||
}
|
||||
@@ -874,7 +956,10 @@ async fn create_profile(
|
||||
},
|
||||
}))
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to create profile: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1951,6 +2036,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
|
||||
|
||||
@@ -409,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: {}",
|
||||
@@ -593,6 +597,14 @@ impl BrowserRunner {
|
||||
if wayfern_config.os.is_some() {
|
||||
updated_wayfern_config.os = wayfern_config.os.clone();
|
||||
}
|
||||
// The fresh fingerprint's location matches the current routing; record
|
||||
// its signature so launches keep it in sync with the non-randomize path.
|
||||
updated_wayfern_config.geo_proxy_signature =
|
||||
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
upstream_proxy.as_ref(),
|
||||
profile.vpn_id.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
));
|
||||
updated_profile.wayfern_config = Some(updated_wayfern_config.clone());
|
||||
|
||||
log::info!(
|
||||
@@ -600,6 +612,62 @@ impl BrowserRunner {
|
||||
profile.name,
|
||||
updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0)
|
||||
);
|
||||
} else {
|
||||
// Safety net: the stored fingerprint's timezone and geolocation were
|
||||
// computed for whatever proxy was set when the fingerprint was
|
||||
// generated. If the profile's proxy or VPN has changed since (the
|
||||
// common case being a user who forgot to set a proxy at creation and
|
||||
// added one afterwards), that location data is stale and the user would
|
||||
// see the wrong timezone on first launch. When the routing signature no
|
||||
// longer matches, refresh just the location fields of the stored
|
||||
// fingerprint through the current proxy. Wayfern only; the randomize
|
||||
// path above already regenerates the whole fingerprint each launch.
|
||||
let current_geo_sig = crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
upstream_proxy.as_ref(),
|
||||
profile.vpn_id.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
);
|
||||
let geo_enabled = !matches!(
|
||||
wayfern_config.geoip.as_ref(),
|
||||
Some(serde_json::Value::Bool(false))
|
||||
);
|
||||
if geo_enabled
|
||||
&& wayfern_config.geo_proxy_signature.as_deref() != Some(current_geo_sig.as_str())
|
||||
{
|
||||
if let Some(stored_fp) = wayfern_config.fingerprint.clone() {
|
||||
log::info!(
|
||||
"Routing changed for Wayfern profile {} since its fingerprint was generated (was {:?}, now {}); refreshing timezone and geolocation",
|
||||
profile.name,
|
||||
wayfern_config.geo_proxy_signature,
|
||||
current_geo_sig
|
||||
);
|
||||
match crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
|
||||
&stored_fp,
|
||||
wayfern_config.proxy.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(refreshed) => {
|
||||
// Use the refreshed fingerprint for this launch...
|
||||
wayfern_config.fingerprint = Some(refreshed.clone());
|
||||
wayfern_config.geo_proxy_signature = Some(current_geo_sig.clone());
|
||||
// ...and persist it so the corrected location sticks and we do
|
||||
// not refresh again on the next launch with the same proxy.
|
||||
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
|
||||
cfg.fingerprint = Some(refreshed);
|
||||
cfg.geo_proxy_signature = Some(current_geo_sig);
|
||||
updated_profile.wayfern_config = Some(cfg);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"Could not refresh geolocation for Wayfern profile {} (proxy unreachable?); launching with existing location and will retry next launch",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral or password-protected profiles
|
||||
@@ -696,6 +764,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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +326,19 @@ impl ProfileManager {
|
||||
log::info!("Using provided fingerprint for Wayfern profile: {name}");
|
||||
}
|
||||
|
||||
// Record which proxy/geoip the fingerprint's location data was computed
|
||||
// for. On launch this is compared against the profile's current routing
|
||||
// so a proxy that was changed after creation triggers a location refresh
|
||||
// instead of showing a stale timezone.
|
||||
config.geo_proxy_signature = Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
|
||||
.as_ref(),
|
||||
None,
|
||||
config.geoip.as_ref(),
|
||||
));
|
||||
|
||||
// Clear the proxy from config after fingerprint generation
|
||||
config.proxy = None;
|
||||
|
||||
|
||||
@@ -1860,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(
|
||||
@@ -2894,6 +2926,7 @@ mod tests {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2906,6 +2939,7 @@ mod tests {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2946,6 +2980,7 @@ mod tests {
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3265,6 +3300,7 @@ mod tests {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
|
||||
+444
-50
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1359,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) {
|
||||
@@ -1372,20 +1560,37 @@ 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();
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -1451,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,
|
||||
@@ -1481,6 +1686,145 @@ async fn handle_connect_from_buffer(
|
||||
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,
|
||||
@@ -1498,21 +1842,8 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
let _ = stream.set_nodelay(true);
|
||||
};
|
||||
let target_stream: BoxedAsyncStream = match upstream_url {
|
||||
None => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
Some("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)
|
||||
}
|
||||
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();
|
||||
@@ -1521,7 +1852,14 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
"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!(
|
||||
@@ -1529,10 +1867,9 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
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));
|
||||
}
|
||||
@@ -1542,7 +1879,9 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
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();
|
||||
|
||||
@@ -1590,19 +1929,15 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
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)
|
||||
@@ -1645,12 +1980,16 @@ pub(crate) async fn connect_to_target_via_upstream(
|
||||
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)
|
||||
@@ -1743,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();
|
||||
|
||||
@@ -22,6 +22,13 @@ pub struct ProxyConfig {
|
||||
/// `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 {
|
||||
@@ -37,6 +44,7 @@ impl ProxyConfig {
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ async fn handle_connect(
|
||||
tracker.record_request(&host, 0, 0);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
log::debug!(
|
||||
"SOCKS5 CONNECT {}:{} (upstream={})",
|
||||
host,
|
||||
port,
|
||||
@@ -252,16 +252,29 @@ async fn handle_connect(
|
||||
|
||||
// Resolve to the target stream, logging and dropping the (non-Send) dial
|
||||
// error inside the match arm so it is never held across the await below.
|
||||
let target =
|
||||
match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher)
|
||||
.await
|
||||
{
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
|
||||
None
|
||||
let 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,6 +39,12 @@ pub struct WayfernConfig {
|
||||
pub block_webgl: Option<bool>,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub proxy: Option<String>,
|
||||
/// Stable signature of the proxy/VPN/geoip the fingerprint's location data
|
||||
/// (timezone, latitude/longitude, language) was last computed for. Compared
|
||||
/// on launch to detect that the routing changed since creation, so the
|
||||
/// location can be refreshed instead of showing stale data.
|
||||
#[serde(default)]
|
||||
pub geo_proxy_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -263,6 +269,130 @@ impl WayfernManager {
|
||||
Err("No response received from CDP".into())
|
||||
}
|
||||
|
||||
/// Stable signature describing what determines this profile's geolocation
|
||||
/// (timezone, latitude/longitude, language): the geoip mode first, then the
|
||||
/// VPN, the proxy, or a direct connection. Compared across creation and
|
||||
/// launch to detect a change. The VPN case keys off `vpn_id` rather than the
|
||||
/// per-launch local port, and the proxy case off type/host/port/username so
|
||||
/// that editing the proxy is also caught.
|
||||
pub fn geo_signature(
|
||||
proxy: Option<&crate::browser::ProxySettings>,
|
||||
vpn_id: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
match geoip {
|
||||
Some(serde_json::Value::Bool(false)) => "off".to_string(),
|
||||
Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"),
|
||||
_ => {
|
||||
if let Some(id) = vpn_id {
|
||||
format!("vpn:{id}")
|
||||
} else if let Some(p) = proxy {
|
||||
format!(
|
||||
"proxy:{}://{}@{}:{}",
|
||||
p.proxy_type.to_lowercase(),
|
||||
p.username.as_deref().unwrap_or(""),
|
||||
p.host,
|
||||
p.port
|
||||
)
|
||||
} else {
|
||||
"direct".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply timezone/geolocation fields to a fingerprint object from the proxy's
|
||||
/// exit IP (or a fixed geoip IP). Mutates `fingerprint` in place. Returns true
|
||||
/// if fresh geolocation was fetched and applied, false if geolocation is
|
||||
/// disabled or could not be resolved (in which case only safe defaults are
|
||||
/// filled in). Shared by fingerprint generation and the launch-time refresh
|
||||
/// so both produce identical location data.
|
||||
async fn apply_geolocation(
|
||||
fingerprint: &mut serde_json::Value,
|
||||
proxy: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
// Default to auto-detect; only an explicit `false` disables geolocation.
|
||||
let should_geolocate = !matches!(geoip, Some(serde_json::Value::Bool(false)));
|
||||
if !should_geolocate {
|
||||
return false;
|
||||
}
|
||||
|
||||
let geo_result = async {
|
||||
let ip = match geoip {
|
||||
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
|
||||
_ => crate::ip_utils::fetch_public_ip(proxy)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch public IP: {e}"))?,
|
||||
};
|
||||
crate::camoufox::geolocation::get_geolocation(&ip)
|
||||
.map_err(|e| format!("Failed to get geolocation for IP {ip}: {e}"))
|
||||
}
|
||||
.await;
|
||||
|
||||
match geo_result {
|
||||
Ok(geo) => {
|
||||
if let Some(obj) = fingerprint.as_object_mut() {
|
||||
obj.insert("timezone".to_string(), json!(geo.timezone));
|
||||
// Calculate timezone offset from IANA timezone name
|
||||
if let Ok(tz) = geo.timezone.parse::<chrono_tz::Tz>() {
|
||||
use chrono::Offset;
|
||||
let now = chrono::Utc::now().with_timezone(&tz);
|
||||
let offset_seconds = now.offset().fix().local_minus_utc();
|
||||
let offset_minutes = -(offset_seconds / 60);
|
||||
obj.insert("timezoneOffset".to_string(), json!(offset_minutes));
|
||||
}
|
||||
obj.insert("latitude".to_string(), json!(geo.latitude));
|
||||
obj.insert("longitude".to_string(), json!(geo.longitude));
|
||||
let locale_str = geo.locale.as_string();
|
||||
obj.insert("language".to_string(), json!(&locale_str));
|
||||
obj.insert(
|
||||
"languages".to_string(),
|
||||
json!([&locale_str, &geo.locale.language]),
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"Applied geolocation to Wayfern fingerprint: {} ({})",
|
||||
geo.locale.as_string(),
|
||||
geo.timezone
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Geolocation failed, using defaults: {e}");
|
||||
if let Some(obj) = fingerprint.as_object_mut() {
|
||||
if !obj.contains_key("timezone") {
|
||||
obj.insert("timezone".to_string(), json!("America/New_York"));
|
||||
}
|
||||
if !obj.contains_key("timezoneOffset") {
|
||||
obj.insert("timezoneOffset".to_string(), json!(300));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh ONLY the location fields (timezone, offset, latitude/longitude,
|
||||
/// language) of an already-generated fingerprint to match the current proxy,
|
||||
/// leaving every other fingerprint field untouched. `proxy` is the local
|
||||
/// proxy URL the browser will use. Returns the updated fingerprint JSON on
|
||||
/// success, or None if geolocation is disabled or could not be resolved, in
|
||||
/// which case the caller keeps the existing fingerprint and retries on the
|
||||
/// next launch.
|
||||
pub async fn refresh_fingerprint_geolocation(
|
||||
fingerprint_json: &str,
|
||||
proxy: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let mut fp: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
||||
if Self::apply_geolocation(&mut fp, proxy, geoip).await {
|
||||
serde_json::to_string(&fp).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_fingerprint_config(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
@@ -424,70 +554,14 @@ impl WayfernManager {
|
||||
// Normalize the fingerprint: convert JSON string fields to proper types
|
||||
let mut normalized = Self::normalize_fingerprint(fp);
|
||||
|
||||
// Apply geolocation based on proxy IP or geoip config
|
||||
let geoip_option = config.geoip.as_ref();
|
||||
let should_geolocate = match geoip_option {
|
||||
Some(serde_json::Value::Bool(false)) => false,
|
||||
_ => true, // Default to auto-detect
|
||||
};
|
||||
|
||||
if should_geolocate {
|
||||
let geo_result = async {
|
||||
let ip = match geoip_option {
|
||||
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
|
||||
_ => {
|
||||
// Auto-detect IP, optionally through proxy
|
||||
crate::ip_utils::fetch_public_ip(config.proxy.as_deref())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch public IP: {e}"))?
|
||||
}
|
||||
};
|
||||
|
||||
crate::camoufox::geolocation::get_geolocation(&ip)
|
||||
.map_err(|e| format!("Failed to get geolocation for IP {ip}: {e}"))
|
||||
}
|
||||
.await;
|
||||
|
||||
match geo_result {
|
||||
Ok(geo) => {
|
||||
if let Some(obj) = normalized.as_object_mut() {
|
||||
obj.insert("timezone".to_string(), json!(geo.timezone));
|
||||
// Calculate timezone offset from IANA timezone name
|
||||
if let Ok(tz) = geo.timezone.parse::<chrono_tz::Tz>() {
|
||||
use chrono::Offset;
|
||||
let now = chrono::Utc::now().with_timezone(&tz);
|
||||
let offset_seconds = now.offset().fix().local_minus_utc();
|
||||
let offset_minutes = -(offset_seconds / 60);
|
||||
obj.insert("timezoneOffset".to_string(), json!(offset_minutes));
|
||||
}
|
||||
obj.insert("latitude".to_string(), json!(geo.latitude));
|
||||
obj.insert("longitude".to_string(), json!(geo.longitude));
|
||||
let locale_str = geo.locale.as_string();
|
||||
obj.insert("language".to_string(), json!(&locale_str));
|
||||
obj.insert(
|
||||
"languages".to_string(),
|
||||
json!([&locale_str, &geo.locale.language]),
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"Applied geolocation to Wayfern fingerprint: {} ({})",
|
||||
geo.locale.as_string(),
|
||||
geo.timezone
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Geolocation failed, using defaults: {e}");
|
||||
if let Some(obj) = normalized.as_object_mut() {
|
||||
if !obj.contains_key("timezone") {
|
||||
obj.insert("timezone".to_string(), json!("America/New_York"));
|
||||
}
|
||||
if !obj.contains_key("timezoneOffset") {
|
||||
obj.insert("timezoneOffset".to_string(), json!(300));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply timezone/geolocation for the proxy this fingerprint is being
|
||||
// generated against. Shared with the launch-time location refresh.
|
||||
Self::apply_geolocation(
|
||||
&mut normalized,
|
||||
config.proxy.as_deref(),
|
||||
config.geoip.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.27.0",
|
||||
"version": "0.27.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+198
-39
@@ -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?.();
|
||||
@@ -1524,7 +1637,7 @@ export default function Home() {
|
||||
: t(`pageTitle.${currentPage}`);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
||||
<div className="flex h-dvh flex-col bg-background font-(family-name:--font-geist-sans)">
|
||||
<CloseConfirmDialog />
|
||||
<CamoufoxDeprecationDialog profiles={profiles} />
|
||||
<HomeHeader
|
||||
@@ -1537,11 +1650,11 @@ export default function Home() {
|
||||
onGroupSelect={handleSelectGroup}
|
||||
pageTitle={subPageTitle}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
|
||||
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{currentPage === "profiles" && (
|
||||
<div className="px-3 pt-2.5 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2.5">
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -198,11 +198,11 @@ export function AccountPage({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[calc(100vh-4rem)] flex flex-col">
|
||||
<DialogContent className="flex max-h-[calc(100vh-4rem)] max-w-2xl 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",
|
||||
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
@@ -226,16 +226,16 @@ export function AccountPage({
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
@@ -247,7 +247,7 @@ export function AccountPage({
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
@@ -257,39 +257,39 @@ export function AccountPage({
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
@@ -321,7 +321,7 @@ export function AccountPage({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -331,7 +331,7 @@ export function AccountPage({
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
@@ -344,7 +344,7 @@ export function AccountPage({
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
@@ -354,7 +354,7 @@ export function AccountPage({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
@@ -380,7 +380,7 @@ export function AccountPage({
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -431,7 +431,7 @@ export function AccountPage({
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
@@ -449,7 +449,7 @@ export function AccountPage({
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
|
||||
@@ -35,13 +35,13 @@ export function AppUpdateToast({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">
|
||||
<LuCheckCheck className="shrink-0 size-5" />
|
||||
<div className="flex w-full max-w-md items-start rounded-lg border border-border bg-card p-4 text-card-foreground shadow-lg">
|
||||
<div className="mt-0.5 mr-3">
|
||||
<LuCheckCheck className="size-5 shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex gap-2 justify-between items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{updateReady
|
||||
@@ -59,18 +59,18 @@ export function AppUpdateToast({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="p-0 size-6 shrink-0"
|
||||
className="size-6 shrink-0 p-0"
|
||||
>
|
||||
<FaTimes className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{updateReady ? (
|
||||
<RippleButton
|
||||
onClick={() => void handleRestartClick()}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<LuCheckCheck className="size-3" />
|
||||
{t("appUpdate.toast.restartNow")}
|
||||
@@ -81,7 +81,7 @@ export function AppUpdateToast({
|
||||
<RippleButton
|
||||
onClick={handleViewRelease}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<FaExternalLinkAlt className="size-3" />
|
||||
{t("appUpdate.toast.viewRelease")}
|
||||
|
||||
@@ -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 w-full min-w-0 border-none bg-transparent",
|
||||
"relative flex w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-2 transition-colors hover:bg-accent/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 h-3 pointer-events-none">
|
||||
<div className="pointer-events-none h-3 min-w-0 flex-1">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
@@ -111,7 +111,7 @@ export function BandwidthMiniChart({
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0 min-w-[60px] text-right">
|
||||
<span className="min-w-[60px] shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{formatBytes(currentBandwidth)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -149,7 +149,7 @@ export function CamoufoxConfigDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl h-[min(85vh,52rem)] flex flex-col">
|
||||
<DialogContent className="flex h-[min(85vh,52rem)] max-w-3xl flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{isRunning
|
||||
@@ -164,7 +164,7 @@ export function CamoufoxConfigDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="py-4">
|
||||
{profile.browser === "wayfern" ? (
|
||||
<WayfernConfigForm
|
||||
@@ -193,7 +193,7 @@ export function CamoufoxConfigDialog({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
<DialogFooter className="shrink-0 border-t pt-4">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{isRunning ? t("common.buttons.close") : t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
|
||||
@@ -74,7 +74,7 @@ function Tokens({ tokens }: { tokens: string[] }) {
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
|
||||
className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
|
||||
@@ -332,7 +332,7 @@ export function CookieCopyDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[min(48rem,calc(100%-4rem))] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuCookie className="size-5" />
|
||||
@@ -349,7 +349,7 @@ export function CookieCopyDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("cookies.copy.sourceProfile")}</Label>
|
||||
<Select
|
||||
@@ -393,7 +393,7 @@ export function CookieCopyDialog({
|
||||
count: targetProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
|
||||
<div className="max-h-20 overflow-y-auto rounded-md bg-muted p-2">
|
||||
{targetProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sourceProfileId
|
||||
@@ -405,7 +405,7 @@ export function CookieCopyDialog({
|
||||
{targetProfiles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-background rounded text-sm"
|
||||
className="inline-flex items-center gap-1 rounded bg-background px-2 py-0.5 text-sm"
|
||||
>
|
||||
{p.name}
|
||||
{runningProfiles.has(p.id) && (
|
||||
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<LuSearch className="absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("cookies.copy.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
@@ -449,11 +449,11 @@ export function CookieCopyDialog({
|
||||
</div>
|
||||
|
||||
{isLoadingCookies ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin size-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-center text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredDomains.length === 0 ? (
|
||||
@@ -463,8 +463,8 @@ export function CookieCopyDialog({
|
||||
: t("cookies.copy.noFound")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredDomains.map((domain) => (
|
||||
<DomainRow
|
||||
key={domain.domain}
|
||||
@@ -549,7 +549,7 @@ function DomainRow({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 p-2 hover:bg-accent/50 rounded">
|
||||
<div className="flex items-center gap-2 rounded p-2 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => {
|
||||
@@ -559,7 +559,7 @@ function DomainRow({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left bg-transparent border-none cursor-pointer"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left"
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
@@ -569,21 +569,21 @@ function DomainRow({
|
||||
) : (
|
||||
<LuChevronRight className="size-4" />
|
||||
)}
|
||||
<span className="font-medium truncate">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
<span className="truncate font-medium">{domain.domain}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-8 pl-2 border-l space-y-1">
|
||||
<div className="ml-8 space-y-1 border-l pl-2">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
|
||||
@@ -409,7 +409,7 @@ export function CookieManagementDialog({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4">
|
||||
<TabsContent value="import" className="mt-4 space-y-4">
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -418,7 +418,7 @@ export function CookieManagementDialog({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-8 transition-colors hover:border-muted-foreground/50"
|
||||
onClick={() =>
|
||||
document.getElementById("cookie-file-input")?.click()
|
||||
}
|
||||
@@ -429,8 +429,8 @@ export function CookieManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="size-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<LuUpload className="mb-4 size-10 text-muted-foreground" />
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("cookies.management.dropPrompt")}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
@@ -454,7 +454,7 @@ export function CookieManagementDialog({
|
||||
|
||||
{fileContent && !importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -481,7 +481,7 @@ export function CookieManagementDialog({
|
||||
|
||||
{importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-success/10">
|
||||
<div className="rounded-lg bg-success/10 p-4">
|
||||
<div className="font-medium text-success">
|
||||
{t("cookies.management.importedSuccess", {
|
||||
imported: importResult.cookies_imported,
|
||||
@@ -505,7 +505,7 @@ export function CookieManagementDialog({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="space-y-3 mt-4">
|
||||
<TabsContent value="export" className="mt-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("cookies.export.formatLabel")}</Label>
|
||||
<Select
|
||||
@@ -533,7 +533,7 @@ export function CookieManagementDialog({
|
||||
<Label>
|
||||
{t("cookies.management.cookiesLabel")}{" "}
|
||||
{exportCookieData && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{t("cookies.management.selectionStatus", {
|
||||
selected: selectedExportCount,
|
||||
total: exportCookieData.total_count,
|
||||
@@ -544,7 +544,7 @@ export function CookieManagementDialog({
|
||||
{exportCookieData && exportCookieData.total_count > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={toggleSelectAll}
|
||||
>
|
||||
{selectedExportCount === exportCookieData.total_count
|
||||
@@ -555,16 +555,16 @@ export function CookieManagementDialog({
|
||||
</div>
|
||||
|
||||
{isLoadingExportCookies ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
|
||||
<div className="rounded-md border p-4 text-center text-sm text-muted-foreground">
|
||||
{t("cookies.management.noCookies")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea className="h-[clamp(140px,30vh,420px)]">
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{exportCookieData.domains.map((domain) => (
|
||||
<ExportDomainRow
|
||||
key={domain.domain}
|
||||
@@ -629,7 +629,7 @@ function ExportDomainRow({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
|
||||
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => {
|
||||
@@ -639,7 +639,7 @@ function ExportDomainRow({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left text-sm"
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
@@ -649,21 +649,21 @@ function ExportDomainRow({
|
||||
) : (
|
||||
<LuChevronRight className="size-3.5" />
|
||||
)}
|
||||
<span className="font-medium truncate">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
<span className="truncate font-medium">{domain.domain}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-7 pl-2 border-l space-y-0.5">
|
||||
<div className="ml-7 space-y-0.5 border-l pl-2">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
|
||||
@@ -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 {
|
||||
@@ -93,7 +93,7 @@ export function CreateGroupDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -534,7 +534,7 @@ export function CreateProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(48rem,calc(100%-4rem))] flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
@@ -551,13 +551,13 @@ export function CreateProfileDialog({
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex flex-col flex-1 w-full min-h-0"
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
{/* Tab list hidden - only anti-detect browsers are supported */}
|
||||
|
||||
<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">
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
<div className="flex w-full flex-col items-center justify-center">
|
||||
<div className="w-full space-y-6 py-4">
|
||||
{currentStep === "browser-selection" ? (
|
||||
<>
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
@@ -569,10 +569,10 @@ export function CreateProfileDialog({
|
||||
handleBrowserSelect("wayfern");
|
||||
}}
|
||||
disabled={!getCreatableVersion("wayfern")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
className="flex h-16 w-full items-center justify-start gap-3 border-2 p-4 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
<div className="flex size-8 items-center justify-center">
|
||||
{isBrowserCurrentlyDownloading("wayfern") ? (
|
||||
<LuLoaderCircle className="size-6 animate-spin" />
|
||||
) : (
|
||||
@@ -600,7 +600,7 @@ export function CreateProfileDialog({
|
||||
profiles. Only Wayfern can be created. */}
|
||||
|
||||
{!getCreatableVersion("wayfern") && (
|
||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||
<p className="pt-2 text-center text-sm text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
@@ -629,10 +629,10 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
handleBrowserSelect(browser.value);
|
||||
}}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
className="flex h-16 w-full items-center justify-start gap-3 border-2 p-4 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
<div className="flex size-8 items-center justify-center">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-6" />
|
||||
)}
|
||||
@@ -684,7 +684,7 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
|
||||
{/* Ephemeral Option */}
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="ephemeral"
|
||||
@@ -697,14 +697,14 @@ export function CreateProfileDialog({
|
||||
{t("profiles.ephemeral")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
<p className="ml-6 text-sm text-muted-foreground">
|
||||
{t("profiles.ephemeralDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Option */}
|
||||
{!ephemeral && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="enable-password"
|
||||
@@ -725,7 +725,7 @@ export function CreateProfileDialog({
|
||||
{t("createProfile.passwordProtect.label")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
<p className="ml-6 text-sm text-muted-foreground">
|
||||
{t("createProfile.passwordProtect.description")}
|
||||
</p>
|
||||
{enablePassword && (
|
||||
@@ -769,15 +769,15 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Wayfern Download Status */}
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
@@ -796,7 +796,7 @@ export function CreateProfileDialog({
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-warning/50 bg-warning/10 p-3">
|
||||
<p className="text-sm text-warning">
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Wayfern",
|
||||
@@ -809,7 +809,7 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
!getCreatableVersion("wayfern") &&
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Wayfern",
|
||||
@@ -840,7 +840,7 @@ export function CreateProfileDialog({
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
getCreatableVersion("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Wayfern",
|
||||
@@ -855,7 +855,7 @@ export function CreateProfileDialog({
|
||||
getCreatableVersion("wayfern") &&
|
||||
!isBrowserVersionAvailable("wayfern") &&
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.upgradeAvailable",
|
||||
@@ -887,7 +887,7 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
@@ -915,8 +915,8 @@ export function CreateProfileDialog({
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
@@ -924,7 +924,7 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
@@ -947,7 +947,7 @@ export function CreateProfileDialog({
|
||||
) &&
|
||||
!getCreatableVersion(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
@@ -1016,7 +1016,7 @@ export function CreateProfileDialog({
|
||||
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -1024,7 +1024,7 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
setShowProxyForm(true);
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 size-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
@@ -1144,7 +1144,7 @@ export function CreateProfileDialog({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -1158,7 +1158,7 @@ export function CreateProfileDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3 text-sm text-muted-foreground">
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
@@ -1285,15 +1285,15 @@ export function CreateProfileDialog({
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
@@ -1316,7 +1316,7 @@ export function CreateProfileDialog({
|
||||
) &&
|
||||
!getCreatableVersion(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
@@ -1383,7 +1383,7 @@ export function CreateProfileDialog({
|
||||
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -1391,7 +1391,7 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
setShowProxyForm(true);
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 size-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
@@ -1511,7 +1511,7 @@ export function CreateProfileDialog({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -1525,7 +1525,7 @@ export function CreateProfileDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3 text-sm text-muted-foreground">
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
@@ -1556,7 +1556,7 @@ export function CreateProfileDialog({
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
<DialogFooter className="shrink-0 border-t pt-4">
|
||||
{currentStep === "browser-config" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleBack}>
|
||||
|
||||
@@ -162,34 +162,34 @@ function formatEtaCompact(seconds: number): string {
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuCheckCheck className="size-4 shrink-0 text-foreground" />;
|
||||
case "error":
|
||||
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuTriangleAlert className="size-4 shrink-0 text-foreground" />;
|
||||
case "download":
|
||||
if (stage === "completed") {
|
||||
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuCheckCheck className="size-4 shrink-0 text-foreground" />;
|
||||
}
|
||||
return <LuDownload className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuDownload className="size-4 shrink-0 text-foreground" />;
|
||||
|
||||
case "version-update":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="size-4 shrink-0 animate-spin text-foreground" />
|
||||
);
|
||||
case "fetching":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="size-4 shrink-0 animate-spin text-foreground" />
|
||||
);
|
||||
case "sync-progress":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="size-4 shrink-0 animate-spin text-foreground" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
<div className="size-4 shrink-0 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
<div className="size-4 shrink-0 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -201,18 +201,16 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
return (
|
||||
<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 w-full max-w-md items-start rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg">
|
||||
<div className="mt-0.5 mr-3">{getToastIcon(type, stage)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-sm/tight font-semibold text-foreground">{title}</p>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="size-3" />
|
||||
@@ -226,17 +224,17 @@ export function UnifiedToast(props: ToastProps) {
|
||||
"percentage" in progress &&
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta &&
|
||||
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -255,15 +253,15 @@ export function UnifiedToast(props: ToastProps) {
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||
<div className="h-1.5 min-w-0 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-xs text-right whitespace-nowrap text-muted-foreground shrink-0">
|
||||
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
@@ -299,7 +297,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
})}`}
|
||||
</p>
|
||||
{progress.failed_count > 0 && (
|
||||
<p className="text-xs text-destructive mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-destructive">
|
||||
{t("toasts.progress.filesFailed", {
|
||||
count: progress.failed_count,
|
||||
})}
|
||||
@@ -310,7 +308,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
<p className="mt-1 text-xs/tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
|
||||
{...props}
|
||||
>
|
||||
{isPending ? (
|
||||
<div className="size-3.5 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
<div className="size-3.5 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
@@ -142,7 +142,7 @@ function DataTableActionBarSelection<TData>({
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
<span className="text-xs whitespace-nowrap">
|
||||
{t("dataTableActionBar.selected", {
|
||||
count: table.getFilteredSelectedRowModel().rows.length,
|
||||
})}
|
||||
@@ -164,7 +164,7 @@ function DataTableActionBarSelection<TData>({
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
|
||||
>
|
||||
<p>{t("dataTableActionBar.clearSelection")}</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<kbd className="rounded border bg-background px-1.5 py-px font-mono text-[0.7rem] font-normal text-foreground shadow-xs select-none">
|
||||
<abbr title={t("common.keys.escape")} className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
|
||||
@@ -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 = [],
|
||||
@@ -48,10 +55,10 @@ export function DeleteConfirmationDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
{profileIds && profileIds.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("deleteDialog.profilesToDelete")}
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1">
|
||||
{profileIds.map((id) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
@@ -59,7 +66,7 @@ export function DeleteConfirmationDialog({
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
>
|
||||
• {displayName}
|
||||
</li>
|
||||
@@ -79,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="max-h-[min(8rem,25vh)] overflow-y-auto w-full border rounded-md p-3">
|
||||
<ScrollArea className="max-h-[min(8rem,25vh)] w-full overflow-y-auto rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
{associatedProfiles.map((profile) => (
|
||||
<div key={profile.id} className="text-sm truncate">
|
||||
<div key={profile.id} className="truncate text-sm">
|
||||
• {profile.name}
|
||||
</div>
|
||||
))}
|
||||
@@ -184,7 +184,7 @@ export function DeleteGroupDialog({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function DnsBlocklistDialog({
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 overflow-y-auto min-h-0 max-h-[40vh]">
|
||||
<div className="max-h-[40vh] min-h-0 space-y-3 overflow-y-auto">
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
@@ -100,18 +100,18 @@ export function DnsBlocklistDialog({
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="text-[10px] px-1.5">
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5">
|
||||
<Badge variant="secondary" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.stale")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 text-muted-foreground"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
|
||||
@@ -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 {
|
||||
@@ -103,7 +103,7 @@ export function EditGroupDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -110,8 +110,8 @@ 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-[min(8rem,20vh)] overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
<div className="max-h-[min(8rem,20vh)] overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
const profile = profiles.find(
|
||||
(p: BrowserProfile) => p.id === profileId,
|
||||
@@ -160,7 +160,7 @@ export function ExtensionGroupAssignmentDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -655,7 +655,7 @@ export function ExtensionManagementDialog({
|
||||
const hasFirefox = compat.includes("firefox");
|
||||
if (!hasChromium && !hasFirefox) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{hasChromium && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -753,7 +753,7 @@ export function ExtensionManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -764,7 +764,7 @@ export function ExtensionManagementDialog({
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-medium truncate min-w-0 block">
|
||||
<span className="block min-w-0 truncate text-sm font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
@@ -786,7 +786,7 @@ export function ExtensionManagementDialog({
|
||||
const ext = row.original;
|
||||
const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t);
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -801,7 +801,7 @@ export function ExtensionManagementDialog({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center shrink-0">
|
||||
<span className="inline-flex shrink-0 items-center">
|
||||
<AnimatedSwitch
|
||||
checked={ext.sync_enabled}
|
||||
onCheckedChange={() => void handleToggleExtSync(ext)}
|
||||
@@ -829,7 +829,7 @@ export function ExtensionManagementDialog({
|
||||
cell: ({ row }) => {
|
||||
const ext = row.original;
|
||||
return (
|
||||
<div className="flex gap-0.5 justify-end shrink-0">
|
||||
<div className="flex shrink-0 justify-end gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -927,7 +927,7 @@ export function ExtensionManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -938,7 +938,7 @@ export function ExtensionManagementDialog({
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium text-sm truncate min-w-0 block">
|
||||
<span className="block min-w-0 truncate text-sm font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
@@ -956,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 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{visibleExts.map((ext) => (
|
||||
<Tooltip key={ext.id}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -972,7 +972,7 @@ export function ExtensionManagementDialog({
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs h-5 px-1.5 shrink-0"
|
||||
className="h-5 shrink-0 px-1.5 text-xs"
|
||||
>
|
||||
+{overflowCount}
|
||||
</Badge>
|
||||
@@ -989,7 +989,7 @@ export function ExtensionManagementDialog({
|
||||
</Tooltip>
|
||||
)}
|
||||
{groupExts.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground truncate min-w-0">
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</span>
|
||||
)}
|
||||
@@ -1010,7 +1010,7 @@ export function ExtensionManagementDialog({
|
||||
t,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -1025,7 +1025,7 @@ export function ExtensionManagementDialog({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center shrink-0">
|
||||
<span className="inline-flex shrink-0 items-center">
|
||||
<AnimatedSwitch
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() => void handleToggleGroupSync(group)}
|
||||
@@ -1053,7 +1053,7 @@ export function ExtensionManagementDialog({
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
return (
|
||||
<div className="flex gap-0.5 justify-end shrink-0">
|
||||
<div className="flex shrink-0 justify-end gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1116,7 +1116,7 @@ export function ExtensionManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -1130,15 +1130,15 @@ export function ExtensionManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="@container relative w-full flex-1 min-h-0 flex flex-col">
|
||||
<div className="@container relative flex min-h-0 w-full flex-1 flex-col">
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="absolute inset-0 z-1 bg-background/30 backdrop-blur-[6px]" />
|
||||
<div className="absolute inset-y-0 left-0 z-2 w-6 bg-linear-to-r from-background to-transparent" />
|
||||
<div className="absolute inset-y-0 right-0 z-2 w-6 bg-linear-to-l from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 z-2 h-6 bg-linear-to-b from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 z-2 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="absolute inset-0 z-3 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
@@ -1153,9 +1153,9 @@ export function ExtensionManagementDialog({
|
||||
key={initialTab}
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger
|
||||
value="extensions"
|
||||
@@ -1219,15 +1219,15 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
|
||||
{/* Notice */}
|
||||
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground mt-4 shrink-0">
|
||||
<div className="mt-4 shrink-0 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
{t("extensions.managedNotice")}
|
||||
</div>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="extensions"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<Input
|
||||
id="ext-file-input"
|
||||
type="file"
|
||||
@@ -1291,7 +1291,7 @@ export function ExtensionManagementDialog({
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
"min-h-0 flex-1",
|
||||
selectedExtensions.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
@@ -1367,12 +1367,12 @@ export function ExtensionManagementDialog({
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="groups"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{/* Create group form */}
|
||||
{showCreateGroup && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => {
|
||||
@@ -1412,7 +1412,7 @@ export function ExtensionManagementDialog({
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
"min-h-0 flex-1",
|
||||
selectedGroups.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
@@ -1509,7 +1509,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1517,7 +1517,7 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
<ScrollArea className="-mx-6 flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
@@ -1562,11 +1562,11 @@ export function ExtensionManagementDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.groupExtensions")}</Label>
|
||||
{editGroupExtensionIds.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[min(40vh,320px)] overflow-y-auto">
|
||||
<div className="max-h-[min(40vh,320px)] space-y-1 overflow-y-auto">
|
||||
{editGroupExtensionIds.map((extId) => {
|
||||
const ext = extensions.find((e) => e.id === extId);
|
||||
if (!ext) return null;
|
||||
@@ -1576,14 +1576,14 @@ export function ExtensionManagementDialog({
|
||||
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
|
||||
>
|
||||
{renderExtensionIcon(ext, "sm")}
|
||||
<span className="text-sm flex-1 truncate min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{ext.name}
|
||||
</span>
|
||||
{renderCompatIcons(ext.browser_compatibility)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 shrink-0"
|
||||
className="size-6 shrink-0 p-0"
|
||||
onClick={() => {
|
||||
setEditGroupExtensionIds((prev) =>
|
||||
prev.filter((id) => id !== extId),
|
||||
@@ -1633,7 +1633,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editExtension")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1641,7 +1641,7 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
<ScrollArea className="-mx-6 flex-1 overflow-y-auto px-6">
|
||||
{editingExtension && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1659,8 +1659,8 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
|
||||
{/* Metadata from manifest.json */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<Label className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
{t("extensions.metadata")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
@@ -1711,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 min-w-0"
|
||||
className="flex min-w-0 items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{editingExtension.homepage_url}
|
||||
@@ -1724,7 +1724,7 @@ export function ExtensionManagementDialog({
|
||||
!editingExtension.author &&
|
||||
!editingExtension.description &&
|
||||
!editingExtension.homepage_url && (
|
||||
<span className="col-span-2 text-muted-foreground text-xs">
|
||||
<span className="col-span-2 text-xs text-muted-foreground">
|
||||
{t("extensions.noMetadata")}
|
||||
</span>
|
||||
)}
|
||||
@@ -1734,7 +1734,7 @@ export function ExtensionManagementDialog({
|
||||
{/* Re-upload */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.reupload")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1742,7 +1742,7 @@ export function ExtensionManagementDialog({
|
||||
document.getElementById("ext-edit-file-input")?.click()
|
||||
}
|
||||
>
|
||||
<LuUpload className="size-3 mr-1" />
|
||||
<LuUpload className="mr-1 size-3" />
|
||||
{t("extensions.selectFile")}
|
||||
</RippleButton>
|
||||
<input
|
||||
@@ -1753,7 +1753,7 @@ export function ExtensionManagementDialog({
|
||||
onChange={handleEditFileSelect}
|
||||
/>
|
||||
{pendingUpdateFile && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
<span className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||
{pendingUpdateFile.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -134,8 +134,8 @@ 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-[min(8rem,20vh)] overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
<div className="max-h-[min(8rem,20vh)] overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
// Find the profile name for display
|
||||
const profile = profiles.find(
|
||||
@@ -153,7 +153,7 @@ export function GroupAssignmentDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="group-select">
|
||||
{t("groupAssignment.assignGroupLabel")}
|
||||
</Label>
|
||||
@@ -198,7 +198,7 @@ export function GroupAssignmentDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -327,7 +327,7 @@ export function GroupManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -346,7 +346,7 @@ export function GroupManagementDialog({
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-medium min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -495,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"));
|
||||
}
|
||||
@@ -507,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);
|
||||
}
|
||||
@@ -553,7 +557,7 @@ export function GroupManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-[min(60rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(60rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("groups.management")}</DialogTitle>
|
||||
@@ -563,7 +567,7 @@ export function GroupManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="w-full flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-semibold">
|
||||
@@ -578,7 +582,7 @@ export function GroupManagementDialog({
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center shrink-0"
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("proxies.management.create")}
|
||||
@@ -586,7 +590,7 @@ export function GroupManagementDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -603,7 +607,7 @@ export function GroupManagementDialog({
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
"min-h-0 flex-1",
|
||||
selectedGroupsForBulk.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
|
||||
@@ -175,7 +175,7 @@ const HomeHeader = ({
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
|
||||
// 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
|
||||
@@ -187,24 +187,24 @@ const HomeHeader = ({
|
||||
{isMacOS && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex items-center gap-[7px] mr-1 shrink-0"
|
||||
className="mr-1 flex shrink-0 items-center gap-[7px]"
|
||||
>
|
||||
{/* Reserve space for the macOS native traffic lights — the OS draws
|
||||
the colored buttons here through the transparent titlebar. */}
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="size-[11px] rounded-full" />
|
||||
<div className="size-[11px] rounded-full" />
|
||||
<div className="size-[11px] rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageTitle ? (
|
||||
<span className="text-xs font-semibold text-card-foreground ml-2">
|
||||
<span className="ml-2 text-xs font-semibold text-card-foreground">
|
||||
{pageTitle}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<div className="relative flex-1 min-w-0 flex items-center">
|
||||
<div className="relative flex min-w-0 flex-1 items-center">
|
||||
{groupsFadeLeft && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -217,14 +217,14 @@ const HomeHeader = ({
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
|
||||
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<LuChevronLeft className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={groupsScrollRef}
|
||||
className="flex items-center gap-3 ml-2 overflow-x-auto scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
className="ml-2 flex scrollbar-none items-center gap-3 overflow-x-auto scroll-smooth [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
style={{
|
||||
paddingLeft: groupsFadeLeft ? 22 : 0,
|
||||
paddingRight: groupsFadeRight ? 22 : 0,
|
||||
@@ -241,9 +241,9 @@ const HomeHeader = ({
|
||||
onGroupSelect(ALL_FILTER_ID);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "text-foreground font-medium"
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -265,9 +265,9 @@ const HomeHeader = ({
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "text-foreground font-medium"
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -291,7 +291,7 @@ const HomeHeader = ({
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
|
||||
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<LuChevronRight className="size-3" />
|
||||
</button>
|
||||
@@ -310,16 +310,16 @@ const HomeHeader = ({
|
||||
onChange={(e) => {
|
||||
onSearchQueryChange(e.target.value);
|
||||
}}
|
||||
className="pr-7 pl-8 w-36 min-[860px]:w-52 h-7 text-xs"
|
||||
className="h-7 w-36 pr-7 pl-8 text-xs min-[860px]:w-52"
|
||||
/>
|
||||
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<LuSearch className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 transform text-muted-foreground" />
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchQueryChange("");
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 p-0.5 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
className="absolute top-1/2 right-1.5 -translate-y-1/2 transform rounded-sm p-0.5 transition-colors hover:bg-accent"
|
||||
aria-label={t("header.clearSearch")}
|
||||
>
|
||||
<LuX className="size-3.5 text-muted-foreground hover:text-foreground" />
|
||||
@@ -338,7 +338,7 @@ const HomeHeader = ({
|
||||
onClick={() => {
|
||||
onCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
|
||||
className="flex h-7 items-center gap-1.5 px-2.5 text-xs"
|
||||
>
|
||||
<GoPlus className="size-3.5" />
|
||||
{t("header.newProfile")}
|
||||
|
||||
@@ -306,7 +306,7 @@ export function ImportProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[min(48rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
@@ -315,7 +315,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto flex-1 space-y-6 min-h-0",
|
||||
"min-h-0 flex-1 space-y-6 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
@@ -389,7 +389,7 @@ export function ImportProfileDialog({
|
||||
key={profile.path}
|
||||
value={profile.path}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-4" />
|
||||
)}
|
||||
@@ -413,7 +413,7 @@ export function ImportProfileDialog({
|
||||
</div>
|
||||
|
||||
{selectedProfile && (
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<p className="text-sm break-all">
|
||||
<span className="font-medium">
|
||||
{t("importProfile.pathLabel")}
|
||||
@@ -481,7 +481,7 @@ export function ImportProfileDialog({
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
return (
|
||||
<SelectItem key={browser} value={browser}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-4" />
|
||||
)}
|
||||
@@ -518,7 +518,7 @@ export function ImportProfileDialog({
|
||||
<FaFolder className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground break-all">
|
||||
<p className="mt-2 text-xs break-all text-muted-foreground">
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
@@ -604,9 +604,9 @@ export function ImportProfileDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex gap-2 items-center justify-end",
|
||||
"flex shrink-0 items-center justify-end gap-2",
|
||||
subPage
|
||||
? "pt-2 border-t border-border mx-auto w-full max-w-2xl"
|
||||
? "mx-auto w-full max-w-2xl border-t border-border pt-2"
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -308,7 +308,7 @@ export function IntegrationsDialog({
|
||||
}}
|
||||
subPage={subPage}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[calc(100vh-5rem)] flex flex-col">
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
@@ -317,8 +317,8 @@ export function IntegrationsDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto flex-1 min-h-0",
|
||||
subPage && "w-full max-w-3xl mx-auto",
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
@@ -333,12 +333,12 @@ export function IntegrationsDialog({
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="mt-4 flex flex-col gap-4 @container"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="size-5 mt-0.5 text-muted-foreground" />
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
@@ -370,9 +370,9 @@ export function IntegrationsDialog({
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<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">
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -463,9 +463,9 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -475,13 +475,13 @@ export function IntegrationsDialog({
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="font-mono pr-10"
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
@@ -501,9 +501,9 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiExampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
@@ -511,7 +511,7 @@ export function IntegrationsDialog({
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
@@ -524,10 +524,10 @@ export function IntegrationsDialog({
|
||||
value="mcp"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuZap className="size-5 mt-0.5 text-muted-foreground" />
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
@@ -552,8 +552,8 @@ export function IntegrationsDialog({
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
@@ -562,13 +562,13 @@ export function IntegrationsDialog({
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="font-mono text-xs pr-10"
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
@@ -587,32 +587,32 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 @container">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 @2xl:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="rounded-md border bg-card px-3 py-2.5 flex items-center gap-3"
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid place-items-center size-8 rounded-md bg-muted shrink-0">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{categoryLabel(t, agent.category)}
|
||||
</p>
|
||||
</div>
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-foreground">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
|
||||
@@ -233,7 +233,7 @@ export function LocationProxyDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto min-h-0 max-h-[calc(100vh-16rem)] pr-1">
|
||||
<div className="max-h-[calc(100vh-16rem)] min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
|
||||
@@ -152,7 +152,7 @@ const CommandEmpty = forwardRef<
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn("py-6 text-sm text-center", className)}
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
cmdk-empty=""
|
||||
role="presentation"
|
||||
{...props}
|
||||
@@ -428,8 +428,8 @@ const MultipleSelector = React.forwardRef<
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
|
||||
"data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
|
||||
"data-disabled:bg-muted-foreground data-disabled:text-muted data-disabled:hover:bg-muted-foreground",
|
||||
"data-fixed:bg-muted-foreground data-fixed:text-muted data-fixed:hover:bg-muted-foreground",
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
@@ -439,7 +439,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"ml-1 cursor-pointer rounded-full ring-offset-background outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
(disabled ?? option.fixed) && "hidden",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -525,7 +525,7 @@ const MultipleSelector = React.forwardRef<
|
||||
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
|
||||
{
|
||||
"w-full": hidePlaceholderWhenSelected,
|
||||
"px-3 mt-1": selected.length === 0,
|
||||
"mt-1 px-3": selected.length === 0,
|
||||
"ml-1": selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
@@ -537,7 +537,7 @@ const MultipleSelector = React.forwardRef<
|
||||
{open && hasAvailableOptions && (
|
||||
<CommandList
|
||||
className={cn(
|
||||
"absolute z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in",
|
||||
"absolute z-10 w-full animate-in rounded-md border bg-popover text-popover-foreground shadow-md outline-none",
|
||||
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
>
|
||||
@@ -554,7 +554,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<CommandGroup
|
||||
key={key}
|
||||
heading={key}
|
||||
className="overflow-auto max-h-48"
|
||||
className="max-h-48 overflow-auto"
|
||||
>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
|
||||
@@ -28,26 +28,26 @@ export function OnboardingCard({
|
||||
const requiresAction = step.selector === '[data-onborda="create-profile"]';
|
||||
|
||||
return (
|
||||
<div className="relative p-4 w-80 max-w-[90vw] rounded-lg border shadow-lg bg-popover text-popover-foreground">
|
||||
<div className="flex gap-2 items-start justify-between">
|
||||
<h3 className="text-sm font-semibold leading-tight">{step.title}</h3>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
<div className="relative w-80 max-w-[90vw] rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm/tight font-semibold">{step.title}</h3>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{currentStep + 1}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<div className="mt-2 text-xs/relaxed text-muted-foreground">
|
||||
{step.content}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-between mt-4">
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
{isLast ? (
|
||||
<span />
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
}}
|
||||
@@ -56,12 +56,12 @@ export function OnboardingCard({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFirst && !isLast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2.5"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => {
|
||||
prevStep();
|
||||
}}
|
||||
@@ -72,7 +72,7 @@ export function OnboardingCard({
|
||||
{isLast ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs h-7 px-3"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
|
||||
@@ -83,7 +83,7 @@ export function OnboardingCard({
|
||||
) : requiresAction ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs h-7 px-3"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
nextStep();
|
||||
}}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function PermissionDialog({
|
||||
|
||||
<div className="space-y-4">
|
||||
{!isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-warning/10 rounded-lg">
|
||||
<div className="rounded-lg bg-warning/10 p-3">
|
||||
<p className="text-sm text-warning">
|
||||
{permissionType === "microphone"
|
||||
? t("permissionDialog.notGrantedMicrophone")
|
||||
|
||||
@@ -51,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,
|
||||
@@ -358,10 +365,10 @@ function ExtCell({
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
|
||||
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<LuPuzzle className="size-3 shrink-0" />
|
||||
<span className="truncate flex-1" title={label}>
|
||||
<span className="flex-1 truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
@@ -453,7 +460,7 @@ function DnsCell({
|
||||
type="button"
|
||||
data-onborda="dns-blocklist"
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
|
||||
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
|
||||
title={
|
||||
level
|
||||
? meta.t("profiles.table.dnsLevel", { level })
|
||||
@@ -674,9 +681,9 @@ const TagsCell = React.memo<{
|
||||
type="button"
|
||||
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
|
||||
className={cn(
|
||||
"flex overflow-hidden gap-1 items-center px-2 py-1 h-6 w-full bg-transparent rounded border-none cursor-pointer",
|
||||
"flex h-6 w-full cursor-pointer items-center gap-1 overflow-hidden rounded border-none bg-transparent px-2 py-1",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -702,7 +709,7 @@ const TagsCell = React.memo<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-6 cursor-pointer">
|
||||
<div className="h-6 w-full cursor-pointer">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
|
||||
{hiddenCount > 0 && (
|
||||
@@ -728,13 +735,13 @@ const TagsCell = React.memo<{
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-6 relative",
|
||||
isDisabled && "opacity-60 pointer-events-none",
|
||||
"relative h-6 w-full",
|
||||
isDisabled && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="absolute top-0 left-0 z-50 w-40 min-h-6 bg-popover rounded-md shadow-md"
|
||||
className="absolute top-0 left-0 z-50 min-h-6 w-40 rounded-md bg-popover shadow-md"
|
||||
>
|
||||
<MultipleSelector
|
||||
value={valueOptions}
|
||||
@@ -748,11 +755,11 @@ const TagsCell = React.memo<{
|
||||
: ""
|
||||
}
|
||||
className={cn(
|
||||
"bg-transparent border-0! focus-within:ring-0!",
|
||||
"border-0! bg-transparent focus-within:ring-0!",
|
||||
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
|
||||
"[&_div:first-child]:min-h-6! [&_div:first-child]:px-2! [&_div:first-child]:py-1!",
|
||||
"[&_div:first-child>div]:items-center [&_div:first-child>div]:h-6!",
|
||||
"[&_input]:ml-0! [&_input]:mt-0! [&_input]:px-0!",
|
||||
"[&_div:first-child>div]:h-6! [&_div:first-child>div]:items-center",
|
||||
"[&_input]:mt-0! [&_input]:ml-0! [&_input]:px-0!",
|
||||
!isFocused && "[&_div:first-child>div]:justify-center",
|
||||
)}
|
||||
badgeClassName="shrink-0"
|
||||
@@ -852,7 +859,7 @@ const OverflowTooltipText = React.memo<{
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("block min-w-0 max-w-full truncate", className)}
|
||||
className={cn("block max-w-full min-w-0 truncate", className)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
@@ -887,16 +894,16 @@ const ProxyCellTrigger = React.memo<{
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-1 rounded min-w-0 max-w-full",
|
||||
"flex max-w-full min-w-0 items-center gap-2 rounded px-2 py-1",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
? "pointer-events-none cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{vpnBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight shrink-0"
|
||||
className="shrink-0 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
{vpnBadge}
|
||||
</Badge>
|
||||
@@ -904,7 +911,7 @@ const ProxyCellTrigger = React.memo<{
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn(
|
||||
"text-sm min-w-0 truncate",
|
||||
"min-w-0 truncate text-sm",
|
||||
!hasAssignment && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -1030,15 +1037,15 @@ const NoteCell = React.memo<{
|
||||
|
||||
if (openNoteEditorFor !== profile.id) {
|
||||
return (
|
||||
<div className="w-full min-h-6">
|
||||
<div className="min-h-6 w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center px-2 py-1 min-h-6 w-full min-w-0 bg-transparent rounded border-none text-left",
|
||||
"flex min-h-6 w-full min-w-0 items-center rounded border-none bg-transparent px-2 py-1 text-left",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -1050,7 +1057,7 @@ const NoteCell = React.memo<{
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate block w-full",
|
||||
"block w-full truncate text-sm",
|
||||
!effectiveNote && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -1060,7 +1067,7 @@ const NoteCell = React.memo<{
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent className="max-w-[320px]">
|
||||
<p className="whitespace-pre-wrap wrap-break-word">
|
||||
<p className="wrap-break-word whitespace-pre-wrap">
|
||||
{effectiveNote ?? t("profiles.note.empty")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
@@ -1073,13 +1080,13 @@ const NoteCell = React.memo<{
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full relative",
|
||||
isDisabled && "opacity-60 pointer-events-none",
|
||||
"relative w-full",
|
||||
isDisabled && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="absolute -top-[15px] -left-px z-50 w-60 min-h-6 bg-popover rounded-md shadow-md border"
|
||||
className="absolute top-[-15px] -left-px z-50 min-h-6 w-60 rounded-md border bg-popover shadow-md"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -1099,7 +1106,7 @@ const NoteCell = React.memo<{
|
||||
setOpenNoteEditorFor(null);
|
||||
}}
|
||||
placeholder={t("profiles.note.placeholder")}
|
||||
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
|
||||
className="max-h-[200px] min-h-6 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm focus:ring-0 focus:outline-none"
|
||||
style={{
|
||||
overflow: "auto",
|
||||
}}
|
||||
@@ -1134,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;
|
||||
@@ -1179,6 +1189,9 @@ export function ProfilesDataTable({
|
||||
onBulkGroupAssignment,
|
||||
onBulkProxyAssignment,
|
||||
onBulkCopyCookies,
|
||||
onBulkRun,
|
||||
onBulkStop,
|
||||
bulkActionsUnlocked = false,
|
||||
onBulkExtensionGroupAssignment,
|
||||
onAssignExtensionGroup,
|
||||
onOpenProfileSyncDialog,
|
||||
@@ -1237,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);
|
||||
}
|
||||
@@ -1546,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;
|
||||
@@ -1578,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;
|
||||
}
|
||||
}
|
||||
@@ -2084,18 +2103,18 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center size-4">
|
||||
<span className="flex size-4 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
className="flex cursor-pointer items-center justify-center border-none p-0"
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="size-4 group">
|
||||
<span className="group size-4">
|
||||
<OsIcon className="size-4 text-muted-foreground group-hover:hidden" />
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
|
||||
<span className="peer pointer-events-none hidden size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow duration-150 outline-none group-hover:block dark:bg-input/30 dark:data-[state=checked]:bg-primary" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
@@ -2123,7 +2142,7 @@ export function ProfilesDataTable({
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
<span className="flex justify-center items-center size-4">
|
||||
<span className="flex size-4 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) => {
|
||||
@@ -2149,7 +2168,7 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center size-4 cursor-not-allowed">
|
||||
<span className="flex size-4 cursor-not-allowed items-center justify-center">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-4 opacity-50" />
|
||||
)}
|
||||
@@ -2171,7 +2190,7 @@ export function ProfilesDataTable({
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
<span className="flex justify-center items-center size-4">
|
||||
<span className="flex size-4 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) => {
|
||||
@@ -2191,20 +2210,20 @@ export function ProfilesDataTable({
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
<span className="flex relative justify-center items-center size-4">
|
||||
<span className="relative flex size-4 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
className="flex cursor-pointer items-center justify-center border-none p-0"
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="size-4 group">
|
||||
<span className="group size-4">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-4 group-hover:hidden" />
|
||||
)}
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
|
||||
<span className="peer pointer-events-none hidden size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow duration-150 outline-none group-hover:block dark:bg-input/30 dark:data-[state=checked]:bg-primary" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
@@ -2313,7 +2332,7 @@ export function ProfilesDataTable({
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDesynced && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2341,8 +2360,8 @@ export function ProfilesDataTable({
|
||||
: meta.t("profiles.actions.launch")
|
||||
}
|
||||
className={cn(
|
||||
"size-7 p-0 grid place-items-center",
|
||||
!canLaunch && "opacity-50 cursor-not-allowed",
|
||||
"grid size-7 place-items-center p-0",
|
||||
!canLaunch && "cursor-not-allowed opacity-50",
|
||||
canLaunch && "cursor-pointer",
|
||||
isFollower && "border-accent",
|
||||
isRunning &&
|
||||
@@ -2355,7 +2374,7 @@ export function ProfilesDataTable({
|
||||
}
|
||||
>
|
||||
{isLaunching || isStopping ? (
|
||||
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : isRunning ? (
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
) : (
|
||||
@@ -2374,28 +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",
|
||||
// The only column without a fixed width: table-fixed hands it all
|
||||
// remaining space as the window grows or shrinks.
|
||||
meta: { flexWidth: true },
|
||||
header: ({ column, table }) => {
|
||||
// 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="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{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,
|
||||
@@ -2411,7 +2491,7 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<div
|
||||
ref={renameContainerRef}
|
||||
className="overflow-visible relative"
|
||||
className="relative overflow-visible"
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -2443,7 +2523,7 @@ export function ProfilesDataTable({
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
className="h-6 w-full max-w-full min-w-0 border-0 px-2 py-1 text-sm leading-none font-medium shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -2452,7 +2532,7 @@ export function ProfilesDataTable({
|
||||
const display = (
|
||||
<OverflowTooltipText
|
||||
text={name}
|
||||
className="font-medium text-left leading-none"
|
||||
className="text-left leading-none font-medium"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2468,13 +2548,13 @@ export function ProfilesDataTable({
|
||||
const isLocked = meta.isProfileLockedByAnother(profile.id);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 max-w-full overflow-hidden">
|
||||
<div className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none h-6 min-w-0 max-w-full overflow-hidden",
|
||||
"mr-auto h-6 max-w-full min-w-0 overflow-hidden rounded border-none bg-transparent px-2 py-1 text-left",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -2635,7 +2715,7 @@ export function ProfilesDataTable({
|
||||
(snapshot?.current_bytes_received ?? 0);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden min-w-0">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<BandwidthMiniChart
|
||||
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
|
||||
data={bandwidthData}
|
||||
@@ -2647,7 +2727,7 @@ export function ProfilesDataTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex overflow-hidden gap-2 items-center min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -2753,7 +2833,7 @@ export function ProfilesDataTable({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -2876,7 +2956,7 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center h-9 w-full">
|
||||
<span className="flex h-9 w-full items-center justify-center">
|
||||
{dot.encrypted ? (
|
||||
<LuLock
|
||||
className={`size-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
|
||||
@@ -2901,10 +2981,10 @@ export function ProfilesDataTable({
|
||||
const profile = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center h-9 w-full">
|
||||
<div className="flex h-9 w-full items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 size-7"
|
||||
className="size-7 p-0"
|
||||
disabled={!meta.isClient}
|
||||
onClick={() => {
|
||||
setProfileForInfoDialog(profile);
|
||||
@@ -2927,7 +3007,7 @@ export function ProfilesDataTable({
|
||||
// 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>({});
|
||||
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.
|
||||
@@ -2987,6 +3067,8 @@ export function ProfilesDataTable({
|
||||
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,
|
||||
@@ -3026,11 +3108,11 @@ export function ProfilesDataTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
className={cn(
|
||||
"overflow-auto relative flex-1 min-h-0 scroll-fade",
|
||||
"scroll-fade relative min-h-0 flex-1 overflow-auto",
|
||||
// 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.
|
||||
@@ -3046,11 +3128,11 @@ export function ProfilesDataTable({
|
||||
}
|
||||
>
|
||||
<Table className="table-fixed" containerClassName="overflow-visible">
|
||||
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
<TableHeader className="sticky top-0 z-10 overflow-visible bg-background [&_tr]:border-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="overflow-visible !border-0"
|
||||
className="overflow-visible border-0!"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
@@ -3114,7 +3196,7 @@ export function ProfilesDataTable({
|
||||
title={crossOsTitle}
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50 !border-0",
|
||||
"overflow-visible border-0! hover:bg-accent/50",
|
||||
rowIsCrossOs && "opacity-60",
|
||||
)}
|
||||
>
|
||||
@@ -3223,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="pointer-events-none absolute -top-2 -right-2" />
|
||||
)}
|
||||
</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="pointer-events-none absolute -top-2 -right-2" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
|
||||
@@ -116,9 +116,9 @@ function _OSIcon({ os }: { os: string }) {
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
|
||||
<div className="rounded-md border bg-muted/50 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm mt-0.5 truncate">{value}</p>
|
||||
<p className="mt-0.5 truncate text-sm">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -503,7 +503,7 @@ export function ProfileInfoDialog({
|
||||
>
|
||||
<DialogContent
|
||||
hideClose
|
||||
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"
|
||||
className="flex h-[min(clamp(30rem,80vh,48rem),calc(100vh-3rem))] max-w-[min(60rem,calc(100%-4rem))] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
{/* The dialog renders its own custom header, so the accessible title is
|
||||
visually hidden but present for screen readers (Radix requires it). */}
|
||||
@@ -720,20 +720,20 @@ function ProfileInfoLayout({
|
||||
return (
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center gap-2 h-11 px-3 border-b border-border shrink-0">
|
||||
<LuUsers className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex items-center gap-1.5 text-xs min-w-0 flex-1">
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<LuUsers className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs">
|
||||
<span className="font-semibold">
|
||||
{t("profileInfo.breadcrumbRoot")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-muted-foreground truncate">{profile.name}</span>
|
||||
<span className="truncate text-muted-foreground">{profile.name}</span>
|
||||
</div>
|
||||
{onCloneProfile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1.5"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
disabled={isDisabled}
|
||||
onClick={() => onCloneProfile(profile)}
|
||||
>
|
||||
@@ -745,16 +745,16 @@ function ProfileInfoLayout({
|
||||
type="button"
|
||||
aria-label={t("common.buttons.close")}
|
||||
onClick={onClose}
|
||||
className="grid place-items-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-100"
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Sidebar */}
|
||||
<nav className="w-44 shrink-0 border-r border-border p-2 flex flex-col gap-0.5 overflow-y-auto">
|
||||
<nav className="flex w-44 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border p-2">
|
||||
{sidebarItems
|
||||
.filter((it) => !it.hidden)
|
||||
.map((it) => {
|
||||
@@ -765,16 +765,16 @@ function ProfileInfoLayout({
|
||||
type="button"
|
||||
onClick={() => setSection(it.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-7 px-2 rounded-md text-xs transition-colors duration-100 text-left",
|
||||
"flex h-7 items-center gap-2 rounded-md px-2 text-left text-xs transition-colors duration-100",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">{it.icon}</span>
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
{it.badge && (
|
||||
<span className="text-[9px] uppercase text-muted-foreground tracking-wide truncate max-w-[60px]">
|
||||
<span className="max-w-[60px] truncate text-[9px] tracking-wide text-muted-foreground uppercase">
|
||||
{it.badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -788,7 +788,7 @@ function ProfileInfoLayout({
|
||||
type="button"
|
||||
onClick={deleteAction.onClick}
|
||||
disabled={deleteAction.disabled}
|
||||
className="flex items-center gap-2 h-7 px-2 rounded-md text-xs transition-colors duration-100 text-destructive hover:bg-destructive/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
className="flex h-7 items-center gap-2 rounded-md px-2 text-xs text-destructive transition-colors duration-100 hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<LuTrash2 className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
@@ -800,21 +800,21 @@ function ProfileInfoLayout({
|
||||
</nav>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto scroll-fade p-4">
|
||||
<div className="scroll-fade min-w-0 flex-1 overflow-y-auto p-4">
|
||||
{section === "overview" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Hero */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-muted p-2.5 shrink-0">
|
||||
<div className="shrink-0 rounded-lg bg-muted p-2.5">
|
||||
<ProfileIcon className="size-7 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="text-base font-semibold truncate">
|
||||
<h3 className="truncate text-base font-semibold">
|
||||
{profile.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{profile.version}
|
||||
</span>
|
||||
@@ -823,17 +823,17 @@ function ProfileInfoLayout({
|
||||
</div>
|
||||
|
||||
{/* ID */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted/40 px-3 py-2 border border-border">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<span className="shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
ID
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
<span className="flex-1 truncate font-mono text-xs">
|
||||
{profile.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyId()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={t("common.buttons.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -874,10 +874,21 @@ function ProfileInfoLayout({
|
||||
|
||||
{/* Activity */}
|
||||
<div className="mt-1 flex flex-col gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<span className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{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={
|
||||
@@ -893,11 +904,11 @@ function ProfileInfoLayout({
|
||||
</div>
|
||||
|
||||
{profile.created_by_email && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("sync.team.title")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">
|
||||
<p className="mt-0.5 text-sm">
|
||||
{t("sync.team.createdBy", {
|
||||
email: profile.created_by_email,
|
||||
})}
|
||||
@@ -1003,7 +1014,7 @@ function _SectionPlaceholder({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
{hint && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
@@ -1011,7 +1022,7 @@ function _SectionPlaceholder({
|
||||
size="sm"
|
||||
onClick={onAction}
|
||||
disabled={disabled}
|
||||
className="self-start h-7 text-xs"
|
||||
className="h-7 self-start text-xs"
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
@@ -1038,11 +1049,11 @@ function _SectionAction({
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-9 px-3 rounded-md text-xs transition-colors text-left",
|
||||
"flex h-9 items-center gap-2 rounded-md px-3 text-left text-xs transition-colors",
|
||||
destructive
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "hover:bg-accent",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -1110,7 +1121,7 @@ function LaunchHookEditor({
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
placeholder={t("profiles.launchHook.placeholder")}
|
||||
className="text-xs font-mono"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{showInvalidHint && (
|
||||
<p className="text-xs text-warning">
|
||||
@@ -1188,7 +1199,7 @@ function SyncSectionInline({
|
||||
{t("profileInfo.sectionDesc.sync")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
||||
<span className="shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.syncMode")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1198,7 +1209,7 @@ function SyncSectionInline({
|
||||
void onChangeMode(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1211,15 +1222,15 @@ function SyncSectionInline({
|
||||
</Select>
|
||||
</div>
|
||||
{syncStatus && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.syncStatus")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">
|
||||
<p className="mt-0.5 text-sm">
|
||||
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
|
||||
</p>
|
||||
{syncStatus.error && (
|
||||
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
||||
<p className="mt-1 text-xs text-destructive">{syncStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1306,7 +1317,7 @@ function NetworkSectionInline({
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-12">
|
||||
<span className="w-12 shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.proxy")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1316,7 +1327,7 @@ function NetworkSectionInline({
|
||||
void onProxyChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1333,7 +1344,7 @@ function NetworkSectionInline({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-12">
|
||||
<span className="w-12 shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.vpn")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1343,7 +1354,7 @@ function NetworkSectionInline({
|
||||
void onVpnChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1438,7 +1449,7 @@ function ExtensionsSectionInline({
|
||||
{t("profileInfo.sectionDesc.extensions")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-16">
|
||||
<span className="w-16 shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.extensionGroup")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1448,7 +1459,7 @@ function ExtensionsSectionInline({
|
||||
void onChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1558,7 +1569,7 @@ function CookiesSectionInline({
|
||||
const domains = stats?.domains ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<LuCookie className="size-4" />
|
||||
@@ -1630,18 +1641,18 @@ function CookiesSectionInline({
|
||||
{t("profileInfo.sectionDesc.cookies")}
|
||||
</p>
|
||||
{isRunning ? (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.cookies.runningNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.cookieCount")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">
|
||||
<p className="mt-0.5 text-sm">
|
||||
{isLoading
|
||||
? t("profileInfo.values.loading")
|
||||
: stats
|
||||
@@ -1650,13 +1661,13 @@ function CookiesSectionInline({
|
||||
</p>
|
||||
</div>
|
||||
{domains.length > 0 && (
|
||||
<div className="rounded-md bg-muted/40 border border-border flex flex-col min-h-0 flex-1 overflow-hidden">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground px-3 py-2 border-b border-border shrink-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-muted/40">
|
||||
<p className="shrink-0 border-b border-border px-3 py-2 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.cookies.domainsHeader", {
|
||||
count: domains.length,
|
||||
})}
|
||||
</p>
|
||||
<ul className="text-xs px-3 py-2 overflow-y-auto flex-1 space-y-1">
|
||||
<ul className="flex-1 space-y-1 overflow-y-auto px-3 py-2 text-xs">
|
||||
{domains.map((d) => (
|
||||
<li
|
||||
key={d.domain}
|
||||
@@ -1831,7 +1842,7 @@ function FingerprintSectionInline({
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{success && !error && <p className="text-xs text-success">{success}</p>}
|
||||
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
@@ -2002,8 +2013,8 @@ function SecuritySectionInline({
|
||||
setIsVerifyOpen(true);
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
||||
"border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
|
||||
"border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("profilePassword.modes.validate")}
|
||||
@@ -2015,10 +2026,10 @@ function SecuritySectionInline({
|
||||
reset();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
||||
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
|
||||
mode === "change"
|
||||
? "bg-accent text-accent-foreground border-transparent"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
? "border-transparent bg-accent text-accent-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("profilePassword.modes.change")}
|
||||
@@ -2030,10 +2041,10 @@ function SecuritySectionInline({
|
||||
reset();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
||||
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
|
||||
mode === "remove"
|
||||
? "bg-destructive/10 text-destructive border-transparent"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
? "border-transparent bg-destructive/10 text-destructive"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("profilePassword.modes.remove")}
|
||||
@@ -2095,7 +2106,7 @@ function SecuritySectionInline({
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === "remove" ? "destructive" : "default"}
|
||||
className="self-start h-7 text-xs"
|
||||
className="h-7 self-start text-xs"
|
||||
disabled={isRunning || isSubmitting}
|
||||
onClick={() => {
|
||||
void onSubmit();
|
||||
@@ -2386,11 +2397,11 @@ export function ProfileBypassRulesDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-lg">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("profileInfo.network.bypassRulesDescription")}
|
||||
@@ -2412,12 +2423,12 @@ export function ProfileBypassRulesDialog({
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRule.trim()}
|
||||
>
|
||||
<LuPlus className="size-4 mr-1" />
|
||||
<LuPlus className="mr-1 size-4" />
|
||||
{t("profileInfo.network.addRule")}
|
||||
</Button>
|
||||
</div>
|
||||
{bypassRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
{t("profileInfo.network.noRules")}
|
||||
</p>
|
||||
) : (
|
||||
@@ -2425,15 +2436,15 @@ export function ProfileBypassRulesDialog({
|
||||
{bypassRules.map((rule) => (
|
||||
<div
|
||||
key={rule}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-muted px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{rule}</span>
|
||||
<span className="truncate font-mono text-xs">{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleRemoveRule(rule);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive"
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -171,7 +171,7 @@ export function ProfileSelectorDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
{url && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("profileSelector.openingUrl")}
|
||||
</Label>
|
||||
@@ -180,7 +180,7 @@ export function ProfileSelectorDialog({
|
||||
successMessage={t("profileSelector.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 text-sm break-all rounded bg-muted max-h-24 overflow-y-auto">
|
||||
<div className="max-h-24 overflow-y-auto rounded bg-muted p-2 text-sm break-all">
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,8 +230,8 @@ export function ProfileSelectorDialog({
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 items-center px-2 py-1 rounded-lg">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-3 rounded-lg px-2 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
|
||||
@@ -172,7 +172,7 @@ export function ProfileSyncDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md flex flex-col overflow-hidden">
|
||||
<DialogContent className="flex max-w-md flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -183,15 +183,15 @@ export function ProfileSyncDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 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 className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="p-3 text-sm rounded-md bg-muted">
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -267,14 +267,14 @@ export function ProfileSyncDialog({
|
||||
{syncMode === "Encrypted" &&
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm 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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
</Badge>
|
||||
|
||||
@@ -157,8 +157,8 @@ 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-[min(8rem,20vh)] overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
<div className="max-h-[min(8rem,20vh)] overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
const profile = profiles.find(
|
||||
(p: BrowserProfile) => p.id === profileId,
|
||||
@@ -206,7 +206,7 @@ export function ProxyAssignmentDialog({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
id={proxyListboxId}
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
@@ -283,7 +283,7 @@ export function ProxyAssignmentDialog({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -299,7 +299,7 @@ export function ProxyAssignmentDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,14 +123,14 @@ export function ProxyCheckButton({
|
||||
disabled={isCurrentlyChecking || disabled}
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : result?.is_valid && result.country_code ? (
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<FlagIcon countryCode={result.country_code} className="h-2.5" />
|
||||
<FiCheck className="absolute bottom-[-6px] right-[-4px]" />
|
||||
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
|
||||
</span>
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-destructive text-sm">✕</span>
|
||||
<span className="text-sm text-destructive">✕</span>
|
||||
) : (
|
||||
<FiCheck className="size-3" />
|
||||
)}
|
||||
|
||||
@@ -125,17 +125,17 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxies.exportDialog.preview")}</Label>
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md bg-muted/30">
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : exportContent ? (
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{exportContent}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
|
||||
{t("proxies.exportDialog.noProxies")}
|
||||
</div>
|
||||
)}
|
||||
@@ -143,7 +143,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
@@ -151,7 +151,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
variant="outline"
|
||||
onClick={() => void handleCopyToClipboard()}
|
||||
disabled={!exportContent || isLoading}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<LuCheck className="size-4" />
|
||||
@@ -165,7 +165,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<RippleButton
|
||||
onClick={handleDownload}
|
||||
disabled={!exportContent || isLoading}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuDownload className="size-4" />
|
||||
{t("common.buttons.download")}
|
||||
|
||||
@@ -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 @container">
|
||||
<div className="@container grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
@@ -228,7 +231,7 @@ export function ProxyFormDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 @sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">
|
||||
{form.proxy_type === "ss"
|
||||
|
||||
@@ -315,8 +315,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="size-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<LuUpload className="mb-4 size-10 text-muted-foreground" />
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.dropzonePrompt")}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
@@ -335,7 +335,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("proxies.importDialog.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -369,19 +369,19 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
{invalidProxies.length > 0 && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t("proxies.importDialog.invalidCount", {
|
||||
count: invalidProxies.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{parsedProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.original_line}-${i}`}
|
||||
className="text-xs font-mono p-2 bg-muted/30 rounded break-all"
|
||||
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
|
||||
>
|
||||
<span className="text-primary">
|
||||
{proxy.proxy_type}://
|
||||
@@ -407,21 +407,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
|
||||
<div className="p-3 space-y-4">
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
|
||||
<div className="space-y-4 p-3">
|
||||
{ambiguousProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.line}-${i}`}
|
||||
className="space-y-2 pb-3 border-b last:border-0"
|
||||
className="space-y-2 border-b pb-3 last:border-0"
|
||||
>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded block break-all">
|
||||
<code className="block rounded bg-muted px-2 py-1 text-xs break-all">
|
||||
{proxy.line}
|
||||
</code>
|
||||
<div className="flex flex-col gap-2">
|
||||
{proxy.possible_formats.map((format) => (
|
||||
<label
|
||||
key={format}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -445,7 +445,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
{step === "result" && importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
|
||||
<div className="space-y-2 rounded-lg bg-muted/30 p-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.imported")}
|
||||
@@ -479,8 +479,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxies.importDialog.errors")}</Label>
|
||||
<ScrollArea className="h-[100px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
<ScrollArea className="h-[100px] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{importResult.errors.map((error, i) => (
|
||||
<div
|
||||
key={`error-${i}`}
|
||||
|
||||
@@ -541,7 +541,7 @@ export function ProxyManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -552,7 +552,7 @@ export function ProxyManagementDialog({
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium block truncate">
|
||||
<span className="block truncate font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
@@ -563,7 +563,7 @@ export function ProxyManagementDialog({
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.protocolCol"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
{row.original.proxy_settings.proxy_type}
|
||||
</span>
|
||||
),
|
||||
@@ -573,7 +573,7 @@ export function ProxyManagementDialog({
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.hostPort"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground block truncate">
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{row.original.proxy_settings.host}:
|
||||
{row.original.proxy_settings.port}
|
||||
</span>
|
||||
@@ -774,7 +774,7 @@ export function ProxyManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -793,7 +793,7 @@ export function ProxyManagementDialog({
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-medium min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -1090,7 +1090,7 @@ export function ProxyManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
@@ -1100,14 +1100,14 @@ export function ProxyManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="@container w-full flex-1 min-h-0 flex flex-col">
|
||||
<div className="@container flex min-h-0 w-full flex-1 flex-col">
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
defaultValue={initialTab}
|
||||
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="proxies">
|
||||
<span>{t("proxies.management.tabProxies")}</span>
|
||||
@@ -1133,7 +1133,7 @@ export function ProxyManagementDialog({
|
||||
onClick={() => {
|
||||
setShowImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("common.buttons.import")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
@@ -1154,7 +1154,7 @@ export function ProxyManagementDialog({
|
||||
onClick={() => {
|
||||
setShowExportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("common.buttons.export")}
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
@@ -1173,7 +1173,7 @@ export function ProxyManagementDialog({
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("proxies.management.newProxy")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
@@ -1198,7 +1198,7 @@ export function ProxyManagementDialog({
|
||||
onClick={() => {
|
||||
setShowVpnImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("common.buttons.import")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
@@ -1216,7 +1216,7 @@ export function ProxyManagementDialog({
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("proxies.management.newVpn")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
@@ -1236,9 +1236,9 @@ export function ProxyManagementDialog({
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="proxies"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.loading")}
|
||||
@@ -1250,7 +1250,7 @@ export function ProxyManagementDialog({
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
"min-h-0 flex-1",
|
||||
selectedProxies.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
@@ -1284,7 +1284,7 @@ export function ProxyManagementDialog({
|
||||
// of it).
|
||||
header.column.id === "name" && "max-w-0",
|
||||
header.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(header.column.id === "protocol" ||
|
||||
header.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
@@ -1320,7 +1320,7 @@ export function ProxyManagementDialog({
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
cell.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(cell.column.id === "protocol" ||
|
||||
cell.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
@@ -1343,9 +1343,9 @@ export function ProxyManagementDialog({
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="vpns"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.loading")}
|
||||
@@ -1357,7 +1357,7 @@ export function ProxyManagementDialog({
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
"min-h-0 flex-1",
|
||||
selectedVpns.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
@@ -1391,7 +1391,7 @@ export function ProxyManagementDialog({
|
||||
// of it).
|
||||
header.column.id === "name" && "max-w-0",
|
||||
header.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(header.column.id === "protocol" ||
|
||||
header.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
@@ -1427,7 +1427,7 @@ export function ProxyManagementDialog({
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
cell.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(cell.column.id === "protocol" ||
|
||||
cell.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
|
||||
+22
-22
@@ -290,13 +290,13 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
} = useLogoEasterEgg({ currentPage, onNavigate });
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col items-center w-10 py-2 gap-1 bg-background border-r border-border shrink-0 relative">
|
||||
<nav className="relative flex w-10 shrink-0 flex-col items-center gap-1 border-r border-border bg-background py-2">
|
||||
{!isHidden ? (
|
||||
<button
|
||||
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 shrink-0"
|
||||
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md bg-transparent text-foreground select-none"
|
||||
onClick={handleClick}
|
||||
onPointerDown={() => {
|
||||
setIsPressed(true);
|
||||
@@ -336,9 +336,9 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
<div className="size-7 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-5 h-px bg-border my-1 shrink-0" />
|
||||
<div className="my-1 h-px w-5 shrink-0 bg-border" />
|
||||
|
||||
<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">
|
||||
<div className="flex min-h-0 w-full scrollbar-none flex-col items-center gap-1 overflow-y-auto [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
||||
const active = currentPage === page;
|
||||
return (
|
||||
@@ -352,16 +352,16 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t(labelKey)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
|
||||
active
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-3.5" />
|
||||
@@ -385,10 +385,10 @@ 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 shrink-0",
|
||||
"grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
|
||||
moreOpen
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
<GoKebabHorizontal className="size-3.5" />
|
||||
@@ -407,16 +407,16 @@ 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 shrink-0",
|
||||
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
|
||||
currentPage === "settings"
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{currentPage === "settings" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<GoGear className="size-3.5" />
|
||||
@@ -430,12 +430,12 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("rail.more.closeAriaLabel")}
|
||||
className="fixed inset-0 z-30 bg-transparent cursor-default"
|
||||
className="fixed inset-0 z-30 cursor-default bg-transparent"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-14 left-11 w-56 bg-card border border-border rounded-lg shadow-2xl p-1 z-40 animate-in fade-in-0 slide-in-from-bottom-1 duration-100">
|
||||
<div className="absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border bg-card p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
||||
<button
|
||||
key={page}
|
||||
@@ -444,16 +444,16 @@ 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 w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
|
||||
>
|
||||
<span className="grid place-items-center size-5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
|
||||
<Icon className="size-3" />
|
||||
</span>
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{t(hintKey)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -93,10 +93,10 @@ export function ReleaseTypeSelector({
|
||||
role="combobox"
|
||||
aria-expanded={popoverOpen}
|
||||
aria-controls={listboxId}
|
||||
className="justify-between w-full"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedDisplayText}
|
||||
<LuChevronsUpDown className="ml-2 size-4 opacity-50 shrink-0" />
|
||||
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</RippleButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent id={listboxId} className="p-0">
|
||||
@@ -134,7 +134,7 @@ export function ReleaseTypeSelector({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="capitalize">{option.type}</span>
|
||||
{option.type === "nightly" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -161,7 +161,7 @@ export function ReleaseTypeSelector({
|
||||
) : (
|
||||
// Show a simple display when only one release type is available
|
||||
releaseOptions.length === 1 && (
|
||||
<div className="flex gap-2 justify-center items-center p-3 rounded-md border bg-muted/50">
|
||||
<div className="flex items-center justify-center gap-2 rounded-md border bg-muted/50 p-3">
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{releaseOptions[0].type}
|
||||
</span>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function SettingsDialog({
|
||||
return (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("common.status.granted")}
|
||||
</Badge>
|
||||
@@ -633,7 +633,7 @@ export function SettingsDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-md max-h-[calc(100vh-5rem)] flex flex-col">
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-md flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
@@ -642,8 +642,8 @@ export function SettingsDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-y-auto flex-1 gap-6 min-h-0",
|
||||
subPage ? "py-2 w-full max-w-2xl mx-auto" : "py-4",
|
||||
"grid min-h-0 flex-1 gap-6 overflow-y-auto",
|
||||
subPage ? "mx-auto w-full max-w-2xl py-2" : "py-4",
|
||||
)}
|
||||
>
|
||||
{/* Appearance Section */}
|
||||
@@ -755,14 +755,14 @@ export function SettingsDialog({
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 items-center"
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="size-8 rounded-md border shadow-sm cursor-pointer"
|
||||
className="size-8 cursor-pointer rounded-md border shadow-sm"
|
||||
style={{ backgroundColor: colorValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
@@ -771,7 +771,7 @@ export function SettingsDialog({
|
||||
sideOffset={6}
|
||||
>
|
||||
<ColorPicker
|
||||
className="p-3 rounded-md border shadow-sm bg-background"
|
||||
className="rounded-md border bg-background p-3 shadow-sm"
|
||||
value={colorValue}
|
||||
onColorChange={([r, g, b, a]) => {
|
||||
const next = Color({ r, g, b }).alpha(a);
|
||||
@@ -792,21 +792,21 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<ColorPickerSelection className="h-36 rounded" />
|
||||
<div className="flex gap-3 items-center mt-3">
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<ColorPickerEyeDropper />
|
||||
<div className="grid gap-1 w-full">
|
||||
<div className="grid w-full gap-1">
|
||||
<ColorPickerHue />
|
||||
<ColorPickerAlpha />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="text-[10px] text-muted-foreground text-center leading-tight">
|
||||
<div className="text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -860,7 +860,7 @@ export function SettingsDialog({
|
||||
{/* Default Browser Section - hidden in portable mode */}
|
||||
{!systemInfo?.portable && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.defaultBrowser.title")}
|
||||
</Label>
|
||||
@@ -909,7 +909,7 @@ export function SettingsDialog({
|
||||
{permissions.map((permission) => (
|
||||
<div
|
||||
key={permission.permission_type}
|
||||
className="flex justify-between items-center p-3 rounded-lg border"
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
{getPermissionIcon(permission.permission_type)}
|
||||
@@ -1015,7 +1015,7 @@ export function SettingsDialog({
|
||||
{t("settings.encryption.passwordSetDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1156,7 +1156,7 @@ export function SettingsDialog({
|
||||
{t("settings.commercial.title")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/40 p-3">
|
||||
{cloudUser != null && cloudUser.plan !== "free" ? (
|
||||
// Paid Donut plan supersedes the local commercial trial —
|
||||
// the trial only exists to gate commercial use until the
|
||||
@@ -1205,7 +1205,7 @@ export function SettingsDialog({
|
||||
</Label>
|
||||
|
||||
{!isLinux && (
|
||||
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
|
||||
<div className="flex items-start gap-x-3 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
id="disable-auto-updates"
|
||||
checked={settings.disable_auto_updates ?? false}
|
||||
@@ -1227,7 +1227,7 @@ export function SettingsDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
|
||||
<div className="flex items-start gap-x-3 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
id="keep-decrypted-profiles-in-ram"
|
||||
checked={settings.keep_decrypted_profiles_in_ram ?? false}
|
||||
@@ -1305,8 +1305,8 @@ export function SettingsDialog({
|
||||
|
||||
{/* System Info */}
|
||||
{systemInfo && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground font-mono whitespace-pre-line select-all">
|
||||
<div className="border-t pt-2">
|
||||
<p className="font-mono text-xs whitespace-pre-line text-muted-foreground select-all">
|
||||
{`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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 w-full max-w-2xl mx-auto">
|
||||
<div className="mx-auto flex w-full max-w-2xl shrink-0 items-center justify-end gap-2 border-t border-border pt-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
|
||||
@@ -302,7 +302,7 @@ export function SharedCamoufoxConfigForm({
|
||||
</div>
|
||||
|
||||
{/* Randomize Fingerprint Option */}
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="randomize-fingerprint"
|
||||
@@ -316,7 +316,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{t("fingerprint.generateRandomOnLaunch")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
<p className="ml-6 text-sm text-muted-foreground">
|
||||
{t("fingerprint.generateRandomDescription")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -410,7 +410,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* Navigator Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.navigatorProperties")}</Label>
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2">
|
||||
<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-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3">
|
||||
<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-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3">
|
||||
<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-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3">
|
||||
<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-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3">
|
||||
<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-1 @md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2">
|
||||
<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-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
@@ -1138,12 +1138,12 @@ export function SharedCamoufoxConfigForm({
|
||||
</fieldset>
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="absolute inset-0 z-1 bg-background/30 backdrop-blur-[6px]" />
|
||||
<div className="absolute inset-y-0 left-0 z-2 w-6 bg-linear-to-r from-background to-transparent" />
|
||||
<div className="absolute inset-y-0 right-0 z-2 w-6 bg-linear-to-l from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 z-2 h-6 bg-linear-to-b from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 z-2 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="absolute inset-0 z-3 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
@@ -1168,7 +1168,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onValueChange={readOnly ? undefined : setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="automatic" disabled={readOnly}>
|
||||
{t("fingerprint.automatic")}
|
||||
</TabsTrigger>
|
||||
@@ -1217,7 +1217,7 @@ export function SharedCamoufoxConfigForm({
|
||||
</div>
|
||||
|
||||
{/* Randomize Fingerprint Option */}
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="randomize-fingerprint-auto"
|
||||
@@ -1234,7 +1234,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{t("fingerprint.generateRandomOnLaunch")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
<p className="ml-6 text-sm text-muted-foreground">
|
||||
{t("fingerprint.generateRandomDescriptionAuto")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1265,7 +1265,7 @@ export function SharedCamoufoxConfigForm({
|
||||
className="space-y-3"
|
||||
>
|
||||
<Label>{t("fingerprint.screenResolution")}</Label>
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-max-width">
|
||||
{t("fingerprint.maxWidth")}
|
||||
@@ -1354,12 +1354,12 @@ export function SharedCamoufoxConfigForm({
|
||||
</fieldset>
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="absolute inset-0 z-1 bg-background/30 backdrop-blur-[6px]" />
|
||||
<div className="absolute inset-y-0 left-0 z-2 w-6 bg-linear-to-r from-background to-transparent" />
|
||||
<div className="absolute inset-y-0 right-0 z-2 w-6 bg-linear-to-l from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 z-2 h-6 bg-linear-to-b from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 z-2 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="absolute inset-0 z-3 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -21,11 +21,11 @@ interface ShortcutsPageProps {
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 rounded border border-border bg-muted text-[11px] font-medium text-foreground"
|
||||
className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border bg-muted px-1.5 text-[11px] font-medium text-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
@@ -49,8 +49,8 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
||||
const digitGroups = groupTargets.slice(0, 9);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-6 pt-4 pb-8">
|
||||
<div className="max-w-3xl w-full mx-auto flex flex-col gap-6">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 pt-4 pb-8">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold">{t("shortcutsPage.title")}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -63,17 +63,17 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={key} className="flex flex-col gap-2">
|
||||
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<h2 className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
<div className="rounded-md border bg-card divide-y divide-border">
|
||||
<div className="divide-y divide-border rounded-md border bg-card">
|
||||
{items.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span
|
||||
className="text-sm truncate min-w-0"
|
||||
className="min-w-0 truncate text-sm"
|
||||
title={t(s.labelKey)}
|
||||
>
|
||||
{t(s.labelKey)}
|
||||
@@ -88,17 +88,17 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
||||
|
||||
{digitGroups.length > 0 ? (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<h2 className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("commandPalette.groups.profileGroups")}
|
||||
</h2>
|
||||
<div className="rounded-md border bg-card divide-y divide-border">
|
||||
<div className="divide-y divide-border rounded-md border bg-card">
|
||||
{digitGroups.map((target, i) => (
|
||||
<div
|
||||
key={target.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span
|
||||
className="text-sm truncate min-w-0"
|
||||
className="min-w-0 truncate text-sm"
|
||||
title={target.name}
|
||||
>
|
||||
{target.name}
|
||||
|
||||
@@ -129,7 +129,7 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 py-2">
|
||||
@@ -141,12 +141,12 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-sm font-medium truncate">
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{label}
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 tabular-nums px-2"
|
||||
className="shrink-0 px-2 tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
|
||||
@@ -248,7 +248,7 @@ export function SyncConfigDialog({
|
||||
|
||||
{isLoggedIn && user ? (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex gap-2 items-center text-sm">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="size-2 rounded-full bg-success" />
|
||||
{t("sync.cloud.connected")}
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@ export function SyncConfigDialog({
|
||||
: t("sync.team.roleMember")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
<p className="pt-1 text-xs text-muted-foreground">
|
||||
{t("sync.team.manageOnWeb")}
|
||||
</p>
|
||||
</>
|
||||
@@ -354,7 +354,7 @@ export function SyncConfigDialog({
|
||||
<TabsContent value="cloud">
|
||||
{isCloudLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
@@ -374,7 +374,7 @@ export function SyncConfigDialog({
|
||||
<TabsContent value="self-hosted">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
@@ -412,7 +412,7 @@ export function SyncConfigDialog({
|
||||
onClick={() => {
|
||||
setShowToken(!showToken);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 transform rounded-sm p-1 transition-colors hover:bg-accent"
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
@@ -434,19 +434,19 @@ export function SyncConfigDialog({
|
||||
</div>
|
||||
|
||||
{connectionStatus === "testing" && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="size-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("sync.status.syncing")}
|
||||
</div>
|
||||
)}
|
||||
{connectionStatus === "connected" && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-2 rounded-full bg-success" />
|
||||
{t("sync.status.connected")}
|
||||
</div>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-2 rounded-full bg-destructive" />
|
||||
{t("sync.status.disconnected")}
|
||||
</div>
|
||||
|
||||
@@ -127,20 +127,20 @@ export function SyncFollowerDialog({
|
||||
|
||||
{leaderProfile && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-primary/10 border border-primary/20">
|
||||
<div className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/10 p-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
{t("profiles.synchronizer.leader")}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium truncate">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{leaderProfile.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<div className="rounded-md border">
|
||||
<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">
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t("profiles.synchronizer.wayfernOnly")}
|
||||
</p>
|
||||
) : (
|
||||
@@ -155,7 +155,7 @@ export function SyncFollowerDialog({
|
||||
return (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-accent cursor-pointer"
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-accent"
|
||||
onClick={() => {
|
||||
handleToggle(
|
||||
profile.id,
|
||||
@@ -174,7 +174,7 @@ export function SyncFollowerDialog({
|
||||
handleToggle(profile.id, checked === true);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm truncate flex-1">
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{profile.name}
|
||||
</span>
|
||||
{isFlaky && (
|
||||
@@ -182,7 +182,7 @@ export function SyncFollowerDialog({
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 text-warning border-warning/50 shrink-0"
|
||||
className="shrink-0 border-warning/50 px-1.5 py-0 text-[10px] text-warning"
|
||||
>
|
||||
{t("profiles.synchronizer.flakyBadge")}
|
||||
</Badge>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ThankYouDialog({
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...spring, delay: 0.15 }}
|
||||
className="mx-auto max-w-[46ch] text-sm leading-6 text-pretty text-muted-foreground"
|
||||
className="mx-auto max-w-[46ch] text-sm/6 text-pretty text-muted-foreground"
|
||||
>
|
||||
{t("onboarding.thankYou.body")}
|
||||
</motion.p>
|
||||
|
||||
@@ -127,7 +127,7 @@ const TruncatedDomain = React.memo<{ domain: string }>(({ domain }) => {
|
||||
}, [checkTruncation]);
|
||||
|
||||
const content = (
|
||||
<span ref={ref} className="truncate block min-w-0 flex-1">
|
||||
<span ref={ref} className="block min-w-0 flex-1 truncate">
|
||||
{domain}
|
||||
</span>
|
||||
);
|
||||
@@ -209,8 +209,8 @@ export function TrafficDetailsDialog({
|
||||
const formattedTime = time.toLocaleTimeString();
|
||||
|
||||
return (
|
||||
<div className="bg-popover border rounded-lg px-3 py-2 shadow-lg">
|
||||
<p className="text-xs text-muted-foreground mb-1">{formattedTime}</p>
|
||||
<div className="rounded-lg border bg-popover px-3 py-2 shadow-lg">
|
||||
<p className="mb-1 text-xs text-muted-foreground">{formattedTime}</p>
|
||||
{payload.map((entry) => (
|
||||
<p key={String(entry.dataKey)} className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
@@ -262,7 +262,7 @@ export function TrafficDetailsDialog({
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="text-muted-foreground font-normal ml-2">
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
— {profileName}
|
||||
</span>
|
||||
)}
|
||||
@@ -273,7 +273,7 @@ export function TrafficDetailsDialog({
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
@@ -283,7 +283,7 @@ export function TrafficDetailsDialog({
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] h-8">
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
@@ -396,7 +396,7 @@ export function TrafficDetailsDialog({
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-6 mt-2">
|
||||
<div className="mt-2 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
@@ -420,7 +420,7 @@ export function TrafficDetailsDialog({
|
||||
|
||||
{/* Period Stats - now uses backend-computed values */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
@@ -433,7 +433,7 @@ export function TrafficDetailsDialog({
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
@@ -446,7 +446,7 @@ export function TrafficDetailsDialog({
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
@@ -462,7 +462,7 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
@@ -488,7 +488,7 @@ export function TrafficDetailsDialog({
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
@@ -496,8 +496,8 @@ export function TrafficDetailsDialog({
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
@@ -513,10 +513,10 @@ export function TrafficDetailsDialog({
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/30"
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground w-4 shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
@@ -540,7 +540,7 @@ export function TrafficDetailsDialog({
|
||||
{/* Top Domains by Requests */}
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
@@ -548,8 +548,8 @@ export function TrafficDetailsDialog({
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
@@ -562,10 +562,10 @@ export function TrafficDetailsDialog({
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/30"
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground w-4 shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
@@ -588,15 +588,15 @@ export function TrafficDetailsDialog({
|
||||
{/* Unique IPs */}
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<FadingScrollArea className="p-3 max-h-[clamp(120px,15vh,240px)]">
|
||||
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="text-xs bg-muted px-2 py-1 rounded font-mono"
|
||||
className="rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
@@ -608,9 +608,9 @@ export function TrafficDetailsDialog({
|
||||
|
||||
{/* No data state */}
|
||||
{!stats && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("traffic.noDataHint")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,13 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[--spacing(4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -55,7 +55,7 @@ function AlertDescription({
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -22,9 +22,9 @@ function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
|
||||
data-slot="animated-switch"
|
||||
className={cn(
|
||||
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center justify-start rounded-full border border-transparent px-[2px]",
|
||||
"bg-input data-[state=checked]:bg-primary data-[state=checked]:justify-end",
|
||||
"bg-input data-[state=checked]:justify-end data-[state=checked]:bg-primary",
|
||||
"transition-colors duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -78,7 +78,7 @@ function AnimatedTabsList({
|
||||
<TabsPrimitive.List
|
||||
data-slot="animated-tabs-list"
|
||||
className={cn(
|
||||
"relative inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-md p-0 [scrollbar-width:none]",
|
||||
"relative inline-flex max-w-full scrollbar-none items-center gap-1 overflow-x-auto rounded-md p-0",
|
||||
className,
|
||||
)}
|
||||
onMouseLeave={(event) => {
|
||||
@@ -120,10 +120,10 @@ function AnimatedTabsTrigger({
|
||||
onMouseEnter?.(event);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors duration-150",
|
||||
"relative isolate inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-colors duration-150",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
isActive && "text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -5,16 +5,16 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary dark:bg-secondary/60 text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
"border-transparent bg-secondary text-secondary-foreground dark:bg-secondary/60 [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
|
||||
@@ -5,16 +5,16 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
@@ -23,7 +23,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -40,7 +40,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,7 @@ const ChartContainer = React.forwardRef<
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
"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]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -196,7 +196,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -213,7 +213,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
key={String(item.dataKey ?? index)}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
@@ -258,7 +258,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
@@ -314,7 +314,7 @@ const ChartLegendContent = React.forwardRef<
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
"flex items-center gap-1.5 [&>svg]:size-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
|
||||
@@ -14,7 +14,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"cursor-pointer peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer size-4 shrink-0 cursor-pointer rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -152,7 +152,7 @@ export const ColorPicker = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 size-full", className)}
|
||||
className={cn("flex size-full flex-col gap-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -232,7 +232,7 @@ export const ColorPickerSelection = memo(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative rounded cursor-pointer size-full", className)}
|
||||
className={cn("relative size-full cursor-pointer rounded", className)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
@@ -245,7 +245,7 @@ export const ColorPickerSelection = memo(
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute size-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
className="pointer-events-none absolute size-4 -translate-1/2 rounded-full border-2 border-white"
|
||||
style={{
|
||||
left: `${positionX * 100}%`,
|
||||
top: `${positionY * 100}%`,
|
||||
@@ -269,7 +269,7 @@ export const ColorPickerHue = ({
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
className={cn("relative flex h-4 w-full touch-none", className)}
|
||||
max={360}
|
||||
onValueChange={([hue]) => {
|
||||
setHue(hue);
|
||||
@@ -281,7 +281,7 @@ export const ColorPickerHue = ({
|
||||
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
|
||||
<Slider.Range className="absolute h-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
<Slider.Thumb className="block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
@@ -296,7 +296,7 @@ export const ColorPickerAlpha = ({
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
className={cn("relative flex h-4 w-full touch-none", className)}
|
||||
max={100}
|
||||
onValueChange={([alpha]) => {
|
||||
setAlpha(alpha);
|
||||
@@ -312,10 +312,10 @@ export const ColorPickerAlpha = ({
|
||||
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent rounded-full to-black/50" />
|
||||
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
||||
<div className="absolute inset-0 rounded-full bg-linear-to-r from-transparent to-black/50" />
|
||||
<Slider.Range className="absolute h-full rounded-full bg-transparent" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
<Slider.Thumb className="block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
@@ -372,7 +372,7 @@ export const ColorPickerOutput = ({
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||
<SelectTrigger className="h-8 w-20 shrink-0 text-xs" {...props}>
|
||||
<SelectValue placeholder={t("common.labels.mode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -396,11 +396,11 @@ const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
|
||||
type="text"
|
||||
{...props}
|
||||
className={cn(
|
||||
"h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none",
|
||||
"h-8 w-13 rounded-l-none bg-secondary px-2 text-xs shadow-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 text-xs -translate-y-1/2 text-muted-foreground">
|
||||
<span className="absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
@@ -422,13 +422,13 @@ export const ColorPickerFormat = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex relative items-center -space-x-px w-full rounded-md shadow-sm",
|
||||
"relative flex w-full items-center -space-x-px rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Input
|
||||
className="px-2 h-8 text-xs rounded-r-none shadow-none bg-secondary"
|
||||
className="h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none"
|
||||
readOnly
|
||||
type="text"
|
||||
value={hex}
|
||||
@@ -479,7 +479,7 @@ export const ColorPickerFormat = ({
|
||||
return (
|
||||
<div className={cn("w-full rounded-md shadow-sm", className)} {...props}>
|
||||
<Input
|
||||
className="px-2 w-full h-8 text-xs shadow-none bg-secondary"
|
||||
className="h-8 w-full bg-secondary px-2 text-xs shadow-none"
|
||||
readOnly
|
||||
type="text"
|
||||
value={`rgba(${rgb.join(", ")}, ${alpha}%)`}
|
||||
|
||||
@@ -22,7 +22,7 @@ function Command({
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
"flex size-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -57,7 +57,7 @@ function CommandDialog({
|
||||
<Command
|
||||
filter={filter}
|
||||
shouldFilter={shouldFilter}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-item]_svg]:size-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
@@ -79,7 +79,7 @@ function CommandInput({
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -124,7 +124,7 @@ function CommandGroup({
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
"overflow-x-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -139,7 +139,7 @@ function CommandSeparator({
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
className={cn("-mx-1 h-px bg-border", 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",
|
||||
"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 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -169,7 +169,7 @@ function CommandShortcut({
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -258,14 +258,14 @@ function DialogContent({
|
||||
// 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",
|
||||
"fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose && dismissible && (
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<DialogPrimitive.Close className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -286,7 +286,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-left pr-8", className)}
|
||||
className={cn("flex flex-col gap-2 pr-8 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -297,7 +297,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-row flex-wrap justify-end gap-2 shrink-0",
|
||||
"flex shrink-0 flex-row flex-wrap justify-end gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -312,7 +312,7 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
className={cn("text-lg leading-none font-semibold", 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