mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-20 21:50:06 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 828a604c9d | |||
| 577ab79fd0 | |||
| 8c221d02fe | |||
| e1b79037bf | |||
| 57036bdc95 | |||
| d3169ad7a9 | |||
| e1fcfd5403 | |||
| 9dc9e13182 | |||
| c5a168ae0f | |||
| 168b7ac6d4 | |||
| e5910ad5cf | |||
| 202f2c852b | |||
| 5a8864654d | |||
| ba40458216 | |||
| 91e6381ba5 | |||
| 2055108578 | |||
| fc9a00b97d | |||
| 15f3aa03f7 | |||
| 6b31c937ea | |||
| 96e4f22e38 | |||
| ef7af59ef8 | |||
| 3df5bffdf5 |
@@ -31,10 +31,10 @@ jobs:
|
|||||||
build-mode: none
|
build-mode: none
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
- name: Contribute List
|
- name: Contribute List
|
||||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Gather context
|
- name: Gather context
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Check if first-time contributor
|
- name: Check if first-time contributor
|
||||||
id: check-first-time
|
id: check-first-time
|
||||||
@@ -479,7 +479,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Check if first-time contributor
|
- name: Check if first-time contributor
|
||||||
id: check-first-time
|
id: check-first-time
|
||||||
@@ -617,10 +617,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
|
uses: anomalyco/opencode/github@11e47f91496005aab4d7c5a2d0a7da5d2651b4ac #v1.17.8
|
||||||
env:
|
env:
|
||||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ jobs:
|
|||||||
run: git config --global core.autocrlf false
|
run: git config --global core.autocrlf false
|
||||||
|
|
||||||
- name: Checkout repository code
|
- name: Checkout repository code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ jobs:
|
|||||||
run: git config --global core.autocrlf false
|
run: git config --global core.autocrlf false
|
||||||
|
|
||||||
- name: Checkout repository code
|
- name: Checkout repository code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
github.event.workflow_run.conclusion == 'success')
|
github.event.workflow_run.conclusion == 'success')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Determine release tag
|
- name: Determine release tag
|
||||||
id: tag
|
id: tag
|
||||||
@@ -59,4 +59,19 @@ jobs:
|
|||||||
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
||||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
run: |
|
||||||
|
# GitHub injects secrets verbatim. If a value was pasted with
|
||||||
|
# surrounding quotes or a trailing newline — the local .env wraps all
|
||||||
|
# four R2_* values in double quotes — it reaches the script malformed:
|
||||||
|
# e.g. an endpoint of https://"host" yields
|
||||||
|
# `Could not connect to the endpoint URL`, and a quoted key yields
|
||||||
|
# `Unauthorized`. The local run is unaffected because publish-repo.sh
|
||||||
|
# sources .env through bash, which strips the quotes; CI has no .env,
|
||||||
|
# so strip here. No-op when the secrets are already clean. The script
|
||||||
|
# itself is intentionally left untouched.
|
||||||
|
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
|
||||||
|
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
|
||||||
|
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
|
||||||
|
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
|
||||||
|
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
|
||||||
|
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -454,7 +454,7 @@ jobs:
|
|||||||
needs: [release, changelog]
|
needs: [release, changelog]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -552,7 +552,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
|
|||||||
@@ -104,10 +104,10 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Generate nightly tag
|
- name: Generate nightly tag
|
||||||
id: tag
|
id: tag
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Actions Repository
|
- name: Checkout Actions Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ jobs:
|
|||||||
stale-pr-label: "stale"
|
stale-pr-label: "stale"
|
||||||
days-before-stale: 30
|
days-before-stale: 30
|
||||||
days-before-close: 7
|
days-before-close: 7
|
||||||
|
# Never let the maintainer's own assigned issues go stale or get
|
||||||
|
# closed, regardless of inactivity.
|
||||||
|
exempt-issue-assignees: "zhom"
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@v7.0.0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@v7.0.0
|
||||||
|
|
||||||
- name: Start MinIO
|
- name: Start MinIO
|
||||||
run: |
|
run: |
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
|
||||||
|
|
||||||
|
**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Project Guidelines
|
# Project Guidelines
|
||||||
|
|
||||||
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
|
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
|
||||||
@@ -27,9 +33,7 @@ donutbrowser/
|
|||||||
│ │ ├── mcp_server.rs # MCP protocol server
|
│ │ ├── mcp_server.rs # MCP protocol server
|
||||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||||
│ │ ├── vpn/ # WireGuard tunnels
|
│ │ ├── vpn/ # WireGuard tunnels
|
||||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
|
||||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
|
||||||
│ │ ├── downloader.rs # Browser binary downloader
|
│ │ ├── downloader.rs # Browser binary downloader
|
||||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||||
│ │ ├── settings_manager.rs # App settings persistence
|
│ │ ├── settings_manager.rs # App settings persistence
|
||||||
@@ -60,9 +64,8 @@ donutbrowser/
|
|||||||
|
|
||||||
Three log surfaces, in order of usefulness:
|
Three log surfaces, in order of usefulness:
|
||||||
|
|
||||||
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
|
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
|
||||||
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
|
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
|
||||||
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
|
|
||||||
|
|
||||||
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
|
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,65 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## v0.27.0 (2026-06-17)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- amek window resizable
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- better tray icon
|
||||||
|
- simplify socks connection
|
||||||
|
- switch local proxy from http to socks
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- readme
|
||||||
|
- readme
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- ci(deps): bump anomalyco/opencode in the github-actions group (#437)
|
||||||
|
- chore: update flake.nix for v0.26.0 [skip ci] (#428)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.0 (2026-06-08)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add cookie export
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- deprecate camoufox
|
||||||
|
- cleanup
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: linting
|
||||||
|
- ci(deps): bump the github-actions group with 3 updates (#421)
|
||||||
|
- chore: update flake.nix for v0.25.3 [skip ci] (#417)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- deps(rust)(deps): bump the rust-dependencies group (#422)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.3 (2026-06-03)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- launch wayfern with proper dimentions for mobile devices
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: update flake.nix for v0.25.2 [skip ci] (#415)
|
||||||
|
|
||||||
|
|
||||||
## v0.25.2 (2026-06-02)
|
## v0.25.2 (2026-06-02)
|
||||||
|
|
||||||
### Refactoring
|
### Refactoring
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), which is privacy-focused Chromium fork that comes with advanced fingerprint spoofing which naturally hides information in a way that is not detected by Cloudflare, reCaptcha v3, and other browser fingerprinting and anti-bot services.
|
||||||
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
|
- **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
|
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||||
- **VPN support** — WireGuard configs per profile
|
- **VPN support** — WireGuard configs per profile
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
| | Apple Silicon | Intel |
|
| | Apple Silicon | Intel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_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:
|
Or install via Homebrew:
|
||||||
|
|
||||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_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
|
### Linux
|
||||||
|
|
||||||
| Format | x86_64 | ARM64 |
|
| Format | x86_64 | ARM64 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_arm64.deb) |
|
| **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.25.2/Donut-0.25.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut-0.25.2-1.aarch64.rpm) |
|
| **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.25.2/Donut_0.25.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.AppImage) |
|
| **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 -->
|
<!-- install-links-end -->
|
||||||
|
|
||||||
Or install via package manager:
|
Or install via package manager:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
type CanActivate,
|
type CanActivate,
|
||||||
type ExecutionContext,
|
type ExecutionContext,
|
||||||
@@ -10,6 +11,13 @@ import type { Request } from "express";
|
|||||||
import * as jwt from "jsonwebtoken";
|
import * as jwt from "jsonwebtoken";
|
||||||
import type { UserContext } from "./user-context.interface.js";
|
import type { UserContext } from "./user-context.interface.js";
|
||||||
|
|
||||||
|
/** Constant-time string compare; false on length mismatch (no early return). */
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
private readonly logger = new Logger(AuthGuard.name);
|
private readonly logger = new Logger(AuthGuard.name);
|
||||||
@@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
|
|
||||||
// Try SYNC_TOKEN first (self-hosted mode)
|
// Try SYNC_TOKEN first (self-hosted mode)
|
||||||
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
||||||
if (expectedToken && token === expectedToken) {
|
if (expectedToken && safeEqual(token, expectedToken)) {
|
||||||
(request as unknown as Record<string, unknown>).user = {
|
(request as unknown as Record<string, unknown>).user = {
|
||||||
mode: "self-hosted",
|
mode: "self-hosted",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
@@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate {
|
|||||||
algorithms: ["RS256"],
|
algorithms: ["RS256"],
|
||||||
}) as jwt.JwtPayload;
|
}) as jwt.JwtPayload;
|
||||||
|
|
||||||
|
// Validate the scope claims' SHAPE before trusting them as S3 key
|
||||||
|
// prefixes. An empty/over-broad prefix would make validateKeyAccess
|
||||||
|
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
|
||||||
|
// bug or permissive claim must not silently widen scope.
|
||||||
|
const prefix = decoded.prefix || `users/${decoded.sub}/`;
|
||||||
|
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
|
||||||
|
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
|
||||||
|
}
|
||||||
|
const teamPrefix =
|
||||||
|
decoded.teamPrefix === undefined || decoded.teamPrefix === null
|
||||||
|
? null
|
||||||
|
: decoded.teamPrefix;
|
||||||
|
if (
|
||||||
|
teamPrefix !== null &&
|
||||||
|
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
|
||||||
|
}
|
||||||
|
|
||||||
(request as unknown as Record<string, unknown>).user = {
|
(request as unknown as Record<string, unknown>).user = {
|
||||||
mode: "cloud",
|
mode: "cloud",
|
||||||
prefix: decoded.prefix || `users/${decoded.sub}/`,
|
prefix,
|
||||||
teamPrefix: decoded.teamPrefix || null,
|
teamPrefix,
|
||||||
profileLimit: decoded.profileLimit || 0,
|
profileLimit: decoded.profileLimit || 0,
|
||||||
teamProfileLimit: decoded.teamProfileLimit || 0,
|
teamProfileLimit: decoded.teamProfileLimit || 0,
|
||||||
} satisfies UserContext;
|
} satisfies UserContext;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -9,6 +10,13 @@ import {
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { SyncService } from "./sync.service.js";
|
import { SyncService } from "./sync.service.js";
|
||||||
|
|
||||||
|
/** Constant-time string compare; false on length mismatch. */
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
@Controller("v1/internal")
|
@Controller("v1/internal")
|
||||||
export class InternalController {
|
export class InternalController {
|
||||||
private readonly internalKey: string | undefined;
|
private readonly internalKey: string | undefined;
|
||||||
@@ -26,7 +34,7 @@ export class InternalController {
|
|||||||
@Headers("x-internal-key") key: string,
|
@Headers("x-internal-key") key: string,
|
||||||
@Body() body: { userId: string; maxProfiles: number },
|
@Body() body: { userId: string; maxProfiles: number },
|
||||||
) {
|
) {
|
||||||
if (!this.internalKey || key !== this.internalKey) {
|
if (!this.internalKey || !key || !safeEqual(key, this.internalKey)) {
|
||||||
throw new UnauthorizedException("Invalid internal key");
|
throw new UnauthorizedException("Invalid internal key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ import type {
|
|||||||
*/
|
*/
|
||||||
const MANIFEST_KEY = ".donut-sync-manifest";
|
const MANIFEST_KEY = ".donut-sync-manifest";
|
||||||
|
|
||||||
|
/** Max presigned-URL lifetime. The client requests ~1h; never mint a URL that
|
||||||
|
* outlives this, regardless of a (possibly hostile) client-supplied expiresIn. */
|
||||||
|
const MAX_PRESIGN_EXPIRES_IN = 3600;
|
||||||
|
|
||||||
|
/** Clamp a client-supplied expiresIn to a sane positive range. */
|
||||||
|
function clampExpiresIn(requested: number | undefined): number {
|
||||||
|
const v = typeof requested === "number" && requested > 0 ? requested : 3600;
|
||||||
|
return Math.min(v, MAX_PRESIGN_EXPIRES_IN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only this metadata key is meaningful to sync (LWW conflict resolution).
|
||||||
|
* Whitelisting prevents a client from signing arbitrary x-amz-meta-* values. */
|
||||||
|
function sanitizeMetadata(
|
||||||
|
metadata: Record<string, string> | undefined,
|
||||||
|
): Record<string, string> | undefined {
|
||||||
|
if (!metadata) return undefined;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (typeof metadata["updated-at"] === "string") {
|
||||||
|
out["updated-at"] = metadata["updated-at"];
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncService implements OnModuleInit {
|
export class SyncService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SyncService.name);
|
private readonly logger = new Logger(SyncService.name);
|
||||||
@@ -286,16 +309,19 @@ export class SyncService implements OnModuleInit {
|
|||||||
await this.checkProfileLimit(ctx);
|
await this.checkProfileLimit(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
|
|
||||||
|
// Whitelist metadata to the single key sync relies on, so a client can't
|
||||||
|
// sign arbitrary x-amz-meta-* values into its objects.
|
||||||
|
const metadata = sanitizeMetadata(dto.metadata);
|
||||||
const command = new PutCmd({
|
const command = new PutCmd({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: dto.contentType || "application/octet-stream",
|
ContentType: dto.contentType || "application/octet-stream",
|
||||||
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
|
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
|
||||||
// exactly these headers on the PUT, so we echo them in the response.
|
// exactly these headers on the PUT, so we echo them in the response.
|
||||||
Metadata: dto.metadata,
|
Metadata: metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||||
@@ -313,6 +339,9 @@ export class SyncService implements OnModuleInit {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
// Echo the metadata we actually signed so the client sends matching
|
||||||
|
// x-amz-meta-* headers on the PUT (S3 rejects unsigned ones).
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +352,7 @@ export class SyncService implements OnModuleInit {
|
|||||||
const key = this.scopeKey(ctx, dto.key);
|
const key = this.scopeKey(ctx, dto.key);
|
||||||
this.validateKeyAccess(ctx, key);
|
this.validateKeyAccess(ctx, key);
|
||||||
|
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
@@ -438,7 +467,7 @@ export class SyncService implements OnModuleInit {
|
|||||||
await this.checkProfileLimit(ctx);
|
await this.checkProfileLimit(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
|
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
@@ -491,7 +520,7 @@ export class SyncService implements OnModuleInit {
|
|||||||
dto: PresignDownloadBatchRequestDto,
|
dto: PresignDownloadBatchRequestDto,
|
||||||
ctx: UserContext,
|
ctx: UserContext,
|
||||||
): Promise<PresignDownloadBatchResponseDto> {
|
): Promise<PresignDownloadBatchResponseDto> {
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
|
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
|
|||||||
@@ -96,17 +96,17 @@
|
|||||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||||
);
|
);
|
||||||
releaseVersion = "0.25.2";
|
releaseVersion = "0.27.0";
|
||||||
releaseAppImage =
|
releaseAppImage =
|
||||||
if system == "x86_64-linux" then
|
if system == "x86_64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage";
|
||||||
hash = "sha256-awESxsKfrSJFMAGbTasbXjL8UnF58ziLnS8Ee0phgb8=";
|
hash = "sha256-b9jY+SPw+5UvvTKgXmvxLJjIbrLW6kHTVeZywJA6DFE=";
|
||||||
}
|
}
|
||||||
else if system == "aarch64-linux" then
|
else if system == "aarch64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage";
|
||||||
hash = "sha256-zOUWnvf+5stknWomHwYRUw2TR0aS4/XeiVySBjHuJLA=";
|
hash = "sha256-UyK3p88kx3JkJmQ9Jv1hQGmfLbG1YZDuF2pZ1h529sQ=";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.25.3",
|
"version": "0.27.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 12341",
|
"dev": "next dev --turbopack -p 12341",
|
||||||
|
|||||||
Generated
+62
-58
@@ -169,7 +169,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -214,7 +214,7 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.60.2",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
@@ -1068,9 +1068,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -1709,7 +1709,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1784,7 +1784,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.25.3"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.1",
|
"aes 0.9.1",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -1838,6 +1838,7 @@ dependencies = [
|
|||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"shadowsocks",
|
"shadowsocks",
|
||||||
"smoltcp",
|
"smoltcp",
|
||||||
|
"subtle",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tar",
|
"tar",
|
||||||
@@ -1852,6 +1853,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-window-state",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2097,7 +2099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2862,14 +2864,17 @@ name = "hashbrown"
|
|||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.11.0"
|
version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
|
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3621,9 +3626,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libfuzzer-sys"
|
name = "libfuzzer-sys"
|
||||||
version = "0.4.12"
|
version = "0.4.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"cc",
|
"cc",
|
||||||
@@ -3656,9 +3661,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.38.0"
|
version = "0.38.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c"
|
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
@@ -3688,9 +3693,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.30"
|
version = "0.4.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"value-bag",
|
"value-bag",
|
||||||
]
|
]
|
||||||
@@ -3910,7 +3915,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4087,7 +4092,7 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate 1.3.1",
|
"proc-macro-crate 3.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
@@ -4447,7 +4452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5496,9 +5501,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.40.0"
|
version = "0.40.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4"
|
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
@@ -5561,7 +5566,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5636,15 +5641,6 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "scc"
|
|
||||||
version = "2.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
|
||||||
dependencies = [
|
|
||||||
"sdd",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -5711,12 +5707,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sdd"
|
|
||||||
version = "3.0.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "seahash"
|
name = "seahash"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -5916,9 +5906,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.20.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
@@ -5936,9 +5926,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.20.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -5961,24 +5951,23 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial_test"
|
name = "serial_test"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"scc",
|
|
||||||
"serial_test_derive",
|
"serial_test_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial_test_derive"
|
name = "serial_test_derive"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -6230,7 +6219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6870,6 +6859,21 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-window-state"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.2"
|
version = "2.11.2"
|
||||||
@@ -6977,10 +6981,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7482,7 +7486,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7554,7 +7558,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7642,9 +7646,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.13.2"
|
version = "1.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-vo"
|
name = "unicode-vo"
|
||||||
@@ -8212,7 +8216,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8738,7 +8742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9018,9 +9022,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.25.3"
|
version = "0.27.0"
|
||||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||||
authors = ["zhom@github"]
|
authors = ["zhom@github"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -41,6 +41,7 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-macos-permissions = "2"
|
tauri-plugin-macos-permissions = "2"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ aes-gcm = "0.10"
|
|||||||
aes = "0.9"
|
aes = "0.9"
|
||||||
cbc = "0.2"
|
cbc = "0.2"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
|
subtle = "2"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
|
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
|
||||||
hyper = { version = "1.10", features = ["full"] }
|
hyper = { version = "1.10", features = ["full"] }
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
+141
-12
@@ -58,13 +58,25 @@ pub struct ApiProfileResponse {
|
|||||||
pub struct CreateProfileRequest {
|
pub struct CreateProfileRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub browser: String,
|
pub browser: String,
|
||||||
pub version: String,
|
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
|
||||||
|
/// version of the chosen browser. A concrete version must already be
|
||||||
|
/// downloaded; the create path does not fetch new versions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: Option<String>,
|
||||||
pub proxy_id: Option<String>,
|
pub proxy_id: Option<String>,
|
||||||
pub vpn_id: Option<String>,
|
pub vpn_id: Option<String>,
|
||||||
pub launch_hook: Option<String>,
|
pub launch_hook: Option<String>,
|
||||||
pub release_type: Option<String>,
|
pub release_type: Option<String>,
|
||||||
|
/// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`.
|
||||||
|
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||||
|
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||||
|
/// pin a specific one.
|
||||||
#[schema(value_type = Object)]
|
#[schema(value_type = Object)]
|
||||||
pub camoufox_config: Option<serde_json::Value>,
|
pub camoufox_config: Option<serde_json::Value>,
|
||||||
|
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
|
||||||
|
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||||
|
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||||
|
/// pin a specific one.
|
||||||
#[schema(value_type = Object)]
|
#[schema(value_type = Object)]
|
||||||
pub wayfern_config: Option<serde_json::Value>,
|
pub wayfern_config: Option<serde_json::Value>,
|
||||||
pub group_id: Option<String>,
|
pub group_id: Option<String>,
|
||||||
@@ -74,7 +86,9 @@ pub struct CreateProfileRequest {
|
|||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateProfileRequest {
|
pub struct UpdateProfileRequest {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub browser: Option<String>,
|
// No `browser` field: a profile's engine is fixed at creation (changing it
|
||||||
|
// would invalidate the generated fingerprint and on-disk profile dir).
|
||||||
|
// Accepting it here only to silently ignore it misled API clients.
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
pub proxy_id: Option<String>,
|
pub proxy_id: Option<String>,
|
||||||
pub vpn_id: Option<String>,
|
pub vpn_id: Option<String>,
|
||||||
@@ -405,6 +419,9 @@ impl ApiServer {
|
|||||||
let api = ApiDoc::openapi();
|
let api = ApiDoc::openapi();
|
||||||
|
|
||||||
let v1_routes = v1_routes
|
let v1_routes = v1_routes
|
||||||
|
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||||
|
// automation request limit. See rate_limit_middleware.
|
||||||
|
.layer(middleware::from_fn(rate_limit_middleware))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
@@ -508,8 +525,14 @@ async fn auth_middleware(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compare tokens
|
// Constant-time comparison so the auth check doesn't leak the shared-prefix
|
||||||
if token != stored_token {
|
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
|
||||||
|
// lengths simply compare unequal.
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
let token_bytes = token.as_bytes();
|
||||||
|
let stored_bytes = stored_token.as_bytes();
|
||||||
|
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
|
||||||
|
if !matches {
|
||||||
log::warn!("[api] Rejected {path}: token mismatch");
|
log::warn!("[api] Rejected {path}: token mismatch");
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
@@ -550,6 +573,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chokepoint for the future per-hour automation request limit. The limit
|
||||||
|
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
|
||||||
|
/// this middleware is intentionally inert today — it resolves the limit but
|
||||||
|
/// never blocks. To enforce, count authenticated requests per rolling hour and
|
||||||
|
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
|
||||||
|
async fn rate_limit_middleware(
|
||||||
|
request: axum::extract::Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
|
||||||
|
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|
||||||
// Global API server instance
|
// Global API server instance
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||||
@@ -694,14 +731,24 @@ async fn get_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a profile.
|
||||||
|
///
|
||||||
|
/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected
|
||||||
|
/// with 400.
|
||||||
|
/// - `version` is optional: omit it or pass `"latest"` to use the newest
|
||||||
|
/// already-downloaded version of that browser. The version must be present
|
||||||
|
/// locally (this endpoint does not download new versions); 400 if none is.
|
||||||
|
/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an
|
||||||
|
/// empty object `{}`, generates a fresh fingerprint automatically.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/v1/profiles",
|
path = "/v1/profiles",
|
||||||
request_body = CreateProfileRequest,
|
request_body = CreateProfileRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
|
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
|
||||||
(status = 400, description = "Bad request"),
|
(status = 400, description = "Invalid browser, or no downloaded version available"),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 402, description = "Selected proxy requires payment"),
|
||||||
(status = 500, description = "Internal server error")
|
(status = 500, description = "Internal server error")
|
||||||
),
|
),
|
||||||
security(
|
security(
|
||||||
@@ -715,6 +762,34 @@ async fn create_profile(
|
|||||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
|
|
||||||
|
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
|
||||||
|
// (fingerprint generation, launch, run) supports nothing else. Reject anything
|
||||||
|
// else up front — otherwise the profile is created with no fingerprint and an
|
||||||
|
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
|
||||||
|
// create_profile validation.
|
||||||
|
if request.browser != "wayfern" && request.browser != "camoufox" {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the version. Omitted, empty, or "latest" means "newest version
|
||||||
|
// already downloaded for this browser". The create path generates the
|
||||||
|
// fingerprint by launching that binary, so the version must be present
|
||||||
|
// locally — we don't fetch new versions here. 400 if none is downloaded.
|
||||||
|
let version = match request.version.as_deref() {
|
||||||
|
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
|
||||||
|
_ => {
|
||||||
|
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||||
|
let mut versions = registry.get_downloaded_versions(&request.browser);
|
||||||
|
// browsers is a HashMap, so keys are unordered — sort newest-first by
|
||||||
|
// semver before taking the latest.
|
||||||
|
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
||||||
|
match versions.into_iter().next() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return Err(StatusCode::BAD_REQUEST),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Parse camoufox config if provided
|
// Parse camoufox config if provided
|
||||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||||
serde_json::from_value(config.clone()).ok()
|
serde_json::from_value(config.clone()).ok()
|
||||||
@@ -747,7 +822,7 @@ async fn create_profile(
|
|||||||
&state.app_handle,
|
&state.app_handle,
|
||||||
&request.name,
|
&request.name,
|
||||||
&request.browser,
|
&request.browser,
|
||||||
&request.version,
|
&version,
|
||||||
request.release_type.as_deref().unwrap_or("stable"),
|
request.release_type.as_deref().unwrap_or("stable"),
|
||||||
request.proxy_id.clone(),
|
request.proxy_id.clone(),
|
||||||
request.vpn_id.clone(),
|
request.vpn_id.clone(),
|
||||||
@@ -895,10 +970,10 @@ async fn update_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(camoufox_config) = request.camoufox_config {
|
if let Some(camoufox_config) = request.camoufox_config {
|
||||||
// Editing a profile's fingerprint config is a paid feature everywhere
|
// Editing a profile's fingerprint config is part of the cross-OS fingerprint
|
||||||
// (GUI, API, MCP). Viewing it is free; mutating it is not.
|
// capability (GUI, API, MCP). Viewing it is free; mutating it is not.
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_cross_os_fingerprints()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||||
@@ -1721,7 +1796,7 @@ async fn run_profile(
|
|||||||
Json(request): Json<RunProfileRequest>,
|
Json(request): Json<RunProfileRequest>,
|
||||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_browser_automation()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||||
@@ -1807,7 +1882,7 @@ async fn open_url_in_profile(
|
|||||||
Json(request): Json<OpenUrlRequest>,
|
Json(request): Json<OpenUrlRequest>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_browser_automation()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||||
@@ -1849,7 +1924,7 @@ async fn kill_profile(
|
|||||||
// Programmatically launching and stopping profiles is a paid feature; the
|
// Programmatically launching and stopping profiles is a paid feature; the
|
||||||
// run/open-url handlers gate the same way.
|
// run/open-url handlers gate the same way.
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_browser_automation()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||||
@@ -2090,3 +2165,57 @@ async fn refresh_wayfern_token(
|
|||||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||||
Ok(Json(WayfernTokenResponse { token }))
|
Ok(Json(WayfernTokenResponse { token }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
|
||||||
|
// `browser` values on create, must NOT make the API reject requests that
|
||||||
|
// carry extra/unknown fields — old clients still send them. serde ignores
|
||||||
|
// unknown fields by default; these tests lock that in so a future
|
||||||
|
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
|
||||||
|
#[test]
|
||||||
|
fn update_profile_request_ignores_unknown_fields() {
|
||||||
|
// `browser` is no longer a field, plus a wholly unknown field. Both must
|
||||||
|
// be accepted and ignored, not rejected.
|
||||||
|
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
|
||||||
|
let parsed: UpdateProfileRequest =
|
||||||
|
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||||
|
assert_eq!(parsed.name.as_deref(), Some("p"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_profile_request_ignores_unknown_fields() {
|
||||||
|
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
|
||||||
|
let parsed: CreateProfileRequest =
|
||||||
|
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||||
|
assert_eq!(parsed.browser, "wayfern");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_profile_request_allows_omitting_version_and_configs() {
|
||||||
|
// Minimal body: no version, no wayfern_config/camoufox_config. Must
|
||||||
|
// deserialize (version resolves to latest-downloaded at the handler; an
|
||||||
|
// absent config triggers fresh-fingerprint generation).
|
||||||
|
let json = r#"{"name": "p", "browser": "wayfern"}"#;
|
||||||
|
let parsed: CreateProfileRequest =
|
||||||
|
serde_json::from_str(json).expect("version and configs are optional");
|
||||||
|
assert_eq!(parsed.browser, "wayfern");
|
||||||
|
assert!(parsed.version.is_none());
|
||||||
|
assert!(parsed.wayfern_config.is_none());
|
||||||
|
assert!(parsed.camoufox_config.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_profile_browser_validation_matches_supported_engines() {
|
||||||
|
// The handler rejects anything that isn't a launchable engine; this is the
|
||||||
|
// same predicate it uses, kept in lockstep with MCP's create_profile.
|
||||||
|
let is_valid = |b: &str| b == "wayfern" || b == "camoufox";
|
||||||
|
assert!(is_valid("wayfern"));
|
||||||
|
assert!(is_valid("camoufox"));
|
||||||
|
assert!(!is_valid("chromium"));
|
||||||
|
assert!(!is_valid("firefox"));
|
||||||
|
assert!(!is_valid(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ async fn main() {
|
|||||||
Arg::new("blocklist-file")
|
Arg::new("blocklist-file")
|
||||||
.long("blocklist-file")
|
.long("blocklist-file")
|
||||||
.help("Path to DNS blocklist file (one domain per line)"),
|
.help("Path to DNS blocklist file (one domain per line)"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("local-protocol")
|
||||||
|
.long("local-protocol")
|
||||||
|
.help("Protocol served to the browser: http (default) or socks5"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
@@ -251,6 +256,7 @@ async fn main() {
|
|||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||||
|
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
||||||
|
|
||||||
match start_proxy_process_with_profile(
|
match start_proxy_process_with_profile(
|
||||||
upstream_url,
|
upstream_url,
|
||||||
@@ -258,6 +264,7 @@ async fn main() {
|
|||||||
profile_id,
|
profile_id,
|
||||||
bypass_rules,
|
bypass_rules,
|
||||||
blocklist_file,
|
blocklist_file,
|
||||||
|
local_protocol,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -261,6 +261,11 @@ impl BrowserRunner {
|
|||||||
Some(&profile_id_str),
|
Some(&profile_id_str),
|
||||||
profile.proxy_bypass_rules.clone(),
|
profile.proxy_bypass_rules.clone(),
|
||||||
blocklist_file,
|
blocklist_file,
|
||||||
|
// Camoufox (Firefox 150, and Firefox 135 on the not-yet-updated
|
||||||
|
// Windows build) keeps the local HTTP proxy: Firefox's QUIC stack
|
||||||
|
// bypasses a configured proxy, so QUIC is disabled and HTTP CONNECT
|
||||||
|
// covers everything. SOCKS5 is reserved for Wayfern.
|
||||||
|
"http",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -527,6 +532,11 @@ impl BrowserRunner {
|
|||||||
Some(&profile_id_str),
|
Some(&profile_id_str),
|
||||||
profile.proxy_bypass_rules.clone(),
|
profile.proxy_bypass_rules.clone(),
|
||||||
blocklist_file,
|
blocklist_file,
|
||||||
|
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
|
||||||
|
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
|
||||||
|
// leaking the real IP, rather than being forced direct as they
|
||||||
|
// would be over an HTTP CONNECT proxy.
|
||||||
|
"socks5",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -535,8 +545,9 @@ impl BrowserRunner {
|
|||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Format proxy URL for wayfern - always use HTTP for the local proxy
|
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
|
||||||
let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port);
|
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
|
||||||
|
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
|
||||||
|
|
||||||
// Set proxy in wayfern config
|
// Set proxy in wayfern config
|
||||||
wayfern_config.proxy = Some(proxy_url);
|
wayfern_config.proxy = Some(proxy_url);
|
||||||
|
|||||||
+189
-23
@@ -21,6 +21,76 @@ use crate::sync;
|
|||||||
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||||
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
||||||
|
|
||||||
|
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
|
||||||
|
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
|
||||||
|
/// rate-limit chokepoints in api_server / mcp_server.
|
||||||
|
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
||||||
|
|
||||||
|
/// Capability + limit set the account is entitled to, derived from its plan.
|
||||||
|
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
|
||||||
|
/// flags instead of a single "is paid?" boolean, so a plan like the future
|
||||||
|
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||||
|
/// data here.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Entitlements {
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: bool,
|
||||||
|
#[serde(rename = "browserAutomation", default)]
|
||||||
|
pub browser_automation: bool,
|
||||||
|
#[serde(rename = "crossOsFingerprints", default)]
|
||||||
|
pub cross_os_fingerprints: bool,
|
||||||
|
#[serde(rename = "cloudBackup", default)]
|
||||||
|
pub cloud_backup: bool,
|
||||||
|
#[serde(rename = "teamCollaboration", default)]
|
||||||
|
pub team_collaboration: bool,
|
||||||
|
#[serde(rename = "profileLimit", default)]
|
||||||
|
pub profile_limit: i64,
|
||||||
|
#[serde(rename = "requestsPerHour", default)]
|
||||||
|
pub requests_per_hour: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local fallback mirror of the backend plan -> capability matrix, used only when
|
||||||
|
/// the server hasn't sent an entitlements object (older cached state / backend).
|
||||||
|
fn derive_entitlements(
|
||||||
|
plan: &str,
|
||||||
|
plan_period: Option<&str>,
|
||||||
|
subscription_status: &str,
|
||||||
|
profile_limit: i64,
|
||||||
|
) -> Entitlements {
|
||||||
|
let active =
|
||||||
|
plan != "free" && (subscription_status == "active" || plan_period == Some("lifetime"));
|
||||||
|
if !active {
|
||||||
|
return Entitlements {
|
||||||
|
active: false,
|
||||||
|
browser_automation: false,
|
||||||
|
cross_os_fingerprints: false,
|
||||||
|
cloud_backup: false,
|
||||||
|
team_collaboration: false,
|
||||||
|
profile_limit: 0,
|
||||||
|
requests_per_hour: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// pro and any unrecognized paid plan -> pro-level (never team).
|
||||||
|
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
|
||||||
|
"starter" => (false, true, true, false),
|
||||||
|
"team" | "enterprise" => (true, true, true, true),
|
||||||
|
_ => (true, true, true, false),
|
||||||
|
};
|
||||||
|
Entitlements {
|
||||||
|
active,
|
||||||
|
browser_automation,
|
||||||
|
cross_os_fingerprints,
|
||||||
|
cloud_backup,
|
||||||
|
team_collaboration,
|
||||||
|
profile_limit,
|
||||||
|
requests_per_hour: if browser_automation {
|
||||||
|
DEFAULT_REQUESTS_PER_HOUR
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CloudUser {
|
pub struct CloudUser {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -56,6 +126,26 @@ pub struct CloudUser {
|
|||||||
pub device_count: Option<i64>,
|
pub device_count: Option<i64>,
|
||||||
#[serde(rename = "isPrimaryDevice", default)]
|
#[serde(rename = "isPrimaryDevice", default)]
|
||||||
pub is_primary_device: Option<bool>,
|
pub is_primary_device: Option<bool>,
|
||||||
|
/// Capability/limit set derived from the plan by the backend. `default` (None)
|
||||||
|
/// keeps older login/state payloads deserializing; resolve via `entitlements()`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub entitlements: Option<Entitlements>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CloudUser {
|
||||||
|
/// Authoritative entitlements: the server-sent set when present, else derived
|
||||||
|
/// locally from the plan fields (keeps older cached state / backends working).
|
||||||
|
pub fn entitlements(&self) -> Entitlements {
|
||||||
|
if let Some(e) = &self.entitlements {
|
||||||
|
return e.clone();
|
||||||
|
}
|
||||||
|
derive_entitlements(
|
||||||
|
&self.plan,
|
||||||
|
self.plan_period.as_deref(),
|
||||||
|
&self.subscription_status,
|
||||||
|
self.profile_limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -658,39 +748,83 @@ impl CloudAuthManager {
|
|||||||
state.is_some()
|
state.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
/// Resolve this session's entitlements (server-sent or locally derived).
|
||||||
|
pub async fn entitlements(&self) -> Option<Entitlements> {
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
match &*state {
|
state.as_ref().map(|auth| auth.user.entitlements())
|
||||||
Some(auth) => {
|
}
|
||||||
auth.user.plan != "free"
|
|
||||||
&& (auth.user.subscription_status == "active"
|
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
/// (sync token, wayfern token); per-feature access uses the capability helpers.
|
||||||
}
|
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||||
None => false,
|
self.entitlements().await.map(|e| e.active).unwrap_or(false)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
|
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
|
||||||
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
||||||
match self.state.try_lock() {
|
match self.state.try_lock() {
|
||||||
Ok(state) => match &*state {
|
Ok(state) => state
|
||||||
Some(auth) => {
|
.as_ref()
|
||||||
auth.user.plan != "free"
|
.map(|auth| auth.user.entitlements().active)
|
||||||
&& (auth.user.subscription_status == "active"
|
.unwrap_or(false),
|
||||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
|
||||||
}
|
|
||||||
None => false,
|
|
||||||
},
|
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||||
|
pub async fn can_use_browser_automation(&self) -> bool {
|
||||||
|
self
|
||||||
|
.entitlements()
|
||||||
|
.await
|
||||||
|
.map(|e| e.browser_automation)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit fingerprints / use a non-native OS fingerprint.
|
||||||
|
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
||||||
|
self
|
||||||
|
.entitlements()
|
||||||
|
.await
|
||||||
|
.map(|e| e.cross_os_fingerprints)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloud profile sync / backup (async).
|
||||||
|
pub async fn can_use_cloud_backup(&self) -> bool {
|
||||||
|
self
|
||||||
|
.entitlements()
|
||||||
|
.await
|
||||||
|
.map(|e| e.cloud_backup)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloud profile sync / backup (non-async, try_lock; false if unavailable).
|
||||||
|
pub fn can_use_cloud_backup_sync(&self) -> bool {
|
||||||
|
match self.state.try_lock() {
|
||||||
|
Ok(state) => state
|
||||||
|
.as_ref()
|
||||||
|
.map(|auth| auth.user.entitlements().cloud_backup)
|
||||||
|
.unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-hour cap on automation requests (0 when automation is unavailable).
|
||||||
|
/// Carried for the future local rate limiter; read by the inert chokepoints.
|
||||||
|
pub async fn requests_per_hour(&self) -> i64 {
|
||||||
|
self
|
||||||
|
.entitlements()
|
||||||
|
.await
|
||||||
|
.map(|e| e.requests_per_hour)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
||||||
let host_os = crate::profile::types::get_host_os();
|
let host_os = crate::profile::types::get_host_os();
|
||||||
match fingerprint_os {
|
match fingerprint_os {
|
||||||
None => true,
|
None => true,
|
||||||
Some(os) if os == host_os => true,
|
Some(os) if os == host_os => true,
|
||||||
Some(_) => self.has_active_paid_subscription().await,
|
Some(_) => self.can_use_cross_os_fingerprints().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,7 +1150,7 @@ impl CloudAuthManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = self
|
let result = self
|
||||||
.api_call_with_retry(|access_token| {
|
.api_call_with_retry(|access_token| {
|
||||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||||
// Bound the request: without a timeout, an unreachable
|
// Bound the request: without a timeout, an unreachable
|
||||||
@@ -1050,7 +1184,31 @@ impl CloudAuthManager {
|
|||||||
Ok(result.token)
|
Ok(result.token)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
|
let token = match result {
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(e) => {
|
||||||
|
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
|
||||||
|
// token-reuse throttle, "active subscription required", and the
|
||||||
|
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
|
||||||
|
// This is distinct from a 401 (dead access token) — the session is still
|
||||||
|
// valid, the user is just temporarily/conditionally not entitled. So we
|
||||||
|
// do NOT invalidate the session. Instead: drop the stale wayfern token so
|
||||||
|
// no browser launches half-authenticated, re-fetch the profile so the
|
||||||
|
// cached plan reflects the backend's real state (it may have changed),
|
||||||
|
// and signal the UI so the user learns why automation stopped working.
|
||||||
|
if e.contains("(403") || e.contains("Forbidden") {
|
||||||
|
log::warn!("Wayfern token blocked by backend (403): {e}");
|
||||||
|
self.clear_wayfern_token().await;
|
||||||
|
if let Err(fetch_err) = self.fetch_profile().await {
|
||||||
|
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
|
||||||
|
}
|
||||||
|
let _ = crate::events::emit_empty("wayfern-paid-blocked");
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut wt = self.wayfern_token.lock().await;
|
let mut wt = self.wayfern_token.lock().await;
|
||||||
*wt = Some(token);
|
*wt = Some(token);
|
||||||
@@ -1184,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
code: String,
|
code: String,
|
||||||
) -> Result<CloudAuthState, String> {
|
) -> Result<CloudAuthState, String> {
|
||||||
let state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||||
|
|
||||||
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1219,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
|
|||||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||||
|
|
||||||
let _ = &app_handle;
|
let _ = &app_handle;
|
||||||
|
state.user.entitlements = Some(state.user.entitlements());
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
||||||
Ok(CLOUD_AUTH.get_user().await)
|
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
|
||||||
|
// Always hand the frontend a resolved entitlements object so it never has to
|
||||||
|
// derive capabilities itself (covers older cached state with no entitlements).
|
||||||
|
state.user.entitlements = Some(state.user.entitlements());
|
||||||
|
state
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||||
CLOUD_AUTH.fetch_profile().await
|
let mut user = CLOUD_AUTH.fetch_profile().await?;
|
||||||
|
user.entitlements = Some(user.entitlements());
|
||||||
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
+62
-6
@@ -43,6 +43,7 @@ pub mod proxy_runner;
|
|||||||
pub mod proxy_server;
|
pub mod proxy_server;
|
||||||
pub mod proxy_storage;
|
pub mod proxy_storage;
|
||||||
mod settings_manager;
|
mod settings_manager;
|
||||||
|
pub mod socks5_local;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
mod synchronizer;
|
mod synchronizer;
|
||||||
pub mod traffic_stats;
|
pub mod traffic_stats;
|
||||||
@@ -150,6 +151,8 @@ use api_server::{get_api_server_status, start_api_server, stop_api_server};
|
|||||||
pub trait WindowExt {
|
pub trait WindowExt {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
|
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn disable_native_fullscreen(&self) -> Result<(), String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||||
@@ -164,7 +167,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
|||||||
|
|
||||||
if transparent {
|
if transparent {
|
||||||
// Hide the title text
|
// Hide the title text
|
||||||
ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden
|
ns_window.setTitleVisibility(NSWindowTitleVisibility(1)); // NSWindowTitleHidden
|
||||||
|
|
||||||
// Make titlebar transparent
|
// Make titlebar transparent
|
||||||
ns_window.setTitlebarAppearsTransparent(true);
|
ns_window.setTitlebarAppearsTransparent(true);
|
||||||
@@ -189,6 +192,33 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn disable_native_fullscreen(&self) -> Result<(), String> {
|
||||||
|
use objc2::rc::Retained;
|
||||||
|
use objc2_app_kit::{NSWindow, NSWindowCollectionBehavior};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let ns_window: Retained<NSWindow> =
|
||||||
|
Retained::retain(self.ns_window().unwrap().cast()).unwrap();
|
||||||
|
|
||||||
|
// Make the green title-bar button (and titlebar double-click) "zoom"
|
||||||
|
// the window to fill the screen as an ordinary window instead of
|
||||||
|
// entering immersive native fullscreen that hides the menu bar and
|
||||||
|
// moves to its own Space. Mirrors Electron's `fullscreenable: false`:
|
||||||
|
// clear FullScreenPrimary and set FullScreenNone. AppKit then maps the
|
||||||
|
// green button to the standard zoom, expanding to the visible screen
|
||||||
|
// frame while keeping the window chrome and the current Space.
|
||||||
|
const FULL_SCREEN_PRIMARY: usize = 1 << 7;
|
||||||
|
const FULL_SCREEN_NONE: usize = 1 << 9;
|
||||||
|
let current = ns_window.collectionBehavior();
|
||||||
|
let updated =
|
||||||
|
NSWindowCollectionBehavior((current.0 & !FULL_SCREEN_PRIMARY) | FULL_SCREEN_NONE);
|
||||||
|
ns_window.setCollectionBehavior(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called internally for deep-link / startup URL handling — not invoked from the
|
// Called internally for deep-link / startup URL handling — not invoked from the
|
||||||
@@ -1272,13 +1302,18 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
|
|||||||
.item(&quit_item)
|
.item(&quit_item)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
// macOS uses a black template icon (the OS tints it for light/dark menu
|
// macOS uses the black icon as a template — the OS tints it for the light or
|
||||||
// bars). Windows and Linux use the full-color icon, because neither tints a
|
// dark menu bar. Linux (and other non-Windows desktops) get a white-bodied
|
||||||
// template — a black template would be invisible on dark Linux panels.
|
// icon with a dark outline so it stays legible on both dark and light
|
||||||
|
// panels: Tauri feeds the SNI/AppIndicator a fixed pixmap with no template
|
||||||
|
// tinting, so the icon has to carry its own contrast (a solid black icon is
|
||||||
|
// invisible on GNOME's dark top bar). Windows keeps its own solid icon.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
|
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(target_os = "windows")]
|
||||||
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
|
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-linux-44.png");
|
||||||
let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8();
|
let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8();
|
||||||
let (tray_w, tray_h) = tray_rgba.dimensions();
|
let (tray_w, tray_h) = tray_rgba.dimensions();
|
||||||
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
|
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
|
||||||
@@ -1388,6 +1423,21 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_macos_permissions::init())
|
.plugin(tauri_plugin_macos_permissions::init())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
// Persist window size/position across restarts. VISIBLE is excluded
|
||||||
|
// because the app hides to tray: restoring visibility would otherwise
|
||||||
|
// relaunch with an invisible window after quitting from the tray while
|
||||||
|
// hidden. FULLSCREEN is excluded because native fullscreen is disabled
|
||||||
|
// (the green button zooms instead) — the maximized flag captures the
|
||||||
|
// "filled screen" state, including green-button zoom on macOS.
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_window_state::Builder::default()
|
||||||
|
.with_state_flags(
|
||||||
|
tauri_plugin_window_state::StateFlags::all()
|
||||||
|
& !tauri_plugin_window_state::StateFlags::VISIBLE
|
||||||
|
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||||
ephemeral_dirs::recover_ephemeral_dirs();
|
ephemeral_dirs::recover_ephemeral_dirs();
|
||||||
@@ -1403,7 +1453,8 @@ pub fn run() {
|
|||||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||||
.title("Donut Browser")
|
.title("Donut Browser")
|
||||||
.inner_size(880.0, 500.0)
|
.inner_size(880.0, 500.0)
|
||||||
.resizable(false)
|
.min_inner_size(640.0, 400.0)
|
||||||
|
.resizable(true)
|
||||||
.fullscreen(false)
|
.fullscreen(false)
|
||||||
.center()
|
.center()
|
||||||
.focused(true)
|
.focused(true)
|
||||||
@@ -1447,6 +1498,11 @@ pub fn run() {
|
|||||||
if let Err(e) = window.set_transparent_titlebar(true) {
|
if let Err(e) = window.set_transparent_titlebar(true) {
|
||||||
log::warn!("Failed to set transparent titlebar: {e}");
|
log::warn!("Failed to set transparent titlebar: {e}");
|
||||||
}
|
}
|
||||||
|
// Green title-bar button maximizes (zoom) the window rather than
|
||||||
|
// entering immersive native fullscreen.
|
||||||
|
if let Err(e) = window.disable_native_fullscreen() {
|
||||||
|
log::warn!("Failed to disable native fullscreen: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up deep link handler
|
// Set up deep link handler
|
||||||
|
|||||||
+131
-34
@@ -152,11 +152,11 @@ impl McpServer {
|
|||||||
self.is_running.load(Ordering::SeqCst)
|
self.is_running.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
|
/// Gate an MCP tool on a capability the caller already resolved (e.g.
|
||||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
|
||||||
// Log the failed gate so customer logs explain why an MCP tool returned
|
/// with enough state for support to diagnose, without leaking secrets.
|
||||||
// an error. Include enough state (logged-in vs not, plan, status) for
|
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
|
||||||
// support to diagnose without leaking secrets.
|
if !allowed {
|
||||||
let summary = match CLOUD_AUTH.get_user().await {
|
let summary = match CLOUD_AUTH.get_user().await {
|
||||||
Some(state) => format!(
|
Some(state) => format!(
|
||||||
"logged_in=true plan={} status={} period={:?}",
|
"logged_in=true plan={} status={} period={:?}",
|
||||||
@@ -164,10 +164,10 @@ impl McpServer {
|
|||||||
),
|
),
|
||||||
None => "logged_in=false".to_string(),
|
None => "logged_in=false".to_string(),
|
||||||
};
|
};
|
||||||
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
|
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
|
||||||
return Err(McpError {
|
return Err(McpError {
|
||||||
code: -32000,
|
code: -32000,
|
||||||
message: format!("{feature} requires an active paid subscription"),
|
message: format!("{feature} requires a plan that includes this feature"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -286,6 +286,9 @@ impl McpServer {
|
|||||||
.delete(Self::handle_mcp_delete),
|
.delete(Self::handle_mcp_delete),
|
||||||
)
|
)
|
||||||
.route("/health", get(Self::handle_health))
|
.route("/health", get(Self::handle_health))
|
||||||
|
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||||
|
// automation request limit. See rate_limit_middleware.
|
||||||
|
.layer(middleware::from_fn(Self::rate_limit_middleware))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
Self::auth_middleware,
|
Self::auth_middleware,
|
||||||
@@ -316,6 +319,17 @@ impl McpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chokepoint for the future per-hour automation request limit, mirroring the
|
||||||
|
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
|
||||||
|
/// entitlements; this is intentionally inert today — it resolves the limit but
|
||||||
|
/// never blocks. To enforce, count authenticated tool calls per rolling hour
|
||||||
|
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
|
||||||
|
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
|
||||||
|
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
|
||||||
|
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
|
|
||||||
async fn auth_middleware(
|
async fn auth_middleware(
|
||||||
State(state): State<McpHttpState>,
|
State(state): State<McpHttpState>,
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
@@ -339,8 +353,16 @@ impl McpServer {
|
|||||||
.and_then(|h| h.to_str().ok())
|
.and_then(|h| h.to_str().ok())
|
||||||
.and_then(|h| h.strip_prefix("Bearer "));
|
.and_then(|h| h.strip_prefix("Bearer "));
|
||||||
|
|
||||||
let valid =
|
// Constant-time comparison to avoid leaking the token prefix via timing.
|
||||||
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
|
use subtle::ConstantTimeEq;
|
||||||
|
let expected = state.token.as_bytes();
|
||||||
|
let ct_eq = |t: Option<&str>| {
|
||||||
|
t.is_some_and(|t| {
|
||||||
|
let b = t.as_bytes();
|
||||||
|
b.len() == expected.len() && b.ct_eq(expected).into()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let valid = ct_eq(path_token) || ct_eq(header_token);
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
@@ -1639,10 +1661,21 @@ impl McpServer {
|
|||||||
"list_profiles" => self.handle_list_profiles().await,
|
"list_profiles" => self.handle_list_profiles().await,
|
||||||
"get_profile" => self.handle_get_profile(arguments).await,
|
"get_profile" => self.handle_get_profile(arguments).await,
|
||||||
"run_profile" => {
|
"run_profile" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_run_profile(arguments).await
|
self.handle_run_profile(arguments).await
|
||||||
}
|
}
|
||||||
"kill_profile" => self.handle_kill_profile(arguments).await,
|
"kill_profile" => {
|
||||||
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.handle_kill_profile(arguments).await
|
||||||
|
}
|
||||||
"create_profile" => self.handle_create_profile(arguments).await,
|
"create_profile" => self.handle_create_profile(arguments).await,
|
||||||
"update_profile" => self.handle_update_profile(arguments).await,
|
"update_profile" => self.handle_update_profile(arguments).await,
|
||||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||||
@@ -1671,13 +1704,16 @@ impl McpServer {
|
|||||||
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
||||||
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
||||||
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
||||||
// Fingerprint management — viewing and editing both require a paid plan.
|
// Fingerprint management — viewing is free everywhere (matches the REST
|
||||||
"get_profile_fingerprint" => {
|
// API and the get_profile tool, which already expose the config); only
|
||||||
Self::require_paid_subscription("Fingerprint").await?;
|
// editing requires a paid plan.
|
||||||
self.handle_get_profile_fingerprint(arguments).await
|
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||||
}
|
|
||||||
"update_profile_fingerprint" => {
|
"update_profile_fingerprint" => {
|
||||||
Self::require_paid_subscription("Fingerprint").await?;
|
Self::require_capability(
|
||||||
|
"Fingerprint editing",
|
||||||
|
CLOUD_AUTH.can_use_cross_os_fingerprints().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_update_profile_fingerprint(arguments).await
|
self.handle_update_profile_fingerprint(arguments).await
|
||||||
}
|
}
|
||||||
"update_profile_proxy_bypass_rules" => {
|
"update_profile_proxy_bypass_rules" => {
|
||||||
@@ -1706,7 +1742,11 @@ impl McpServer {
|
|||||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||||
// Synchronizer tools
|
// Synchronizer tools
|
||||||
"start_sync_session" => {
|
"start_sync_session" => {
|
||||||
Self::require_paid_subscription("Synchronizer").await?;
|
Self::require_capability(
|
||||||
|
"Synchronizer",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_start_sync_session(arguments).await
|
self.handle_start_sync_session(arguments).await
|
||||||
}
|
}
|
||||||
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
||||||
@@ -1714,43 +1754,83 @@ impl McpServer {
|
|||||||
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
||||||
// Browser interaction tools (require paid subscription)
|
// Browser interaction tools (require paid subscription)
|
||||||
"navigate" => {
|
"navigate" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_navigate(arguments).await
|
self.handle_navigate(arguments).await
|
||||||
}
|
}
|
||||||
"screenshot" => {
|
"screenshot" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_screenshot(arguments).await
|
self.handle_screenshot(arguments).await
|
||||||
}
|
}
|
||||||
"evaluate_javascript" => {
|
"evaluate_javascript" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_evaluate_javascript(arguments).await
|
self.handle_evaluate_javascript(arguments).await
|
||||||
}
|
}
|
||||||
"click_element" => {
|
"click_element" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_click_element(arguments).await
|
self.handle_click_element(arguments).await
|
||||||
}
|
}
|
||||||
"type_text" => {
|
"type_text" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_type_text(arguments).await
|
self.handle_type_text(arguments).await
|
||||||
}
|
}
|
||||||
"get_page_content" => {
|
"get_page_content" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_get_page_content(arguments).await
|
self.handle_get_page_content(arguments).await
|
||||||
}
|
}
|
||||||
"get_page_info" => {
|
"get_page_info" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_get_page_info(arguments).await
|
self.handle_get_page_info(arguments).await
|
||||||
}
|
}
|
||||||
"get_interactive_elements" => {
|
"get_interactive_elements" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_get_interactive_elements(arguments).await
|
self.handle_get_interactive_elements(arguments).await
|
||||||
}
|
}
|
||||||
"click_by_index" => {
|
"click_by_index" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_click_by_index(arguments).await
|
self.handle_click_by_index(arguments).await
|
||||||
}
|
}
|
||||||
"type_by_index" => {
|
"type_by_index" => {
|
||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_capability(
|
||||||
|
"Browser automation",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
self.handle_type_by_index(arguments).await
|
self.handle_type_by_index(arguments).await
|
||||||
}
|
}
|
||||||
_ => Err(McpError {
|
_ => Err(McpError {
|
||||||
@@ -1829,8 +1909,12 @@ impl McpServer {
|
|||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value, McpError> {
|
) -> Result<serde_json::Value, McpError> {
|
||||||
// Launching profiles programmatically is a paid feature.
|
// Launching profiles programmatically requires the automation capability.
|
||||||
Self::require_paid_subscription("Launching a profile").await?;
|
Self::require_capability(
|
||||||
|
"Launching a profile",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let profile_id = arguments
|
let profile_id = arguments
|
||||||
.get("profile_id")
|
.get("profile_id")
|
||||||
@@ -1913,8 +1997,12 @@ impl McpServer {
|
|||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value, McpError> {
|
) -> Result<serde_json::Value, McpError> {
|
||||||
// Stopping profiles programmatically is a paid feature.
|
// Stopping profiles programmatically requires the automation capability.
|
||||||
Self::require_paid_subscription("Killing a profile").await?;
|
Self::require_capability(
|
||||||
|
"Killing a profile",
|
||||||
|
CLOUD_AUTH.can_use_browser_automation().await,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let profile_id = arguments
|
let profile_id = arguments
|
||||||
.get("profile_id")
|
.get("profile_id")
|
||||||
@@ -2592,6 +2680,15 @@ impl McpServer {
|
|||||||
message: "Missing proxy_type".to_string(),
|
message: "Missing proxy_type".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
|
||||||
|
// enforce it here so a bad value can't produce a non-functional proxy.
|
||||||
|
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
|
||||||
|
return Err(McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let host = arguments
|
let host = arguments
|
||||||
.get("host")
|
.get("host")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -3243,10 +3340,10 @@ impl McpServer {
|
|||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value, McpError> {
|
) -> Result<serde_json::Value, McpError> {
|
||||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
if !CLOUD_AUTH.can_use_cross_os_fingerprints().await {
|
||||||
return Err(McpError {
|
return Err(McpError {
|
||||||
code: -32000,
|
code: -32000,
|
||||||
message: "Fingerprint editing requires an active Pro subscription".to_string(),
|
message: "Fingerprint editing requires a plan that includes it".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,42 @@ use crate::profile::BrowserProfile;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// True if a process command line refers to `profile_path` as a real browser
|
||||||
|
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
|
||||||
|
/// force-killed unrelated processes that happened to mention the path (editors,
|
||||||
|
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
|
||||||
|
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
|
||||||
|
///
|
||||||
|
/// Only the macOS and Linux process-kill paths use this; Windows has no
|
||||||
|
/// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there.
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
||||||
|
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
||||||
|
for (i, arg) in args.iter().enumerate() {
|
||||||
|
// Exact argument equality (Firefox/Camoufox: `-profile <path>`; some launchers
|
||||||
|
// pass the path as its own arg).
|
||||||
|
if *arg == profile_path {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
|
||||||
|
if let Some(val) = arg
|
||||||
|
.strip_prefix("--user-data-dir=")
|
||||||
|
.or_else(|| arg.strip_prefix("-profile="))
|
||||||
|
{
|
||||||
|
if val == profile_path {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flag followed by the path as the next argument.
|
||||||
|
if (*arg == "-profile" || *arg == "--user-data-dir")
|
||||||
|
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
// Platform-specific modules
|
// Platform-specific modules
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -215,16 +251,7 @@ pub mod macos {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any command line argument contains the profile path
|
if cmd_matches_profile_path(cmd, profile_path) {
|
||||||
let has_profile = cmd.iter().any(|arg| {
|
|
||||||
if let Some(arg_str) = arg.to_str() {
|
|
||||||
arg_str.contains(profile_path)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_profile {
|
|
||||||
pids.push(pid.as_u32());
|
pids.push(pid.as_u32());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -832,15 +859,7 @@ pub mod linux {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_profile = cmd.iter().any(|arg| {
|
if cmd_matches_profile_path(cmd, profile_path) {
|
||||||
if let Some(arg_str) = arg.to_str() {
|
|
||||||
arg_str.contains(profile_path)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_profile {
|
|
||||||
pids.push(pid.as_u32());
|
pids.push(pid.as_u32());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1035,7 +1035,7 @@ impl ProfileManager {
|
|||||||
fs::create_dir_all(&dest_dir)?;
|
fs::create_dir_all(&dest_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_profile = BrowserProfile {
|
let mut new_profile = BrowserProfile {
|
||||||
id: new_id,
|
id: new_id,
|
||||||
name: clone_name,
|
name: clone_name,
|
||||||
browser: source.browser,
|
browser: source.browser,
|
||||||
@@ -1071,6 +1071,21 @@ impl ProfileManager {
|
|||||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Donut: a clone must NOT be linkable to its source. The source
|
||||||
|
// wayfern_config embeds the persisted fingerprint JSON (including the
|
||||||
|
// canvas_noise_seed), so copying it verbatim makes the clone emit
|
||||||
|
// BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device
|
||||||
|
// signals as the source — trivially linkable if both run concurrently. Clear
|
||||||
|
// the fingerprint so the launch path mints a fresh one (a new
|
||||||
|
// canvas_noise_seed via RandBytes + an independent device fingerprint),
|
||||||
|
// exactly as create_profile does when fingerprint.is_none(). NOTE: the
|
||||||
|
// user-data-dir copy above still duplicates cookies/localStorage/TLS state —
|
||||||
|
// a separate storage-linkage vector the user must clear if they want full
|
||||||
|
// isolation between a clone and its source.
|
||||||
|
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
|
||||||
|
cfg.fingerprint = None;
|
||||||
|
}
|
||||||
|
|
||||||
self.save_profile(&new_profile)?;
|
self.save_profile(&new_profile)?;
|
||||||
|
|
||||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||||
@@ -2501,7 +2516,7 @@ pub async fn update_camoufox_config(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if config.fingerprint.is_some()
|
if config.fingerprint.is_some()
|
||||||
&& !crate::cloud_auth::CLOUD_AUTH
|
&& !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_cross_os_fingerprints()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||||
@@ -2529,7 +2544,7 @@ pub async fn update_wayfern_config(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if config.fingerprint.is_some()
|
if config.fingerprint.is_some()
|
||||||
&& !crate::cloud_auth::CLOUD_AUTH
|
&& !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_cross_os_fingerprints()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use directories::BaseDirs;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs::{self, create_dir_all};
|
use std::fs::{self, create_dir_all};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::camoufox_manager::CamoufoxConfig;
|
use crate::camoufox_manager::CamoufoxConfig;
|
||||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||||
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn map_browser_type(browser: &str) -> &str {
|
fn map_browser_type(browser: &str) -> &str {
|
||||||
|
// Firefox-based sources map to the now-deprecated Camoufox. They are no longer
|
||||||
|
// detected for import; the mapping is kept only so the import command can
|
||||||
|
// recognize and REJECT them. Everything else maps to Wayfern.
|
||||||
match browser {
|
match browser {
|
||||||
"firefox" | "firefox-developer" | "zen" => "camoufox",
|
"firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
|
||||||
"chromium" | "brave" => "wayfern",
|
|
||||||
"camoufox" => "camoufox",
|
|
||||||
"wayfern" => "wayfern",
|
|
||||||
_ => "wayfern",
|
_ => "wayfern",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
|
|||||||
base_dirs: BaseDirs,
|
base_dirs: BaseDirs,
|
||||||
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||||
profile_manager: &'static ProfileManager,
|
profile_manager: &'static ProfileManager,
|
||||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
|
||||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ impl ProfileImporter {
|
|||||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||||
profile_manager: ProfileManager::instance(),
|
profile_manager: ProfileManager::instance(),
|
||||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
|
||||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,12 +56,12 @@ impl ProfileImporter {
|
|||||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||||
let mut detected_profiles = Vec::new();
|
let mut detected_profiles = Vec::new();
|
||||||
|
|
||||||
detected_profiles.extend(self.detect_firefox_profiles()?);
|
// Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox,
|
||||||
|
// which is deprecated — they can no longer be imported. Only Chromium-based
|
||||||
|
// sources (mapping to Wayfern) are detected.
|
||||||
detected_profiles.extend(self.detect_chrome_profiles()?);
|
detected_profiles.extend(self.detect_chrome_profiles()?);
|
||||||
detected_profiles.extend(self.detect_brave_profiles()?);
|
detected_profiles.extend(self.detect_brave_profiles()?);
|
||||||
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
|
|
||||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
|
||||||
|
|
||||||
let mut seen_paths = HashSet::new();
|
let mut seen_paths = HashSet::new();
|
||||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||||
@@ -74,80 +72,6 @@ impl ProfileImporter {
|
|||||||
Ok(unique_profiles)
|
Ok(unique_profiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
|
||||||
let mut profiles = Vec::new();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let firefox_dir = self
|
|
||||||
.base_dirs
|
|
||||||
.home_dir()
|
|
||||||
.join("Library/Application Support/Firefox/Profiles");
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let app_data = self.base_dirs.data_dir();
|
|
||||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
|
||||||
|
|
||||||
let local_app_data = self.base_dirs.data_local_dir();
|
|
||||||
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
|
|
||||||
if firefox_local_dir.exists() {
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(profiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_firefox_developer_profiles(
|
|
||||||
&self,
|
|
||||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
|
||||||
let mut profiles = Vec::new();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let firefox_dev_alt_dir = self
|
|
||||||
.base_dirs
|
|
||||||
.home_dir()
|
|
||||||
.join("Library/Application Support/Firefox Developer Edition/Profiles");
|
|
||||||
|
|
||||||
if firefox_dev_alt_dir.exists() {
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let app_data = self.base_dirs.data_dir();
|
|
||||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
|
||||||
if firefox_dev_dir.exists() {
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let firefox_dev_dir = self
|
|
||||||
.base_dirs
|
|
||||||
.home_dir()
|
|
||||||
.join(".mozilla/firefox-dev-edition");
|
|
||||||
if firefox_dev_dir.exists() {
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(profiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||||
let mut profiles = Vec::new();
|
let mut profiles = Vec::new();
|
||||||
|
|
||||||
@@ -235,191 +159,6 @@ impl ProfileImporter {
|
|||||||
Ok(profiles)
|
Ok(profiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_zen_browser_profiles(
|
|
||||||
&self,
|
|
||||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
|
||||||
let mut profiles = Vec::new();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let zen_dir = self
|
|
||||||
.base_dirs
|
|
||||||
.home_dir()
|
|
||||||
.join("Library/Application Support/Zen/Profiles");
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let app_data = self.base_dirs.data_dir();
|
|
||||||
let zen_dir = app_data.join("Zen/Profiles");
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let zen_dir = self.base_dirs.home_dir().join(".zen");
|
|
||||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(profiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_firefox_profiles_dir(
|
|
||||||
&self,
|
|
||||||
profiles_dir: &Path,
|
|
||||||
browser_type: &str,
|
|
||||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
|
||||||
let mut profiles = Vec::new();
|
|
||||||
|
|
||||||
if !profiles_dir.exists() {
|
|
||||||
return Ok(profiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
let profiles_ini = profiles_dir
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(profiles_dir)
|
|
||||||
.join("profiles.ini");
|
|
||||||
if profiles_ini.exists() {
|
|
||||||
if let Ok(content) = fs::read_to_string(&profiles_ini) {
|
|
||||||
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(entries) = fs::read_dir(profiles_dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
let prefs_file = path.join("prefs.js");
|
|
||||||
if prefs_file.exists() {
|
|
||||||
let profile_name = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("Unknown Profile");
|
|
||||||
|
|
||||||
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
|
|
||||||
if !already_added {
|
|
||||||
profiles.push(DetectedProfile {
|
|
||||||
browser: browser_type.to_string(),
|
|
||||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
|
||||||
name: format!(
|
|
||||||
"{} Profile - {}",
|
|
||||||
self.get_browser_display_name(browser_type),
|
|
||||||
profile_name
|
|
||||||
),
|
|
||||||
path: path.to_string_lossy().to_string(),
|
|
||||||
description: format!("Profile folder: {profile_name}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(profiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_firefox_profiles_ini(
|
|
||||||
&self,
|
|
||||||
content: &str,
|
|
||||||
profiles_dir: &Path,
|
|
||||||
browser_type: &str,
|
|
||||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
|
||||||
let mut profiles = Vec::new();
|
|
||||||
let mut current_section = String::new();
|
|
||||||
let mut profile_name = String::new();
|
|
||||||
let mut profile_path = String::new();
|
|
||||||
let mut is_relative = true;
|
|
||||||
|
|
||||||
for line in content.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
|
|
||||||
if line.starts_with('[') && line.ends_with(']') {
|
|
||||||
if !current_section.is_empty()
|
|
||||||
&& current_section.starts_with("Profile")
|
|
||||||
&& !profile_path.is_empty()
|
|
||||||
{
|
|
||||||
let full_path = if is_relative {
|
|
||||||
profiles_dir.join(&profile_path)
|
|
||||||
} else {
|
|
||||||
PathBuf::from(&profile_path)
|
|
||||||
};
|
|
||||||
|
|
||||||
if full_path.exists() {
|
|
||||||
let display_name = if profile_name.is_empty() {
|
|
||||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{} - {}",
|
|
||||||
self.get_browser_display_name(browser_type),
|
|
||||||
profile_name
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
profiles.push(DetectedProfile {
|
|
||||||
browser: browser_type.to_string(),
|
|
||||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
|
||||||
name: display_name,
|
|
||||||
path: full_path.to_string_lossy().to_string(),
|
|
||||||
description: format!("Profile: {profile_name}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
current_section = line[1..line.len() - 1].to_string();
|
|
||||||
profile_name.clear();
|
|
||||||
profile_path.clear();
|
|
||||||
is_relative = true;
|
|
||||||
} else if line.contains('=') {
|
|
||||||
let parts: Vec<&str> = line.splitn(2, '=').collect();
|
|
||||||
if parts.len() == 2 {
|
|
||||||
let key = parts[0].trim();
|
|
||||||
let value = parts[1].trim();
|
|
||||||
|
|
||||||
match key {
|
|
||||||
"Name" => profile_name = value.to_string(),
|
|
||||||
"Path" => profile_path = value.to_string(),
|
|
||||||
"IsRelative" => is_relative = value == "1",
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !current_section.is_empty()
|
|
||||||
&& current_section.starts_with("Profile")
|
|
||||||
&& !profile_path.is_empty()
|
|
||||||
{
|
|
||||||
let full_path = if is_relative {
|
|
||||||
profiles_dir.join(&profile_path)
|
|
||||||
} else {
|
|
||||||
PathBuf::from(&profile_path)
|
|
||||||
};
|
|
||||||
|
|
||||||
if full_path.exists() {
|
|
||||||
let display_name = if profile_name.is_empty() {
|
|
||||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{} - {}",
|
|
||||||
self.get_browser_display_name(browser_type),
|
|
||||||
profile_name
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
profiles.push(DetectedProfile {
|
|
||||||
browser: browser_type.to_string(),
|
|
||||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
|
||||||
name: display_name,
|
|
||||||
path: full_path.to_string_lossy().to_string(),
|
|
||||||
description: format!("Profile: {profile_name}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(profiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_chrome_profiles_dir(
|
fn scan_chrome_profiles_dir(
|
||||||
&self,
|
&self,
|
||||||
browser_dir: &Path,
|
browser_dir: &Path,
|
||||||
@@ -493,7 +232,7 @@ impl ProfileImporter {
|
|||||||
browser_type: &str,
|
browser_type: &str,
|
||||||
new_profile_name: &str,
|
new_profile_name: &str,
|
||||||
proxy_id: Option<String>,
|
proxy_id: Option<String>,
|
||||||
camoufox_config: Option<CamoufoxConfig>,
|
_camoufox_config: Option<CamoufoxConfig>,
|
||||||
wayfern_config: Option<WayfernConfig>,
|
wayfern_config: Option<WayfernConfig>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let source_path = Path::new(source_path);
|
let source_path = Path::new(source_path);
|
||||||
@@ -529,88 +268,9 @@ impl ProfileImporter {
|
|||||||
|
|
||||||
let version = self.get_default_version_for_browser(mapped)?;
|
let version = self.get_default_version_for_browser(mapped)?;
|
||||||
|
|
||||||
let final_camoufox_config = if mapped == "camoufox" {
|
// Camoufox import is removed; only Wayfern profiles are imported now, so the
|
||||||
let mut config = camoufox_config.unwrap_or_default();
|
// imported profile never carries a Camoufox config.
|
||||||
|
let final_camoufox_config: Option<CamoufoxConfig> = None;
|
||||||
if let Some(ref proxy_id_val) = proxy_id {
|
|
||||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
|
|
||||||
let proxy_url = if let (Some(username), Some(password)) =
|
|
||||||
(&proxy_settings.username, &proxy_settings.password)
|
|
||||||
{
|
|
||||||
format!(
|
|
||||||
"{}://{}:{}@{}:{}",
|
|
||||||
proxy_settings.proxy_type.to_lowercase(),
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
proxy_settings.host,
|
|
||||||
proxy_settings.port
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{}://{}:{}",
|
|
||||||
proxy_settings.proxy_type.to_lowercase(),
|
|
||||||
proxy_settings.host,
|
|
||||||
proxy_settings.port
|
|
||||||
)
|
|
||||||
};
|
|
||||||
config.proxy = Some(proxy_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.fingerprint.is_none() {
|
|
||||||
let temp_profile = BrowserProfile {
|
|
||||||
id: uuid::Uuid::new_v4(),
|
|
||||||
name: new_profile_name.to_string(),
|
|
||||||
browser: mapped.to_string(),
|
|
||||||
version: version.clone(),
|
|
||||||
proxy_id: proxy_id.clone(),
|
|
||||||
vpn_id: None,
|
|
||||||
launch_hook: None,
|
|
||||||
process_id: None,
|
|
||||||
last_launch: None,
|
|
||||||
release_type: "stable".to_string(),
|
|
||||||
camoufox_config: None,
|
|
||||||
wayfern_config: None,
|
|
||||||
group_id: None,
|
|
||||||
tags: Vec::new(),
|
|
||||||
note: None,
|
|
||||||
sync_mode: SyncMode::Disabled,
|
|
||||||
encryption_salt: None,
|
|
||||||
last_sync: None,
|
|
||||||
host_os: None,
|
|
||||||
ephemeral: false,
|
|
||||||
extension_group_id: None,
|
|
||||||
proxy_bypass_rules: Vec::new(),
|
|
||||||
created_by_id: None,
|
|
||||||
created_by_email: None,
|
|
||||||
dns_blocklist: None,
|
|
||||||
password_protected: false,
|
|
||||||
created_at: None,
|
|
||||||
updated_at: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match self
|
|
||||||
.camoufox_manager
|
|
||||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(fp) => config.fingerprint = Some(fp),
|
|
||||||
Err(e) => {
|
|
||||||
return Err(
|
|
||||||
format!(
|
|
||||||
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config.proxy = None;
|
|
||||||
Some(config)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let final_wayfern_config = if mapped == "wayfern" {
|
let final_wayfern_config = if mapped == "wayfern" {
|
||||||
let mut config = wayfern_config.unwrap_or_default();
|
let mut config = wayfern_config.unwrap_or_default();
|
||||||
@@ -806,6 +466,12 @@ pub async fn import_browser_profile(
|
|||||||
camoufox_config: Option<CamoufoxConfig>,
|
camoufox_config: Option<CamoufoxConfig>,
|
||||||
wayfern_config: Option<WayfernConfig>,
|
wayfern_config: Option<WayfernConfig>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Camoufox is deprecated — Firefox-based profiles (which map to Camoufox) can
|
||||||
|
// no longer be imported. Reject them before doing any work.
|
||||||
|
if map_browser_type(&browser_type) == "camoufox" {
|
||||||
|
return Err(serde_json::json!({ "code": "CAMOUFOX_IMPORT_DEPRECATED" }).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let fingerprint_os = camoufox_config
|
let fingerprint_os = camoufox_config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|c| c.os.as_deref())
|
.and_then(|c| c.os.as_deref())
|
||||||
@@ -897,24 +563,6 @@ mod tests {
|
|||||||
let _profiles = result.unwrap();
|
let _profiles = result.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scan_firefox_profiles_dir_nonexistent() {
|
|
||||||
let (importer, temp_dir) = create_test_profile_importer();
|
|
||||||
|
|
||||||
let nonexistent_dir = temp_dir.path().join("nonexistent");
|
|
||||||
let result = importer.scan_firefox_profiles_dir(&nonexistent_dir, "firefox");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"Should handle nonexistent directory gracefully"
|
|
||||||
);
|
|
||||||
let profiles = result.unwrap();
|
|
||||||
assert!(
|
|
||||||
profiles.is_empty(),
|
|
||||||
"Should return empty vector for nonexistent directory"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_scan_chrome_profiles_dir_nonexistent() {
|
fn test_scan_chrome_profiles_dir_nonexistent() {
|
||||||
let (importer, temp_dir) = create_test_profile_importer();
|
let (importer, temp_dir) = create_test_profile_importer();
|
||||||
@@ -933,51 +581,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_firefox_profiles_ini_empty() {
|
|
||||||
let (importer, _temp_dir) = create_test_profile_importer();
|
|
||||||
|
|
||||||
let empty_content = "";
|
|
||||||
let profiles_dir = Path::new("/tmp");
|
|
||||||
let result = importer.parse_firefox_profiles_ini(empty_content, profiles_dir, "firefox");
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "Should handle empty profiles.ini");
|
|
||||||
let profiles = result.unwrap();
|
|
||||||
assert!(
|
|
||||||
profiles.is_empty(),
|
|
||||||
"Should return empty vector for empty content"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_firefox_profiles_ini_valid() {
|
|
||||||
let (importer, temp_dir) = create_test_profile_importer();
|
|
||||||
|
|
||||||
let profiles_dir = temp_dir.path().join("profiles");
|
|
||||||
let profile_dir = profiles_dir.join("test.profile");
|
|
||||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
|
||||||
|
|
||||||
let prefs_file = profile_dir.join("prefs.js");
|
|
||||||
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
|
|
||||||
|
|
||||||
let profiles_ini_content = r#"
|
|
||||||
[Profile0]
|
|
||||||
Name=Test Profile
|
|
||||||
IsRelative=1
|
|
||||||
Path=test.profile
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let result =
|
|
||||||
importer.parse_firefox_profiles_ini(profiles_ini_content, &profiles_dir, "firefox");
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "Should parse valid profiles.ini");
|
|
||||||
let profiles = result.unwrap();
|
|
||||||
assert_eq!(profiles.len(), 1, "Should find one profile");
|
|
||||||
assert_eq!(profiles[0].name, "Firefox - Test Profile");
|
|
||||||
assert_eq!(profiles[0].browser, "firefox");
|
|
||||||
assert_eq!(profiles[0].mapped_browser, "camoufox");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_copy_directory_recursive() {
|
fn test_copy_directory_recursive() {
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||||
|
|||||||
@@ -774,6 +774,17 @@ impl ProxyManager {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert/replace a stored proxy in the in-memory map. Used by sync's
|
||||||
|
/// download_proxy after it writes the file to disk, mirroring how
|
||||||
|
/// download_group/download_vpn/download_extension keep their managers'
|
||||||
|
/// in-memory state in sync. Without this, get_stored_proxies (which reads
|
||||||
|
/// only the map) never sees a downloaded proxy until restart, so sync keeps
|
||||||
|
/// re-downloading it indefinitely.
|
||||||
|
pub fn upsert_stored_proxy(&self, proxy: StoredProxy) {
|
||||||
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||||
|
stored_proxies.insert(proxy.id.clone(), proxy);
|
||||||
|
}
|
||||||
|
|
||||||
// Get a stored proxy by ID
|
// Get a stored proxy by ID
|
||||||
|
|
||||||
// Update a stored proxy
|
// Update a stored proxy
|
||||||
@@ -1467,6 +1478,7 @@ impl ProxyManager {
|
|||||||
|
|
||||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||||
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn start_proxy(
|
pub async fn start_proxy(
|
||||||
&self,
|
&self,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -1475,6 +1487,10 @@ impl ProxyManager {
|
|||||||
profile_id: Option<&str>,
|
profile_id: Option<&str>,
|
||||||
bypass_rules: Vec<String>,
|
bypass_rules: Vec<String>,
|
||||||
blocklist_file: Option<String>,
|
blocklist_file: Option<String>,
|
||||||
|
// Protocol the local worker serves the browser: "http" (Camoufox) or
|
||||||
|
// "socks5" (Wayfern). Reflected in the returned ProxySettings.proxy_type
|
||||||
|
// so the caller formats the right local proxy URL scheme.
|
||||||
|
local_protocol: &str,
|
||||||
) -> Result<ProxySettings, String> {
|
) -> Result<ProxySettings, String> {
|
||||||
if let Some(name) = profile_id {
|
if let Some(name) = profile_id {
|
||||||
// Check if we have an active proxy recorded for this profile
|
// Check if we have an active proxy recorded for this profile
|
||||||
@@ -1508,7 +1524,7 @@ impl ProxyManager {
|
|||||||
if proxies.contains_key(&browser_pid) {
|
if proxies.contains_key(&browser_pid) {
|
||||||
// Already mapped, reuse it
|
// Already mapped, reuse it
|
||||||
return Ok(ProxySettings {
|
return Ok(ProxySettings {
|
||||||
proxy_type: "http".to_string(),
|
proxy_type: local_protocol.to_string(),
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: existing.local_port,
|
port: existing.local_port,
|
||||||
username: None,
|
username: None,
|
||||||
@@ -1548,7 +1564,7 @@ impl ProxyManager {
|
|||||||
if profile_id_matches {
|
if profile_id_matches {
|
||||||
// Reuse existing local proxy (settings and profile_id match)
|
// Reuse existing local proxy (settings and profile_id match)
|
||||||
return Ok(ProxySettings {
|
return Ok(ProxySettings {
|
||||||
proxy_type: "http".to_string(),
|
proxy_type: local_protocol.to_string(),
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: existing.local_port,
|
port: existing.local_port,
|
||||||
username: None,
|
username: None,
|
||||||
@@ -1607,6 +1623,9 @@ impl ProxyManager {
|
|||||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tell the worker which protocol to serve the browser (http or socks5)
|
||||||
|
proxy_cmd = proxy_cmd.arg("--local-protocol").arg(local_protocol);
|
||||||
|
|
||||||
// Execute the command and wait for it to complete
|
// Execute the command and wait for it to complete
|
||||||
// The donut-proxy binary should start the worker and then exit
|
// The donut-proxy binary should start the worker and then exit
|
||||||
let output = proxy_cmd
|
let output = proxy_cmd
|
||||||
@@ -1698,7 +1717,7 @@ impl ProxyManager {
|
|||||||
|
|
||||||
// Return proxy settings for the browser
|
// Return proxy settings for the browser
|
||||||
Ok(ProxySettings {
|
Ok(ProxySettings {
|
||||||
proxy_type: "http".to_string(),
|
proxy_type: local_protocol.to_string(),
|
||||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||||
port: proxy_info.local_port,
|
port: proxy_info.local_port,
|
||||||
username: None,
|
username: None,
|
||||||
@@ -1730,12 +1749,18 @@ impl ProxyManager {
|
|||||||
.arg("--id")
|
.arg("--id")
|
||||||
.arg(&proxy_id);
|
.arg(&proxy_id);
|
||||||
|
|
||||||
let output = proxy_cmd.output().await.unwrap();
|
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
|
||||||
|
// not panic the cleanup task — the proxy is already removed from tracking,
|
||||||
if !output.status.success() {
|
// so degrade gracefully like the non-success branch below.
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
match proxy_cmd.output().await {
|
||||||
log::warn!("Proxy stop error: {stderr}");
|
Ok(output) if !output.status.success() => {
|
||||||
// We still return Ok since we've already removed the proxy from our tracking
|
log::warn!(
|
||||||
|
"Proxy stop error: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear profile-to-proxy mapping if it references this proxy
|
// Clear profile-to-proxy mapping if it references this proxy
|
||||||
@@ -1795,11 +1820,16 @@ impl ProxyManager {
|
|||||||
.arg("--id")
|
.arg("--id")
|
||||||
.arg(&proxy_id);
|
.arg(&proxy_id);
|
||||||
|
|
||||||
let output = proxy_cmd.output().await.unwrap();
|
// Don't panic if the sidecar can't be spawned — still clear the mapping.
|
||||||
|
match proxy_cmd.output().await {
|
||||||
if !output.status.success() {
|
Ok(output) if !output.status.success() => {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
log::warn!(
|
||||||
log::warn!("Proxy stop error: {stderr}");
|
"Proxy stop error: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear profile-to-proxy mapping
|
// Clear profile-to-proxy mapping
|
||||||
@@ -2863,6 +2893,7 @@ mod tests {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
local_protocol: None,
|
||||||
};
|
};
|
||||||
let dead_config = ProxyConfig {
|
let dead_config = ProxyConfig {
|
||||||
id: dead_id.clone(),
|
id: dead_id.clone(),
|
||||||
@@ -2874,6 +2905,7 @@ mod tests {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
local_protocol: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
save_proxy_config(&live_config).unwrap();
|
save_proxy_config(&live_config).unwrap();
|
||||||
@@ -2913,6 +2945,7 @@ mod tests {
|
|||||||
profile_id: Some("prof_abc".to_string()),
|
profile_id: Some("prof_abc".to_string()),
|
||||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
local_protocol: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
@@ -3231,6 +3264,7 @@ mod tests {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
local_protocol: None,
|
||||||
};
|
};
|
||||||
save_proxy_config(&config).unwrap();
|
save_proxy_config(&config).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ pub async fn start_proxy_process(
|
|||||||
upstream_url: Option<String>,
|
upstream_url: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await
|
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_proxy_process_with_profile(
|
pub async fn start_proxy_process_with_profile(
|
||||||
@@ -169,6 +169,7 @@ pub async fn start_proxy_process_with_profile(
|
|||||||
profile_id: Option<String>,
|
profile_id: Option<String>,
|
||||||
bypass_rules: Vec<String>,
|
bypass_rules: Vec<String>,
|
||||||
blocklist_file: Option<String>,
|
blocklist_file: Option<String>,
|
||||||
|
local_protocol: Option<String>,
|
||||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||||
let id = generate_proxy_id();
|
let id = generate_proxy_id();
|
||||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||||
@@ -183,7 +184,8 @@ pub async fn start_proxy_process_with_profile(
|
|||||||
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
|
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
|
||||||
.with_profile_id(profile_id.clone())
|
.with_profile_id(profile_id.clone())
|
||||||
.with_bypass_rules(bypass_rules)
|
.with_bypass_rules(bypass_rules)
|
||||||
.with_blocklist_file(blocklist_file);
|
.with_blocklist_file(blocklist_file)
|
||||||
|
.with_local_protocol(local_protocol);
|
||||||
save_proxy_config(&config)?;
|
save_proxy_config(&config)?;
|
||||||
|
|
||||||
// Log profile_id for debugging
|
// Log profile_id for debugging
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ use tokio::net::TcpStream;
|
|||||||
/// Combined read+write trait for tunnel target streams, allowing
|
/// Combined read+write trait for tunnel target streams, allowing
|
||||||
/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and
|
/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and
|
||||||
/// Shadowsocks through the same bidirectional-copy path.
|
/// Shadowsocks through the same bidirectional-copy path.
|
||||||
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
pub(crate) trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncStream for T {}
|
impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncStream for T {}
|
||||||
type BoxedAsyncStream = Box<dyn AsyncStream>;
|
pub(crate) type BoxedAsyncStream = Box<dyn AsyncStream>;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
enum CompiledRule {
|
enum CompiledRule {
|
||||||
@@ -509,47 +509,20 @@ async fn handle_http_via_socks4(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve target host to IP (SOCKS4 requires IP addresses)
|
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
|
||||||
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
|
// hostname locally: tokio::net::lookup_host would call the HOST resolver
|
||||||
Ok(mut addrs) => {
|
// (getaddrinfo), leaking the destination domain to the host's DNS server and
|
||||||
if let Some(addr) = addrs.next() {
|
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
|
||||||
match addr.ip() {
|
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
|
||||||
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
|
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
|
||||||
std::net::IpAddr::V6(_) => {
|
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
|
||||||
log::error!("SOCKS4 does not support IPv6");
|
// HTTP — prefer SOCKS5 there.)
|
||||||
let mut response = Response::new(Full::new(Bytes::from(
|
|
||||||
"SOCKS4 does not support IPv6 addresses",
|
|
||||||
)));
|
|
||||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::error!("Failed to resolve target host: {}", target_host);
|
|
||||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
|
||||||
"Failed to resolve target host: {}",
|
|
||||||
target_host
|
|
||||||
))));
|
|
||||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to resolve target host {}: {}", target_host, e);
|
|
||||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
|
||||||
"Failed to resolve target host: {}",
|
|
||||||
e
|
|
||||||
))));
|
|
||||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build SOCKS4 CONNECT request
|
|
||||||
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
|
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
|
||||||
socks_request.extend_from_slice(&target_port.to_be_bytes());
|
socks_request.extend_from_slice(&target_port.to_be_bytes());
|
||||||
socks_request.extend_from_slice(&target_ip);
|
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
|
||||||
socks_request.push(0); // NULL terminator for userid
|
socks_request.push(0); // empty userid, NULL-terminated
|
||||||
|
socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve
|
||||||
|
socks_request.push(0); // NULL-terminated hostname
|
||||||
|
|
||||||
// Send SOCKS4 CONNECT request
|
// Send SOCKS4 CONNECT request
|
||||||
if let Err(e) = socks_stream.write_all(&socks_request).await {
|
if let Err(e) = socks_stream.write_all(&socks_request).await {
|
||||||
@@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy(
|
|||||||
Proxy::http(upstream_url)?
|
Proxy::http(upstream_url)?
|
||||||
}
|
}
|
||||||
"socks5" => {
|
"socks5" => {
|
||||||
// For SOCKS5, reqwest supports it directly
|
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
|
||||||
Proxy::all(upstream_url)?
|
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
|
||||||
|
// which resolves the destination hostname on the HOST (getaddrinfo) BEFORE
|
||||||
|
// connecting — leaking the destination domain to the host's DNS resolver
|
||||||
|
// and defeating the per-profile proxy. The `socks5h` scheme maps to
|
||||||
|
// DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks.
|
||||||
|
// (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's
|
||||||
|
// AddrKind::Domain.)
|
||||||
|
let remote_dns_url = match upstream_url.strip_prefix("socks5://") {
|
||||||
|
Some(rest) => format!("socks5h://{rest}"),
|
||||||
|
None => upstream_url.to_string(),
|
||||||
|
};
|
||||||
|
Proxy::all(remote_dns_url)?
|
||||||
}
|
}
|
||||||
"socks4" => {
|
"socks4" => {
|
||||||
// SOCKS4 is handled manually in handle_http_via_socks4
|
// SOCKS4 is handled manually in handle_http_via_socks4
|
||||||
@@ -1263,10 +1247,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
|||||||
|
|
||||||
log::info!("Successfully bound to port {}", actual_port);
|
log::info!("Successfully bound to port {}", actual_port);
|
||||||
|
|
||||||
// Update config with actual port and local_url
|
// Protocol served to the browser: "socks5" (Wayfern) or "http" (default).
|
||||||
|
let local_protocol = config.local_protocol_or_default();
|
||||||
|
let serve_socks5 = local_protocol == "socks5";
|
||||||
|
|
||||||
|
// Update config with actual port and local_url (scheme matches the protocol
|
||||||
|
// we serve, so the parent's readiness check and any consumer see the truth)
|
||||||
let mut updated_config = config.clone();
|
let mut updated_config = config.clone();
|
||||||
updated_config.local_port = Some(actual_port);
|
updated_config.local_port = Some(actual_port);
|
||||||
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
|
updated_config.local_url = Some(format!(
|
||||||
|
"{}://127.0.0.1:{}",
|
||||||
|
if serve_socks5 { "socks5" } else { "http" },
|
||||||
|
actual_port
|
||||||
|
));
|
||||||
|
|
||||||
if !crate::proxy_storage::update_proxy_config(&updated_config) {
|
if !crate::proxy_storage::update_proxy_config(&updated_config) {
|
||||||
log::error!("Failed to update proxy config");
|
log::error!("Failed to update proxy config");
|
||||||
@@ -1387,9 +1380,15 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
|||||||
let upstream = upstream_url.clone();
|
let upstream = upstream_url.clone();
|
||||||
let matcher = bypass_matcher.clone();
|
let matcher = bypass_matcher.clone();
|
||||||
let blocker = blocklist_matcher.clone();
|
let blocker = blocklist_matcher.clone();
|
||||||
tokio::task::spawn(async move {
|
if serve_socks5 {
|
||||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
tokio::task::spawn(async move {
|
||||||
});
|
crate::socks5_local::handle_socks5_connection(stream, upstream, matcher, blocker).await;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Error accepting connection: {:?}", e);
|
log::error!("Error accepting connection: {:?}", e);
|
||||||
@@ -1460,20 +1459,51 @@ async fn handle_connect_from_buffer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Connect to target (directly or via upstream proxy).
|
// Connect to target (directly or via upstream proxy).
|
||||||
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
let target_stream = connect_to_target_via_upstream(
|
||||||
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
target_host,
|
||||||
|
target_port,
|
||||||
|
upstream_url.as_deref(),
|
||||||
|
&bypass_matcher,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Send 200 Connection Established response to client
|
||||||
|
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
||||||
|
client_stream
|
||||||
|
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||||
|
.await?;
|
||||||
|
client_stream.flush().await?;
|
||||||
|
|
||||||
|
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||||
|
|
||||||
|
tunnel_streams(client_stream, target_stream, domain).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establish a stream to `target_host:target_port`, either directly or through
|
||||||
|
/// the configured upstream proxy. Shared by the HTTP CONNECT path and the
|
||||||
|
/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT,
|
||||||
|
/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a
|
||||||
|
/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly.
|
||||||
|
pub(crate) async fn connect_to_target_via_upstream(
|
||||||
|
target_host: &str,
|
||||||
|
target_port: u16,
|
||||||
|
upstream_url: Option<&str>,
|
||||||
|
bypass_matcher: &BypassMatcher,
|
||||||
|
) -> Result<BoxedAsyncStream, Box<dyn std::error::Error>> {
|
||||||
let should_bypass = bypass_matcher.should_bypass(target_host);
|
let should_bypass = bypass_matcher.should_bypass(target_host);
|
||||||
// Helper: configure outbound TCP to match browser TCP fingerprint
|
// Helper: configure outbound TCP to match browser TCP fingerprint
|
||||||
let configure_tcp = |stream: &TcpStream| {
|
let configure_tcp = |stream: &TcpStream| {
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
};
|
};
|
||||||
let target_stream: BoxedAsyncStream = match upstream_url.as_ref() {
|
let target_stream: BoxedAsyncStream = match upstream_url {
|
||||||
None => {
|
None => {
|
||||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||||
configure_tcp(&s);
|
configure_tcp(&s);
|
||||||
Box::new(s)
|
Box::new(s)
|
||||||
}
|
}
|
||||||
Some(url) if url == "DIRECT" => {
|
Some("DIRECT") => {
|
||||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||||
configure_tcp(&s);
|
configure_tcp(&s);
|
||||||
Box::new(s)
|
Box::new(s)
|
||||||
@@ -1632,20 +1662,18 @@ async fn handle_connect_from_buffer(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TCP_NODELAY is set per-stream where applicable (TcpStream paths).
|
Ok(target_stream)
|
||||||
// For encrypted streams (Shadowsocks), the underlying TCP connection
|
}
|
||||||
// is managed by the library and nodelay is handled internally.
|
|
||||||
|
|
||||||
// Send 200 Connection Established response to client
|
/// Bidirectionally relay `client_stream` <-> `target_stream` until either side
|
||||||
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
/// closes, counting bytes for traffic stats and attributing them to `domain`.
|
||||||
client_stream
|
/// The caller is responsible for having already sent any protocol-specific
|
||||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
/// success reply (HTTP `200` or SOCKS5 reply) before calling this.
|
||||||
.await?;
|
pub(crate) async fn tunnel_streams(
|
||||||
client_stream.flush().await?;
|
client_stream: TcpStream,
|
||||||
|
target_stream: BoxedAsyncStream,
|
||||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
domain: String,
|
||||||
|
) {
|
||||||
// Now tunnel data bidirectionally with counting
|
|
||||||
// Wrap streams to count bytes transferred
|
// Wrap streams to count bytes transferred
|
||||||
let counting_client = CountingStream::new(client_stream);
|
let counting_client = CountingStream::new(client_stream);
|
||||||
let counting_target = CountingStream::new(target_stream);
|
let counting_target = CountingStream::new(target_stream);
|
||||||
@@ -1708,8 +1736,6 @@ async fn handle_connect_from_buffer(
|
|||||||
if let Some(tracker) = get_traffic_tracker() {
|
if let Some(tracker) = get_traffic_tracker() {
|
||||||
tracker.update_domain_bytes(&domain, final_sent, final_recv);
|
tracker.update_domain_bytes(&domain, final_sent, final_recv);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ pub struct ProxyConfig {
|
|||||||
pub bypass_rules: Vec<String>,
|
pub bypass_rules: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub blocklist_file: Option<String>,
|
pub blocklist_file: Option<String>,
|
||||||
|
/// Protocol the local worker serves to the browser: "http" (default, used
|
||||||
|
/// by Camoufox/Firefox) or "socks5" (used by Wayfern/Chromium so QUIC and
|
||||||
|
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
|
||||||
|
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_protocol: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyConfig {
|
impl ProxyConfig {
|
||||||
@@ -30,6 +36,7 @@ impl ProxyConfig {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
local_protocol: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +54,20 @@ impl ProxyConfig {
|
|||||||
self.blocklist_file = blocklist_file;
|
self.blocklist_file = blocklist_file;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
|
||||||
|
self.local_protocol = local_protocol;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "socks5" or "http" (default). Lowercased for case-insensitive matching.
|
||||||
|
pub fn local_protocol_or_default(&self) -> String {
|
||||||
|
self
|
||||||
|
.local_protocol
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("http")
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_storage_dir() -> PathBuf {
|
pub fn get_storage_dir() -> PathBuf {
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
//! Local SOCKS5 server served to the browser (Wayfern/Chromium).
|
||||||
|
//!
|
||||||
|
//! The HTTP front-end (`proxy_server::handle_proxy_connection`) can only tunnel
|
||||||
|
//! TCP, so QUIC and WebRTC — which are UDP — would be forced direct and leak the
|
||||||
|
//! real IP. Serving SOCKS5 instead lets Chromium proxy UDP via SOCKS5 UDP
|
||||||
|
//! ASSOCIATE (RFC 1928). TCP CONNECT reuses the exact same upstream-dial and
|
||||||
|
//! tunnel code as the HTTP path, so every upstream type (direct, HTTP/HTTPS
|
||||||
|
//! CONNECT, SOCKS4/5, Shadowsocks) behaves identically.
|
||||||
|
//!
|
||||||
|
//! UDP ASSOCIATE is leak-safe by construction: UDP is only relayed where it
|
||||||
|
//! cannot expose the host IP — directly when there is no upstream proxy, or
|
||||||
|
//! tunneled through a UDP-capable SOCKS5 upstream. For upstreams that cannot
|
||||||
|
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
|
||||||
|
//! the association) the request is refused, so Chromium falls back to proxied
|
||||||
|
//! TCP rather than sending UDP from the real IP.
|
||||||
|
|
||||||
|
use crate::proxy_server::{
|
||||||
|
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
|
||||||
|
};
|
||||||
|
use crate::traffic_stats::get_traffic_tracker;
|
||||||
|
use async_socks5::{AddrKind, Auth, SocksDatagram};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpStream, UdpSocket};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
// SOCKS5 reply codes (RFC 1928 §6).
|
||||||
|
const REP_SUCCEEDED: u8 = 0x00;
|
||||||
|
const REP_GENERAL_FAILURE: u8 = 0x01;
|
||||||
|
const REP_NOT_ALLOWED: u8 = 0x02;
|
||||||
|
const REP_COMMAND_NOT_SUPPORTED: u8 = 0x07;
|
||||||
|
|
||||||
|
// SOCKS5 commands (RFC 1928 §4).
|
||||||
|
const CMD_CONNECT: u8 = 0x01;
|
||||||
|
const CMD_UDP_ASSOCIATE: u8 = 0x03;
|
||||||
|
|
||||||
|
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
|
||||||
|
const UDP_BUF: usize = 65_536;
|
||||||
|
|
||||||
|
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
|
||||||
|
/// IP never leaks.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum UdpMode {
|
||||||
|
/// No upstream proxy: relay UDP directly (the host IP is the profile's IP,
|
||||||
|
/// so there is nothing to hide).
|
||||||
|
Direct,
|
||||||
|
/// SOCKS5 upstream: attempt SOCKS5 UDP ASSOCIATE against it. Tunnels UDP if
|
||||||
|
/// the upstream grants it; refuses (no leak) if it does not.
|
||||||
|
Socks5Upstream,
|
||||||
|
/// Upstream that cannot carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks): refuse so
|
||||||
|
/// Chromium falls back to proxied TCP instead of leaking UDP.
|
||||||
|
Refuse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide the leak-safe UDP policy for an upstream URL.
|
||||||
|
fn udp_mode(upstream_url: Option<&str>) -> UdpMode {
|
||||||
|
match upstream_url {
|
||||||
|
None => UdpMode::Direct,
|
||||||
|
Some("DIRECT") => UdpMode::Direct,
|
||||||
|
Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) {
|
||||||
|
Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream,
|
||||||
|
// http / https / socks4 / ss / shadowsocks / anything else: TCP-only.
|
||||||
|
_ => UdpMode::Refuse,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `0.0.0.0:0` — used for BND fields in replies where the bound address is
|
||||||
|
/// irrelevant to the client (e.g. CONNECT).
|
||||||
|
fn unspecified() -> SocketAddr {
|
||||||
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle one SOCKS5 client connection from the browser. Mirrors the spawn
|
||||||
|
/// contract of `proxy_server::handle_proxy_connection`.
|
||||||
|
pub async fn handle_socks5_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
upstream_url: Option<String>,
|
||||||
|
bypass_matcher: BypassMatcher,
|
||||||
|
blocklist_matcher: BlocklistMatcher,
|
||||||
|
) {
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
|
||||||
|
if let Err(e) = negotiate_method(&mut stream).await {
|
||||||
|
log::debug!("SOCKS5 method negotiation failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = match read_request(&mut stream).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("SOCKS5 request parse failed: {e}");
|
||||||
|
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.cmd {
|
||||||
|
CMD_CONNECT => {
|
||||||
|
handle_connect(
|
||||||
|
stream,
|
||||||
|
request.host,
|
||||||
|
request.port,
|
||||||
|
upstream_url,
|
||||||
|
bypass_matcher,
|
||||||
|
blocklist_matcher,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
CMD_UDP_ASSOCIATE => {
|
||||||
|
handle_udp_associate(stream, upstream_url).await;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
log::debug!("SOCKS5 unsupported command {other:#04x}");
|
||||||
|
let _ = send_reply(&mut stream, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the SOCKS5 greeting and select the no-auth method. The local proxy is
|
||||||
|
/// loopback-only, so no authentication is required (Chromium offers no-auth).
|
||||||
|
async fn negotiate_method(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||||
|
let mut head = [0u8; 2];
|
||||||
|
stream.read_exact(&mut head).await?;
|
||||||
|
if head[0] != 0x05 {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"not a SOCKS5 greeting",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let nmethods = head[1] as usize;
|
||||||
|
let mut methods = vec![0u8; nmethods];
|
||||||
|
stream.read_exact(&mut methods).await?;
|
||||||
|
|
||||||
|
if methods.contains(&0x00) {
|
||||||
|
stream.write_all(&[0x05, 0x00]).await?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// No acceptable methods.
|
||||||
|
let _ = stream.write_all(&[0x05, 0xFF]).await;
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"no no-auth method offered",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Socks5Request {
|
||||||
|
cmd: u8,
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a SOCKS5 request line: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT.
|
||||||
|
async fn read_request(stream: &mut TcpStream) -> std::io::Result<Socks5Request> {
|
||||||
|
let mut head = [0u8; 4];
|
||||||
|
stream.read_exact(&mut head).await?;
|
||||||
|
if head[0] != 0x05 {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"bad SOCKS5 request version",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cmd = head[1];
|
||||||
|
let atyp = head[3];
|
||||||
|
let host = read_addr(stream, atyp).await?;
|
||||||
|
let mut port = [0u8; 2];
|
||||||
|
stream.read_exact(&mut port).await?;
|
||||||
|
Ok(Socks5Request {
|
||||||
|
cmd,
|
||||||
|
host,
|
||||||
|
port: u16::from_be_bytes(port),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a SOCKS5 address of the given type into a host string (an IP literal or
|
||||||
|
/// a domain name; `connect_to_target_via_upstream` handles both).
|
||||||
|
async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result<String> {
|
||||||
|
match atyp {
|
||||||
|
0x01 => {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
stream.read_exact(&mut b).await?;
|
||||||
|
Ok(Ipv4Addr::new(b[0], b[1], b[2], b[3]).to_string())
|
||||||
|
}
|
||||||
|
0x04 => {
|
||||||
|
let mut b = [0u8; 16];
|
||||||
|
stream.read_exact(&mut b).await?;
|
||||||
|
Ok(Ipv6Addr::from(b).to_string())
|
||||||
|
}
|
||||||
|
0x03 => {
|
||||||
|
let mut len = [0u8; 1];
|
||||||
|
stream.read_exact(&mut len).await?;
|
||||||
|
let mut domain = vec![0u8; len[0] as usize];
|
||||||
|
stream.read_exact(&mut domain).await?;
|
||||||
|
Ok(String::from_utf8_lossy(&domain).to_string())
|
||||||
|
}
|
||||||
|
other => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("unsupported SOCKS5 address type {other:#04x}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a SOCKS5 reply with the given code and bound address.
|
||||||
|
async fn send_reply(stream: &mut TcpStream, rep: u8, bnd: SocketAddr) -> std::io::Result<()> {
|
||||||
|
let mut resp = vec![0x05, rep, 0x00];
|
||||||
|
push_addr(&mut resp, bnd);
|
||||||
|
stream.write_all(&resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an ATYP + address + port to a SOCKS5 message buffer.
|
||||||
|
fn push_addr(buf: &mut Vec<u8>, addr: SocketAddr) {
|
||||||
|
match addr.ip() {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
buf.push(0x01);
|
||||||
|
buf.extend_from_slice(&v4.octets());
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
buf.push(0x04);
|
||||||
|
buf.extend_from_slice(&v6.octets());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&addr.port().to_be_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SOCKS5 CONNECT: dial the target via the upstream and bidirectionally tunnel,
|
||||||
|
/// reusing the same code path as the HTTP CONNECT proxy.
|
||||||
|
async fn handle_connect(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
upstream_url: Option<String>,
|
||||||
|
bypass_matcher: BypassMatcher,
|
||||||
|
blocklist_matcher: BlocklistMatcher,
|
||||||
|
) {
|
||||||
|
if blocklist_matcher.is_blocked(&host) {
|
||||||
|
log::debug!("[blocklist] Blocked SOCKS5 CONNECT to {host}");
|
||||||
|
let _ = send_reply(&mut stream, REP_NOT_ALLOWED, unspecified()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tracker) = get_traffic_tracker() {
|
||||||
|
tracker.record_request(&host, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"SOCKS5 CONNECT {}:{} (upstream={})",
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve to the target stream, logging and dropping the (non-Send) dial
|
||||||
|
// error inside the match arm so it is never held across the await below.
|
||||||
|
let target =
|
||||||
|
match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(t) => Some(t),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(target) = target else {
|
||||||
|
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if send_reply(&mut stream, REP_SUCCEEDED, unspecified())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tunnel_streams(stream, target, host).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SOCKS5 UDP ASSOCIATE, leak-safe per upstream (see [`UdpMode`]).
|
||||||
|
///
|
||||||
|
/// `control` is the TCP control connection; the UDP association lives exactly
|
||||||
|
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
|
||||||
|
/// the browser closes it.
|
||||||
|
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) {
|
||||||
|
let mode = udp_mode(upstream_url.as_deref());
|
||||||
|
|
||||||
|
if mode == UdpMode::Refuse {
|
||||||
|
log::info!(
|
||||||
|
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
|
||||||
|
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||||
|
);
|
||||||
|
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The UDP relay socket the browser sends its datagrams to. Loopback-only.
|
||||||
|
let relay = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to bind UDP relay socket: {e}");
|
||||||
|
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let relay_addr = match relay.local_addr() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to read UDP relay addr: {e}");
|
||||||
|
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
UdpMode::Direct => {
|
||||||
|
// Bind the egress socket before replying so a failure surfaces as a
|
||||||
|
// refusal (no half-open association).
|
||||||
|
let out = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to bind UDP egress socket: {e}");
|
||||||
|
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
|
||||||
|
run_udp_relay_direct(control, relay, out).await;
|
||||||
|
}
|
||||||
|
UdpMode::Socks5Upstream => {
|
||||||
|
// Establish the upstream association FIRST; if the upstream refuses UDP,
|
||||||
|
// refuse to the browser too (no leak).
|
||||||
|
let upstream = upstream_url.as_deref().unwrap_or("");
|
||||||
|
let datagram = match associate_upstream(upstream).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::info!(
|
||||||
|
"SOCKS5 upstream did not grant UDP ASSOCIATE ({e}); refusing so Chromium uses proxied TCP"
|
||||||
|
);
|
||||||
|
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
|
||||||
|
run_udp_relay_socks5(control, relay, datagram).await;
|
||||||
|
}
|
||||||
|
UdpMode::Refuse => unreachable!("handled above"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a SOCKS5 UDP association against the upstream proxy.
|
||||||
|
async fn associate_upstream(
|
||||||
|
upstream_url: &str,
|
||||||
|
) -> Result<SocksDatagram<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let upstream = Url::parse(upstream_url)?;
|
||||||
|
let host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||||
|
let port = upstream.port().unwrap_or(1080);
|
||||||
|
let auth = if !upstream.username().is_empty() {
|
||||||
|
Some(Auth {
|
||||||
|
username: upstream.username().to_string(),
|
||||||
|
password: upstream.password().unwrap_or("").to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let proxy_stream = TcpStream::connect((host, port)).await?;
|
||||||
|
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
|
||||||
|
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
|
||||||
|
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?;
|
||||||
|
Ok(datagram)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed SOCKS5 UDP datagram header (RFC 1928 §7): the destination and the
|
||||||
|
/// offset at which the payload begins. Fragmented datagrams (FRAG != 0) are
|
||||||
|
/// rejected by the caller.
|
||||||
|
struct UdpHeader {
|
||||||
|
frag: u8,
|
||||||
|
dst: AddrKind,
|
||||||
|
data_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_udp_header(buf: &[u8]) -> Option<UdpHeader> {
|
||||||
|
if buf.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let frag = buf[2];
|
||||||
|
let atyp = buf[3];
|
||||||
|
match atyp {
|
||||||
|
0x01 => {
|
||||||
|
if buf.len() < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ip = Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||||
|
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||||
|
Some(UdpHeader {
|
||||||
|
frag,
|
||||||
|
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V4(ip), port)),
|
||||||
|
data_offset: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
0x04 => {
|
||||||
|
if buf.len() < 22 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut octets = [0u8; 16];
|
||||||
|
octets.copy_from_slice(&buf[4..20]);
|
||||||
|
let ip = Ipv6Addr::from(octets);
|
||||||
|
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||||
|
Some(UdpHeader {
|
||||||
|
frag,
|
||||||
|
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V6(ip), port)),
|
||||||
|
data_offset: 22,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
0x03 => {
|
||||||
|
let dlen = *buf.get(4)? as usize;
|
||||||
|
let needed = 5 + dlen + 2;
|
||||||
|
if buf.len() < needed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let domain = String::from_utf8_lossy(&buf[5..5 + dlen]).to_string();
|
||||||
|
let port = u16::from_be_bytes([buf[5 + dlen], buf[6 + dlen]]);
|
||||||
|
Some(UdpHeader {
|
||||||
|
frag,
|
||||||
|
dst: AddrKind::Domain(domain, port),
|
||||||
|
data_offset: needed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a SOCKS5 UDP response datagram (header + payload) to send back to the
|
||||||
|
/// browser, naming `peer` as the source.
|
||||||
|
fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||||
|
push_addr(&mut out, peer);
|
||||||
|
out.extend_from_slice(data);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
|
||||||
|
/// there is no upstream proxy, so the host IP is the profile's own IP.
|
||||||
|
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) {
|
||||||
|
let mut client_addr: Option<SocketAddr> = None;
|
||||||
|
let mut from_client = vec![0u8; UDP_BUF];
|
||||||
|
let mut from_target = vec![0u8; UDP_BUF];
|
||||||
|
let mut ctrl_buf = [0u8; 256];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Control connection closed => association ends.
|
||||||
|
r = control.read(&mut ctrl_buf) => {
|
||||||
|
match r {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(_) => {} // ignore any data on the control channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Browser -> target.
|
||||||
|
r = relay.recv_from(&mut from_client) => {
|
||||||
|
let Ok((n, src)) = r else { break };
|
||||||
|
client_addr = Some(src);
|
||||||
|
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||||
|
if header.frag != 0 {
|
||||||
|
continue; // fragmentation unsupported
|
||||||
|
}
|
||||||
|
let payload = &from_client[header.data_offset..n];
|
||||||
|
let dst = match resolve_addr(&header.dst).await {
|
||||||
|
Some(d) => d,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let _ = out.send_to(payload, dst).await;
|
||||||
|
}
|
||||||
|
// Target -> browser.
|
||||||
|
r = out.recv_from(&mut from_target) => {
|
||||||
|
let Ok((n, peer)) = r else { continue };
|
||||||
|
if let Some(client) = client_addr {
|
||||||
|
let resp = build_udp_response(peer, &from_target[..n]);
|
||||||
|
let _ = relay.send_to(&resp, client).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
|
||||||
|
async fn run_udp_relay_socks5(
|
||||||
|
mut control: TcpStream,
|
||||||
|
relay: UdpSocket,
|
||||||
|
datagram: SocksDatagram<TcpStream>,
|
||||||
|
) {
|
||||||
|
let mut client_addr: Option<SocketAddr> = None;
|
||||||
|
let mut from_client = vec![0u8; UDP_BUF];
|
||||||
|
let mut from_upstream = vec![0u8; UDP_BUF];
|
||||||
|
let mut ctrl_buf = [0u8; 256];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
r = control.read(&mut ctrl_buf) => {
|
||||||
|
match r {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Browser -> upstream.
|
||||||
|
r = relay.recv_from(&mut from_client) => {
|
||||||
|
let Ok((n, src)) = r else { break };
|
||||||
|
client_addr = Some(src);
|
||||||
|
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||||
|
if header.frag != 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let payload = from_client[header.data_offset..n].to_vec();
|
||||||
|
let _ = datagram.send_to(&payload, header.dst).await;
|
||||||
|
}
|
||||||
|
// Upstream -> browser.
|
||||||
|
r = datagram.recv_from(&mut from_upstream) => {
|
||||||
|
let Ok((n, peer)) = r else { continue };
|
||||||
|
if let Some(client) = client_addr {
|
||||||
|
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
|
||||||
|
let _ = relay.send_to(&resp, client).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a UDP destination to a concrete socket address for direct relay.
|
||||||
|
async fn resolve_addr(addr: &AddrKind) -> Option<SocketAddr> {
|
||||||
|
match addr {
|
||||||
|
AddrKind::Ip(s) => Some(*s),
|
||||||
|
AddrKind::Domain(domain, port) => tokio::net::lookup_host(format!("{domain}:{port}"))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut it| it.next()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort conversion of an upstream-reported source address into a
|
||||||
|
/// `SocketAddr` for the response header. A domain (rare for UDP) collapses to
|
||||||
|
/// `0.0.0.0:port`, which clients treat as "from the proxy".
|
||||||
|
fn addrkind_to_socketaddr(addr: &AddrKind) -> SocketAddr {
|
||||||
|
match addr {
|
||||||
|
AddrKind::Ip(s) => *s,
|
||||||
|
AddrKind::Domain(_, port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), *port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_mode_direct_for_none_and_direct() {
|
||||||
|
assert_eq!(udp_mode(None), UdpMode::Direct);
|
||||||
|
assert_eq!(udp_mode(Some("DIRECT")), UdpMode::Direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_mode_socks5_upstream() {
|
||||||
|
assert_eq!(
|
||||||
|
udp_mode(Some("socks5://user:pass@1.2.3.4:1080")),
|
||||||
|
UdpMode::Socks5Upstream
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
udp_mode(Some("socks5://1.2.3.4:1080")),
|
||||||
|
UdpMode::Socks5Upstream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_mode_refuses_tcp_only_upstreams() {
|
||||||
|
// HTTP/HTTPS CONNECT, SOCKS4, and Shadowsocks cannot carry UDP, so UDP
|
||||||
|
// ASSOCIATE must be refused (Chromium then uses proxied TCP — no leak).
|
||||||
|
assert_eq!(udp_mode(Some("http://1.2.3.4:8080")), UdpMode::Refuse);
|
||||||
|
assert_eq!(udp_mode(Some("https://1.2.3.4:8080")), UdpMode::Refuse);
|
||||||
|
assert_eq!(udp_mode(Some("socks4://1.2.3.4:1080")), UdpMode::Refuse);
|
||||||
|
assert_eq!(
|
||||||
|
udp_mode(Some("ss://aes-256-gcm:pw@1.2.3.4:8388")),
|
||||||
|
UdpMode::Refuse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_udp_header_ipv4() {
|
||||||
|
// RSV RSV FRAG ATYP=1 1.2.3.4 :443 payload="hi"
|
||||||
|
let buf = [0, 0, 0, 0x01, 1, 2, 3, 4, 0x01, 0xBB, b'h', b'i'];
|
||||||
|
let h = parse_udp_header(&buf).expect("ipv4 header");
|
||||||
|
assert_eq!(h.frag, 0);
|
||||||
|
assert_eq!(h.data_offset, 10);
|
||||||
|
assert_eq!(
|
||||||
|
h.dst,
|
||||||
|
AddrKind::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443))
|
||||||
|
);
|
||||||
|
assert_eq!(&buf[h.data_offset..], b"hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_udp_header_domain() {
|
||||||
|
// ATYP=3, len=3, "abc", port 8080, payload "x"
|
||||||
|
let mut buf = vec![0, 0, 0, 0x03, 3, b'a', b'b', b'c', 0x1F, 0x90];
|
||||||
|
buf.push(b'x');
|
||||||
|
let h = parse_udp_header(&buf).expect("domain header");
|
||||||
|
assert_eq!(h.dst, AddrKind::Domain("abc".to_string(), 8080));
|
||||||
|
assert_eq!(&buf[h.data_offset..], b"x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_udp_header_rejects_truncated() {
|
||||||
|
assert!(parse_udp_header(&[0, 0, 0]).is_none());
|
||||||
|
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_udp_response_prefixes_header() {
|
||||||
|
let resp = build_udp_response(
|
||||||
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
|
||||||
|
b"data",
|
||||||
|
);
|
||||||
|
// RSV RSV FRAG ATYP=1 9.9.9.9 :53 "data"
|
||||||
|
assert_eq!(
|
||||||
|
resp,
|
||||||
|
vec![0, 0, 0, 0x01, 9, 9, 9, 9, 0x00, 0x35, b'd', b'a', b't', b'a']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -294,7 +294,10 @@ impl SyncProgressTracker {
|
|||||||
|
|
||||||
/// Check if sync is configured (cloud or self-hosted)
|
/// Check if sync is configured (cloud or self-hosted)
|
||||||
pub fn is_sync_configured() -> bool {
|
pub fn is_sync_configured() -> bool {
|
||||||
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
|
// Cloud backup is a plan capability. Every paid plan (incl. the future
|
||||||
|
// "starter" tier) grants it, but gating on the capability — not just "is paid"
|
||||||
|
// — keeps this correct if a plan without cloud backup is ever added.
|
||||||
|
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let manager = SettingsManager::instance();
|
let manager = SettingsManager::instance();
|
||||||
@@ -1597,6 +1600,13 @@ impl SyncEngine {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Keep the in-memory cache in sync with disk. Without this, get_stored_proxies
|
||||||
|
// (which reads only the in-memory map) never sees the downloaded proxy until
|
||||||
|
// restart, so check_for_missing_synced_entities/sync_proxy treat it as
|
||||||
|
// missing every pass and re-download it forever. Mirrors download_group/
|
||||||
|
// download_vpn/download_extension.
|
||||||
|
proxy_manager.upsert_stored_proxy(proxy.clone());
|
||||||
|
|
||||||
// Emit event for UI update
|
// Emit event for UI update
|
||||||
if let Some(_handle) = app_handle {
|
if let Some(_handle) = app_handle {
|
||||||
let _ = events::emit("stored-proxies-changed", ());
|
let _ = events::emit("stored-proxies-changed", ());
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use boringtun::x25519::{PublicKey, StaticSecret};
|
|||||||
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
|
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
|
||||||
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
|
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
|
||||||
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
|
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
|
||||||
|
use smoltcp::socket::udp;
|
||||||
use smoltcp::time::Instant as SmolInstant;
|
use smoltcp::time::Instant as SmolInstant;
|
||||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
|
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -13,6 +14,58 @@ use tokio::net::{TcpListener, TcpStream};
|
|||||||
|
|
||||||
const SMOLTCP_TCP_RX_BUF: usize = 65536;
|
const SMOLTCP_TCP_RX_BUF: usize = 65536;
|
||||||
const SMOLTCP_TCP_TX_BUF: usize = 65536;
|
const SMOLTCP_TCP_TX_BUF: usize = 65536;
|
||||||
|
const SMOLTCP_UDP_BUF: usize = 65536;
|
||||||
|
|
||||||
|
/// Parse an RFC 1928 §7 UDP request header. Returns the destination endpoint
|
||||||
|
/// and the payload offset, or None if malformed, fragmented, or domain-typed.
|
||||||
|
/// Only literal IPs are routed through the tunnel: resolving a domain on the
|
||||||
|
/// host would leak DNS, and QUIC/WebRTC datagrams always carry literal IPs.
|
||||||
|
fn parse_udp_datagram(buf: &[u8]) -> Option<(IpEndpoint, usize)> {
|
||||||
|
if buf.len() < 4 || buf[2] != 0 {
|
||||||
|
// too short, or FRAG != 0 (fragmentation unsupported)
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match buf[3] {
|
||||||
|
0x01 => {
|
||||||
|
if buf.len() < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ip = Ipv4Address::new(buf[4], buf[5], buf[6], buf[7]);
|
||||||
|
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||||
|
Some((IpEndpoint::new(IpAddress::Ipv4(ip), port), 10))
|
||||||
|
}
|
||||||
|
0x04 => {
|
||||||
|
if buf.len() < 22 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut o = [0u8; 16];
|
||||||
|
o.copy_from_slice(&buf[4..20]);
|
||||||
|
let ip = smoltcp::wire::Ipv6Address::from(o);
|
||||||
|
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||||
|
Some((IpEndpoint::new(IpAddress::Ipv6(ip), port), 22))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap a tunnel-received datagram in an RFC 1928 §7 UDP reply header naming
|
||||||
|
/// `src` as the origin, for delivery back to the browser's relay socket.
|
||||||
|
fn build_udp_datagram(src: IpEndpoint, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||||
|
match src.addr {
|
||||||
|
IpAddress::Ipv4(v4) => {
|
||||||
|
out.push(0x01);
|
||||||
|
out.extend_from_slice(&v4.octets());
|
||||||
|
}
|
||||||
|
IpAddress::Ipv6(v6) => {
|
||||||
|
out.push(0x04);
|
||||||
|
out.extend_from_slice(&v6.octets());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&src.port.to_be_bytes());
|
||||||
|
out.extend_from_slice(payload);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
struct WgDevice {
|
struct WgDevice {
|
||||||
tunn: Arc<Mutex<Box<Tunn>>>,
|
tunn: Arc<Mutex<Box<Tunn>>>,
|
||||||
@@ -432,6 +485,15 @@ impl WireGuardSocks5Server {
|
|||||||
|
|
||||||
let mut sockets = SocketSet::new(vec![]);
|
let mut sockets = SocketSet::new(vec![]);
|
||||||
|
|
||||||
|
// A live SOCKS5 UDP ASSOCIATE: the loopback relay socket the browser sends
|
||||||
|
// datagrams to, and the browser's learned source address. The tunnel-side
|
||||||
|
// smoltcp UDP socket lives in `sockets`, keyed by the connection's
|
||||||
|
// (repurposed) `smol_handle`.
|
||||||
|
struct UdpAssoc {
|
||||||
|
relay: UdpSocket,
|
||||||
|
client_addr: Option<SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
struct Connection {
|
struct Connection {
|
||||||
smol_handle: SocketHandle,
|
smol_handle: SocketHandle,
|
||||||
tcp_stream: TcpStream,
|
tcp_stream: TcpStream,
|
||||||
@@ -440,6 +502,7 @@ impl WireGuardSocks5Server {
|
|||||||
greeting_done: bool,
|
greeting_done: bool,
|
||||||
read_buf: Vec<u8>,
|
read_buf: Vec<u8>,
|
||||||
dest_addr: Option<SocketAddr>,
|
dest_addr: Option<SocketAddr>,
|
||||||
|
udp: Option<UdpAssoc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut connections: Vec<Connection> = Vec::new();
|
let mut connections: Vec<Connection> = Vec::new();
|
||||||
@@ -463,6 +526,7 @@ impl WireGuardSocks5Server {
|
|||||||
greeting_done: false,
|
greeting_done: false,
|
||||||
read_buf: Vec::new(),
|
read_buf: Vec::new(),
|
||||||
dest_addr: None,
|
dest_addr: None,
|
||||||
|
udp: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,8 +604,17 @@ impl WireGuardSocks5Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
||||||
// SOCKS5 connect request
|
// SOCKS5 request: CONNECT (0x01) or UDP ASSOCIATE (0x03)
|
||||||
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
|
if conn.read_buf[0] != 0x05 {
|
||||||
|
completed.push(idx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cmd = conn.read_buf[1];
|
||||||
|
if cmd != 0x01 && cmd != 0x03 {
|
||||||
|
// command not supported
|
||||||
|
let _ = conn
|
||||||
|
.tcp_stream
|
||||||
|
.try_write(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||||
completed.push(idx);
|
completed.push(idx);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -613,6 +686,75 @@ impl WireGuardSocks5Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
conn.read_buf.drain(..addr_len);
|
conn.read_buf.drain(..addr_len);
|
||||||
|
|
||||||
|
if cmd == 0x03 {
|
||||||
|
// === SOCKS5 UDP ASSOCIATE ===
|
||||||
|
// The request's DST is the client's intended source (typically
|
||||||
|
// 0.0.0.0:0) and is ignored — the browser's relay source is
|
||||||
|
// learned from its first datagram. Bind a loopback relay socket
|
||||||
|
// the browser sends to, plus a smoltcp UDP socket that egresses
|
||||||
|
// through the WireGuard tunnel on the interface IP.
|
||||||
|
let relay = match UdpSocket::bind("127.0.0.1:0") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = conn
|
||||||
|
.tcp_stream
|
||||||
|
.try_write(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||||
|
completed.push(idx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = relay.set_nonblocking(true);
|
||||||
|
let relay_port = relay.local_addr().map(|a| a.port()).unwrap_or(0);
|
||||||
|
|
||||||
|
// Reply with the relay endpoint (127.0.0.1:relay_port).
|
||||||
|
if conn
|
||||||
|
.tcp_stream
|
||||||
|
.try_write(&[
|
||||||
|
0x05,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
127,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
(relay_port >> 8) as u8,
|
||||||
|
(relay_port & 0xff) as u8,
|
||||||
|
])
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
completed.push(idx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let udp_rx = udp::PacketBuffer::new(
|
||||||
|
vec![udp::PacketMetadata::EMPTY; 32],
|
||||||
|
vec![0u8; SMOLTCP_UDP_BUF],
|
||||||
|
);
|
||||||
|
let udp_tx = udp::PacketBuffer::new(
|
||||||
|
vec![udp::PacketMetadata::EMPTY; 32],
|
||||||
|
vec![0u8; SMOLTCP_UDP_BUF],
|
||||||
|
);
|
||||||
|
let mut udp_socket = udp::Socket::new(udp_rx, udp_tx);
|
||||||
|
let local_port = 20000 + (rand::random::<u16>() % 40000);
|
||||||
|
if udp_socket.bind(local_port).is_err() {
|
||||||
|
completed.push(idx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap this connection's unused TCP socket for the UDP socket;
|
||||||
|
// `smol_handle` now keys the UDP socket, so teardown is unchanged.
|
||||||
|
sockets.remove(conn.smol_handle);
|
||||||
|
conn.smol_handle = sockets.add(udp_socket);
|
||||||
|
conn.udp = Some(UdpAssoc {
|
||||||
|
relay,
|
||||||
|
client_addr: None,
|
||||||
|
});
|
||||||
|
conn.socks_done = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
conn.dest_addr = Some(addr);
|
conn.dest_addr = Some(addr);
|
||||||
|
|
||||||
// Open smoltcp TCP socket to the destination
|
// Open smoltcp TCP socket to the destination
|
||||||
@@ -641,6 +783,62 @@ impl WireGuardSocks5Server {
|
|||||||
|
|
||||||
conn.connecting = true;
|
conn.connecting = true;
|
||||||
}
|
}
|
||||||
|
} else if conn.udp.is_some() {
|
||||||
|
// === UDP ASSOCIATE relay ===
|
||||||
|
// The association lives only while the TCP control connection is
|
||||||
|
// open (RFC 1928 §6); tear down when the browser closes it.
|
||||||
|
let mut probe = [0u8; 1];
|
||||||
|
match conn.tcp_stream.try_read(&mut probe) {
|
||||||
|
Ok(0) => {
|
||||||
|
completed.push(idx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(_) => {} // ignore any data on the control channel
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||||
|
Err(_) => {
|
||||||
|
completed.push(idx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = conn.smol_handle;
|
||||||
|
let Some(udp) = conn.udp.as_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Browser → tunnel: strip the §7 header and forward the payload.
|
||||||
|
let mut dbuf = [0u8; SMOLTCP_UDP_BUF];
|
||||||
|
loop {
|
||||||
|
match udp.relay.recv_from(&mut dbuf) {
|
||||||
|
Ok((n, src)) => {
|
||||||
|
udp.client_addr = Some(src);
|
||||||
|
if let Some((dst, off)) = parse_udp_datagram(&dbuf[..n]) {
|
||||||
|
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||||
|
if socket.can_send() {
|
||||||
|
let _ = socket.send_slice(&dbuf[off..n], dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunnel → browser: wrap each datagram in a §7 header and relay back.
|
||||||
|
loop {
|
||||||
|
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||||
|
if !socket.can_recv() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (payload, src) = match socket.recv() {
|
||||||
|
Ok((data, meta)) => (data.to_vec(), meta.endpoint),
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
if let Some(client) = udp.client_addr {
|
||||||
|
let resp = build_udp_datagram(src, &payload);
|
||||||
|
let _ = udp.relay.send_to(&resp, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Data relay between SOCKS5 client and smoltcp socket
|
// Data relay between SOCKS5 client and smoltcp socket
|
||||||
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
||||||
|
|||||||
@@ -651,7 +651,12 @@ impl WayfernManager {
|
|||||||
"--disable-session-crashed-bubble".to_string(),
|
"--disable-session-crashed-bubble".to_string(),
|
||||||
"--hide-crash-restore-bubble".to_string(),
|
"--hide-crash-restore-bubble".to_string(),
|
||||||
"--disable-infobars".to_string(),
|
"--disable-infobars".to_string(),
|
||||||
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
|
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
|
||||||
|
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
|
||||||
|
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
|
||||||
|
// re-enabled by an imported/synced network_prediction_options pref (which a
|
||||||
|
// compile-time pref default could be).
|
||||||
|
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
|
||||||
"--use-mock-keychain".to_string(),
|
"--use-mock-keychain".to_string(),
|
||||||
"--password-store=basic".to_string(),
|
"--password-store=basic".to_string(),
|
||||||
];
|
];
|
||||||
@@ -723,9 +728,21 @@ impl WayfernManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(proxy) = proxy_url {
|
if let Some(proxy) = proxy_url {
|
||||||
|
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
|
||||||
|
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
|
||||||
|
// PROXY is HTTP CONNECT (TCP only). The host:port is the same either way.
|
||||||
|
let (pac_directive, host_port) = if let Some(rest) = proxy.strip_prefix("socks5://") {
|
||||||
|
("SOCKS5", rest)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"PROXY",
|
||||||
|
proxy
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.trim_start_matches("https://"),
|
||||||
|
)
|
||||||
|
};
|
||||||
let pac_data = format!(
|
let pac_data = format!(
|
||||||
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}",
|
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"{pac_directive} {host_port}\";}}",
|
||||||
proxy.trim_start_matches("http://").trim_start_matches("https://")
|
|
||||||
);
|
);
|
||||||
args.push(format!("--proxy-pac-url={pac_data}"));
|
args.push(format!("--proxy-pac-url={pac_data}"));
|
||||||
args.push("--dns-prefetch-disable".to_string());
|
args.push("--dns-prefetch-disable".to_string());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Donut",
|
"productName": "Donut",
|
||||||
"version": "0.25.3",
|
"version": "0.27.0",
|
||||||
"identifier": "com.donutbrowser",
|
"identifier": "com.donutbrowser",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||||
|
|||||||
+24
-9
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AccountPage } from "@/components/account-page";
|
import { AccountPage } from "@/components/account-page";
|
||||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||||
|
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
|
||||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
@@ -59,6 +60,7 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
|
|||||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
|
import { getEntitlements } from "@/lib/entitlements";
|
||||||
import {
|
import {
|
||||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||||
setOnboardingActive,
|
setOnboardingActive,
|
||||||
@@ -225,10 +227,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Cloud auth for cross-OS unlock
|
// Cloud auth for cross-OS unlock
|
||||||
const { user: cloudUser } = useCloudAuth();
|
const { user: cloudUser } = useCloudAuth();
|
||||||
const crossOsUnlocked =
|
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||||
cloudUser?.plan !== "free" &&
|
|
||||||
(cloudUser?.subscriptionStatus === "active" ||
|
|
||||||
cloudUser?.planPeriod === "lifetime");
|
|
||||||
|
|
||||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -1168,11 +1167,14 @@ export default function Home() {
|
|||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
syncMode: enabling ? "Regular" : "Disabled",
|
syncMode: enabling ? "Regular" : "Disabled",
|
||||||
});
|
});
|
||||||
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
|
showSuccessToast(
|
||||||
description: enabling
|
t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
|
||||||
? "Profile sync has been enabled"
|
{
|
||||||
: "Profile sync has been disabled",
|
description: t(
|
||||||
});
|
enabling ? "sync.enabledDescription" : "sync.disabledDescription",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle sync:", error);
|
console.error("Failed to toggle sync:", error);
|
||||||
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
||||||
@@ -1325,6 +1327,7 @@ export default function Home() {
|
|||||||
let unlistenStarted: (() => void) | undefined;
|
let unlistenStarted: (() => void) | undefined;
|
||||||
let unlistenProgress: (() => void) | undefined;
|
let unlistenProgress: (() => void) | undefined;
|
||||||
let unlistenCompleted: (() => void) | undefined;
|
let unlistenCompleted: (() => void) | undefined;
|
||||||
|
let unlistenWayfernBlocked: (() => void) | undefined;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
unlistenRequired = await listen(
|
unlistenRequired = await listen(
|
||||||
@@ -1386,6 +1389,16 @@ export default function Home() {
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => {
|
||||||
|
showToast({
|
||||||
|
id: "wayfern-paid-blocked",
|
||||||
|
type: "error",
|
||||||
|
title: t("wayfernBlocked.title"),
|
||||||
|
description: t("wayfernBlocked.description"),
|
||||||
|
duration: 15000,
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1393,6 +1406,7 @@ export default function Home() {
|
|||||||
unlistenStarted?.();
|
unlistenStarted?.();
|
||||||
unlistenProgress?.();
|
unlistenProgress?.();
|
||||||
unlistenCompleted?.();
|
unlistenCompleted?.();
|
||||||
|
unlistenWayfernBlocked?.();
|
||||||
};
|
};
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
@@ -1512,6 +1526,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
||||||
<CloseConfirmDialog />
|
<CloseConfirmDialog />
|
||||||
|
<CamoufoxDeprecationDialog profiles={profiles} />
|
||||||
<HomeHeader
|
<HomeHeader
|
||||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
|
import { getEntitlements } from "@/lib/entitlements";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { SyncSettings } from "@/types";
|
import type { SyncSettings } from "@/types";
|
||||||
|
|
||||||
interface AccountPageProps {
|
interface AccountPageProps {
|
||||||
@@ -196,8 +198,13 @@ export function AccountPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||||
<DialogContent className="max-w-2xl flex flex-col">
|
<DialogContent className="max-w-2xl max-h-[calc(100vh-4rem)] flex flex-col">
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 p-4 overflow-y-auto flex-1 min-h-0",
|
||||||
|
subPage && "w-full max-w-2xl mx-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<AnimatedTabs defaultValue="account">
|
<AnimatedTabs defaultValue="account">
|
||||||
<AnimatedTabsList>
|
<AnimatedTabsList>
|
||||||
<AnimatedTabsTrigger value="account">
|
<AnimatedTabsTrigger value="account">
|
||||||
@@ -298,7 +305,7 @@ export function AccountPage({
|
|||||||
|
|
||||||
{isLoggedIn &&
|
{isLoggedIn &&
|
||||||
user &&
|
user &&
|
||||||
user.plan !== "free" &&
|
getEntitlements(user).browserAutomation &&
|
||||||
user.isPrimaryDevice === false && (
|
user.isPrimaryDevice === false && (
|
||||||
<p className="text-xs text-warning">
|
<p className="text-xs text-warning">
|
||||||
{t("account.automationPrimaryOnly")}
|
{t("account.automationPrimaryOnly")}
|
||||||
@@ -306,7 +313,7 @@ export function AccountPage({
|
|||||||
)}
|
)}
|
||||||
{isLoggedIn &&
|
{isLoggedIn &&
|
||||||
user &&
|
user &&
|
||||||
user.plan !== "free" &&
|
getEntitlements(user).browserAutomation &&
|
||||||
user.isPrimaryDevice === true &&
|
user.isPrimaryDevice === true &&
|
||||||
(user.deviceCount ?? 1) > 1 && (
|
(user.deviceCount ?? 1) > 1 && (
|
||||||
<p className="text-xs text-success">
|
<p className="text-xs text-success">
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ export function BandwidthMiniChart({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent",
|
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors w-full min-w-0 border-none bg-transparent",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 h-3 pointer-events-none">
|
<div className="flex-1 min-w-0 h-3 pointer-events-none">
|
||||||
<ResponsiveContainer
|
<ResponsiveContainer
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
@@ -111,7 +111,7 @@ export function BandwidthMiniChart({
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap min-w-[60px] text-right">
|
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0 min-w-[60px] text-right">
|
||||||
{formatBytes(currentBandwidth)}
|
{formatBytes(currentBandwidth)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function CamoufoxConfigDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-3xl h-[min(85vh,52rem)] flex flex-col">
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isRunning
|
{isRunning
|
||||||
@@ -164,7 +164,7 @@ export function CamoufoxConfigDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 h-[300px]">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
{profile.browser === "wayfern" ? (
|
{profile.browser === "wayfern" ? (
|
||||||
<WayfernConfigForm
|
<WayfernConfigForm
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LuTriangleAlert } from "react-icons/lu";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { BrowserProfile } from "@/types";
|
||||||
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
|
interface CamoufoxDeprecationDialogProps {
|
||||||
|
profiles: BrowserProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns users who still have Camoufox profiles that Camoufox support is ending.
|
||||||
|
* Shown once per app session (this component mounts for the app lifetime), only
|
||||||
|
* when at least one Camoufox profile exists. Not a toast — a blocking dialog so
|
||||||
|
* the deprecation can't be missed.
|
||||||
|
*/
|
||||||
|
export function CamoufoxDeprecationDialog({
|
||||||
|
profiles,
|
||||||
|
}: CamoufoxDeprecationDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shown) return;
|
||||||
|
const hasCamoufox = profiles.some((p) => p.browser === "camoufox");
|
||||||
|
if (hasCamoufox) {
|
||||||
|
setIsOpen(true);
|
||||||
|
setShown(true);
|
||||||
|
}
|
||||||
|
}, [profiles, shown]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<LuTriangleAlert className="size-5 text-warning" />
|
||||||
|
{t("camoufoxDeprecation.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("camoufoxDeprecation.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<RippleButton
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void openUrl(
|
||||||
|
"https://github.com/zhom/donutbrowser/discussions/426",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.buttons.learnMore")}
|
||||||
|
</RippleButton>
|
||||||
|
<RippleButton
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("camoufoxDeprecation.acknowledge")}
|
||||||
|
</RippleButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ export function CloneProfileDialog({
|
|||||||
if (!open) onClose();
|
if (!open) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function CommandPalette({
|
|||||||
return (
|
return (
|
||||||
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||||
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||||
<CommandList>
|
<CommandList className="max-h-[min(60vh,480px)]">
|
||||||
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||||
|
|
||||||
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||||
@@ -205,7 +205,7 @@ export function CommandPalette({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuCircleStop />
|
<LuCircleStop />
|
||||||
<span>
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{t("commandPalette.actions.stopProfile", {
|
{t("commandPalette.actions.stopProfile", {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
})}
|
})}
|
||||||
@@ -221,7 +221,7 @@ export function CommandPalette({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuPlay />
|
<LuPlay />
|
||||||
<span>
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{t("commandPalette.actions.launchProfile", {
|
{t("commandPalette.actions.launchProfile", {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
})}
|
})}
|
||||||
@@ -239,7 +239,7 @@ export function CommandPalette({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuInfo />
|
<LuInfo />
|
||||||
<span>
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export function CookieCopyDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<LuCookie className="size-5" />
|
<LuCookie className="size-5" />
|
||||||
@@ -463,7 +463,7 @@ export function CookieCopyDialog({
|
|||||||
: t("cookies.copy.noFound")}
|
: t("cookies.copy.noFound")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[250px] border rounded-md">
|
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
{filteredDomains.map((domain) => (
|
{filteredDomains.map((domain) => (
|
||||||
<DomainRow
|
<DomainRow
|
||||||
@@ -559,7 +559,7 @@ function DomainRow({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
|
className="flex items-center gap-1 flex-1 min-w-0 text-left bg-transparent border-none cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onToggleExpand(domain.domain);
|
onToggleExpand(domain.domain);
|
||||||
}}
|
}}
|
||||||
@@ -569,8 +569,8 @@ function DomainRow({
|
|||||||
) : (
|
) : (
|
||||||
<LuChevronRight className="size-4" />
|
<LuChevronRight className="size-4" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">{domain.domain}</span>
|
<span className="font-medium truncate">{domain.domain}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
({domain.cookie_count})
|
({domain.cookie_count})
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ export function CookieManagementDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-[min(44rem,calc(100%-4rem))]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -563,7 +563,7 @@ export function CookieManagementDialog({
|
|||||||
{t("cookies.management.noCookies")}
|
{t("cookies.management.noCookies")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FadingScrollArea className="h-[200px]">
|
<FadingScrollArea className="h-[clamp(140px,30vh,420px)]">
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
{exportCookieData.domains.map((domain) => (
|
{exportCookieData.domains.map((domain) => (
|
||||||
<ExportDomainRow
|
<ExportDomainRow
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import { GoPlus } from "react-icons/go";
|
|||||||
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
|
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
|
||||||
import { LoadingButton } from "@/components/loading-button";
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -56,15 +54,9 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
|
|||||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||||
BrowserReleaseTypes,
|
|
||||||
CamoufoxConfig,
|
|
||||||
CamoufoxOS,
|
|
||||||
WayfernConfig,
|
|
||||||
WayfernOS,
|
|
||||||
} from "@/types";
|
|
||||||
|
|
||||||
const getCurrentOS = (): CamoufoxOS => {
|
const getCurrentOS = (): WayfernOS => {
|
||||||
if (typeof navigator === "undefined") return "linux";
|
if (typeof navigator === "undefined") return "linux";
|
||||||
const platform = navigator.platform.toLowerCase();
|
const platform = navigator.platform.toLowerCase();
|
||||||
if (platform.includes("win")) return "windows";
|
if (platform.includes("win")) return "windows";
|
||||||
@@ -86,7 +78,6 @@ interface CreateProfileDialogProps {
|
|||||||
releaseType: string;
|
releaseType: string;
|
||||||
proxyId?: string;
|
proxyId?: string;
|
||||||
vpnId?: string;
|
vpnId?: string;
|
||||||
camoufoxConfig?: CamoufoxConfig;
|
|
||||||
wayfernConfig?: WayfernConfig;
|
wayfernConfig?: WayfernConfig;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
extensionGroupId?: string;
|
extensionGroupId?: string;
|
||||||
@@ -105,10 +96,6 @@ interface BrowserOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const browserOptions: BrowserOption[] = [
|
const browserOptions: BrowserOption[] = [
|
||||||
{
|
|
||||||
value: "camoufox",
|
|
||||||
label: "Camoufox",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "wayfern",
|
value: "wayfern",
|
||||||
label: "Wayfern",
|
label: "Wayfern",
|
||||||
@@ -126,28 +113,24 @@ export function CreateProfileDialog({
|
|||||||
const proxyListboxIdAntiDetect = useId();
|
const proxyListboxIdAntiDetect = useId();
|
||||||
const proxyListboxIdRegular = useId();
|
const proxyListboxIdRegular = useId();
|
||||||
const [profileName, setProfileName] = useState("");
|
const [profileName, setProfileName] = useState("");
|
||||||
|
// Camoufox is deprecated: only Wayfern profiles can be created, so the dialog
|
||||||
|
// opens straight into the Wayfern config step (no browser-selection screen).
|
||||||
const [currentStep, setCurrentStep] = useState<
|
const [currentStep, setCurrentStep] = useState<
|
||||||
"browser-selection" | "browser-config"
|
"browser-selection" | "browser-config"
|
||||||
>("browser-selection");
|
>("browser-config");
|
||||||
const [activeTab, setActiveTab] = useState("anti-detect");
|
const [activeTab, setActiveTab] = useState("anti-detect");
|
||||||
|
|
||||||
// Browser selection states
|
// Browser selection states. Defaults to Wayfern — the only creatable browser.
|
||||||
const [selectedBrowser, setSelectedBrowser] =
|
const [selectedBrowser, setSelectedBrowser] =
|
||||||
useState<BrowserTypeString | null>(null);
|
useState<BrowserTypeString>("wayfern");
|
||||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||||
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
||||||
const [launchHook, setLaunchHook] = useState("");
|
const [launchHook, setLaunchHook] = useState("");
|
||||||
|
|
||||||
// Camoufox anti-detect states
|
|
||||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
|
||||||
geoip: true, // Default to automatic geoip
|
|
||||||
os: getCurrentOS(), // Default to current OS
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Wayfern anti-detect states
|
// Wayfern anti-detect states
|
||||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
|
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
|
||||||
os: getCurrentOS() as WayfernOS, // Default to current OS
|
os: getCurrentOS(), // Default to current OS
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Handle browser selection from the initial screen
|
// Handle browser selection from the initial screen
|
||||||
@@ -156,22 +139,23 @@ export function CreateProfileDialog({
|
|||||||
setCurrentStep("browser-config");
|
setCurrentStep("browser-config");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle back button
|
// Reset the form fields without leaving the Wayfern config step — Camoufox is
|
||||||
const handleBack = () => {
|
// deprecated, so there is no browser-selection screen to go back to.
|
||||||
setCurrentStep("browser-selection");
|
const resetForm = () => {
|
||||||
setSelectedBrowser(null);
|
setSelectedBrowser("wayfern");
|
||||||
setProfileName("");
|
setProfileName("");
|
||||||
setSelectedProxyId(undefined);
|
setSelectedProxyId(undefined);
|
||||||
setLaunchHook("");
|
setLaunchHook("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle back button
|
||||||
|
const handleBack = () => {
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
setCurrentStep("browser-selection");
|
resetForm();
|
||||||
setSelectedBrowser(null);
|
|
||||||
setProfileName("");
|
|
||||||
setSelectedProxyId(undefined);
|
|
||||||
setLaunchHook("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||||
@@ -307,16 +291,15 @@ export function CreateProfileDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
void loadSupportedBrowsers();
|
void loadSupportedBrowsers();
|
||||||
// Load downloaded versions for both anti-detect browsers up front so the
|
// Load downloaded Wayfern versions up front so the availability gate is
|
||||||
// selection-screen availability gate is accurate before either is picked.
|
// accurate. Camoufox is deprecated and no longer creatable.
|
||||||
void loadDownloadedVersions("wayfern");
|
void loadDownloadedVersions("wayfern");
|
||||||
void loadDownloadedVersions("camoufox");
|
|
||||||
// Load release types when a browser is selected
|
// Load release types when a browser is selected
|
||||||
if (selectedBrowser) {
|
if (selectedBrowser) {
|
||||||
void loadReleaseTypes(selectedBrowser);
|
void loadReleaseTypes(selectedBrowser);
|
||||||
}
|
}
|
||||||
// Check and download GeoIP database if needed for Camoufox or Wayfern
|
// Wayfern needs the GeoIP database for fingerprint generation.
|
||||||
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
|
if (selectedBrowser === "wayfern") {
|
||||||
void checkAndDownloadGeoIPDatabase();
|
void checkAndDownloadGeoIPDatabase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,66 +400,34 @@ export function CreateProfileDialog({
|
|||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
if (activeTab === "anti-detect") {
|
if (activeTab === "anti-detect") {
|
||||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
// Camoufox is deprecated — only Wayfern anti-detect profiles are created.
|
||||||
if (selectedBrowser === "wayfern") {
|
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
if (!bestWayfernVersion) {
|
||||||
if (!bestWayfernVersion) {
|
console.error("No Wayfern version available");
|
||||||
console.error("No Wayfern version available");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fingerprint will be generated at launch time by the Rust backend
|
|
||||||
const finalWayfernConfig = { ...wayfernConfig };
|
|
||||||
|
|
||||||
await onCreateProfile({
|
|
||||||
name: profileName.trim(),
|
|
||||||
browserStr: "wayfern" as BrowserTypeString,
|
|
||||||
version: bestWayfernVersion.version,
|
|
||||||
releaseType: bestWayfernVersion.releaseType,
|
|
||||||
proxyId: resolvedProxyId,
|
|
||||||
vpnId: resolvedVpnId,
|
|
||||||
wayfernConfig: finalWayfernConfig,
|
|
||||||
groupId:
|
|
||||||
selectedGroupId && selectedGroupId !== "__all__"
|
|
||||||
? selectedGroupId
|
|
||||||
: undefined,
|
|
||||||
extensionGroupId: selectedExtensionGroupId,
|
|
||||||
ephemeral,
|
|
||||||
dnsBlocklist: dnsBlocklist || undefined,
|
|
||||||
launchHook: launchHook.trim() || undefined,
|
|
||||||
password: passwordToSet,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Default to Camoufox
|
|
||||||
const bestCamoufoxVersion = getCreatableVersion("camoufox");
|
|
||||||
if (!bestCamoufoxVersion) {
|
|
||||||
console.error("No Camoufox version available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fingerprint will be generated at launch time by the Rust backend
|
|
||||||
// We don't need to generate it here during profile creation
|
|
||||||
const finalCamoufoxConfig = { ...camoufoxConfig };
|
|
||||||
|
|
||||||
await onCreateProfile({
|
|
||||||
name: profileName.trim(),
|
|
||||||
browserStr: "camoufox" as BrowserTypeString,
|
|
||||||
version: bestCamoufoxVersion.version,
|
|
||||||
releaseType: bestCamoufoxVersion.releaseType,
|
|
||||||
proxyId: resolvedProxyId,
|
|
||||||
vpnId: resolvedVpnId,
|
|
||||||
camoufoxConfig: finalCamoufoxConfig,
|
|
||||||
groupId:
|
|
||||||
selectedGroupId && selectedGroupId !== "__all__"
|
|
||||||
? selectedGroupId
|
|
||||||
: undefined,
|
|
||||||
extensionGroupId: selectedExtensionGroupId,
|
|
||||||
ephemeral,
|
|
||||||
dnsBlocklist: dnsBlocklist || undefined,
|
|
||||||
launchHook: launchHook.trim() || undefined,
|
|
||||||
password: passwordToSet,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fingerprint will be generated at launch time by the Rust backend
|
||||||
|
const finalWayfernConfig = { ...wayfernConfig };
|
||||||
|
|
||||||
|
await onCreateProfile({
|
||||||
|
name: profileName.trim(),
|
||||||
|
browserStr: "wayfern" as BrowserTypeString,
|
||||||
|
version: bestWayfernVersion.version,
|
||||||
|
releaseType: bestWayfernVersion.releaseType,
|
||||||
|
proxyId: resolvedProxyId,
|
||||||
|
vpnId: resolvedVpnId,
|
||||||
|
wayfernConfig: finalWayfernConfig,
|
||||||
|
groupId:
|
||||||
|
selectedGroupId && selectedGroupId !== "__all__"
|
||||||
|
? selectedGroupId
|
||||||
|
: undefined,
|
||||||
|
extensionGroupId: selectedExtensionGroupId,
|
||||||
|
ephemeral,
|
||||||
|
dnsBlocklist: dnsBlocklist || undefined,
|
||||||
|
launchHook: launchHook.trim() || undefined,
|
||||||
|
password: passwordToSet,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Regular browser
|
// Regular browser
|
||||||
if (!selectedBrowser) {
|
if (!selectedBrowser) {
|
||||||
@@ -519,22 +470,19 @@ export function CreateProfileDialog({
|
|||||||
// Cancel any ongoing loading
|
// Cancel any ongoing loading
|
||||||
loadingBrowserRef.current = null;
|
loadingBrowserRef.current = null;
|
||||||
|
|
||||||
// Reset all states
|
// Reset all states. Stay on the Wayfern config step — Camoufox is
|
||||||
|
// deprecated, so the browser-selection screen is gone.
|
||||||
setProfileName("");
|
setProfileName("");
|
||||||
setCurrentStep("browser-selection");
|
setCurrentStep("browser-config");
|
||||||
setActiveTab("anti-detect");
|
setActiveTab("anti-detect");
|
||||||
setSelectedBrowser(null);
|
setSelectedBrowser("wayfern");
|
||||||
setSelectedProxyId(undefined);
|
setSelectedProxyId(undefined);
|
||||||
setLaunchHook("");
|
setLaunchHook("");
|
||||||
setReleaseTypes({});
|
setReleaseTypes({});
|
||||||
setIsLoadingReleaseTypes(false);
|
setIsLoadingReleaseTypes(false);
|
||||||
setReleaseTypesError(null);
|
setReleaseTypesError(null);
|
||||||
setCamoufoxConfig({
|
|
||||||
geoip: true, // Reset to automatic geoip
|
|
||||||
os: getCurrentOS(), // Reset to current OS
|
|
||||||
});
|
|
||||||
setWayfernConfig({
|
setWayfernConfig({
|
||||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
os: getCurrentOS(), // Reset to current OS
|
||||||
});
|
});
|
||||||
setEphemeral(false);
|
setEphemeral(false);
|
||||||
setEnablePassword(false);
|
setEnablePassword(false);
|
||||||
@@ -544,10 +492,6 @@ export function CreateProfileDialog({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
|
|
||||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
|
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
|
||||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
@@ -590,7 +534,7 @@ export function CreateProfileDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{currentStep === "browser-selection"
|
{currentStep === "browser-selection"
|
||||||
@@ -613,7 +557,7 @@ export function CreateProfileDialog({
|
|||||||
|
|
||||||
<ScrollArea className="overflow-y-auto flex-1">
|
<ScrollArea className="overflow-y-auto flex-1">
|
||||||
<div className="flex flex-col justify-center items-center w-full">
|
<div className="flex flex-col justify-center items-center w-full">
|
||||||
<div className="py-4 space-y-6 w-full max-w-md">
|
<div className="py-4 space-y-6 w-full">
|
||||||
{currentStep === "browser-selection" ? (
|
{currentStep === "browser-selection" ? (
|
||||||
<>
|
<>
|
||||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||||
@@ -652,46 +596,14 @@ export function CreateProfileDialog({
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Camoufox (Firefox) - Second */}
|
{/* Camoufox is deprecated — no longer offered for new
|
||||||
<Button
|
profiles. Only Wayfern can be created. */}
|
||||||
onClick={() => {
|
|
||||||
handleBrowserSelect("camoufox");
|
|
||||||
}}
|
|
||||||
disabled={!getCreatableVersion("camoufox")}
|
|
||||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<div className="flex justify-center items-center size-8">
|
|
||||||
{isBrowserCurrentlyDownloading("camoufox") ? (
|
|
||||||
<LuLoaderCircle className="size-6 animate-spin" />
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const IconComponent =
|
|
||||||
getBrowserIcon("camoufox");
|
|
||||||
return IconComponent ? (
|
|
||||||
<IconComponent className="size-6" />
|
|
||||||
) : null;
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-medium">
|
|
||||||
{t("createProfile.firefoxLabel")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{isBrowserCurrentlyDownloading("camoufox")
|
|
||||||
? t("createProfile.downloadingSubtitle")
|
|
||||||
: t("createProfile.firefoxSubtitle")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!getCreatableVersion("wayfern") &&
|
{!getCreatableVersion("wayfern") && (
|
||||||
!getCreatableVersion("camoufox") && (
|
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
{t("createProfile.browsersDownloading")}
|
||||||
{t("createProfile.browsersDownloading")}
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -996,162 +908,9 @@ export function CreateProfileDialog({
|
|||||||
profileBrowser="wayfern"
|
profileBrowser="wayfern"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : selectedBrowser === "camoufox" ? (
|
|
||||||
// Camoufox Configuration
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Camoufox Download Status */}
|
|
||||||
{isLoadingReleaseTypes && (
|
|
||||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
|
||||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("createProfile.version.fetching")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
|
||||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
|
||||||
<p className="flex-1 text-sm text-destructive">
|
|
||||||
{releaseTypesError}
|
|
||||||
</p>
|
|
||||||
<RippleButton
|
|
||||||
onClick={() =>
|
|
||||||
selectedBrowser &&
|
|
||||||
loadReleaseTypes(selectedBrowser)
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("common.buttons.retry")}
|
|
||||||
</RippleButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingReleaseTypes &&
|
|
||||||
!releaseTypesError &&
|
|
||||||
!getBestAvailableVersion("camoufox") && (
|
|
||||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
|
||||||
<p className="text-sm text-warning">
|
|
||||||
{t("createProfile.platformUnavailable", {
|
|
||||||
browser: "Camoufox",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingReleaseTypes &&
|
|
||||||
!releaseTypesError &&
|
|
||||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
|
||||||
!getCreatableVersion("camoufox") &&
|
|
||||||
getBestAvailableVersion("camoufox") && (
|
|
||||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("createProfile.version.needsDownload", {
|
|
||||||
browser: "Camoufox",
|
|
||||||
version:
|
|
||||||
getBestAvailableVersion("camoufox")
|
|
||||||
?.version,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<LoadingButton
|
|
||||||
onClick={() => {
|
|
||||||
void handleDownload("camoufox");
|
|
||||||
}}
|
|
||||||
isLoading={isBrowserCurrentlyDownloading(
|
|
||||||
"camoufox",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
disabled={isBrowserCurrentlyDownloading(
|
|
||||||
"camoufox",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isBrowserCurrentlyDownloading("camoufox")
|
|
||||||
? t("common.buttons.downloading")
|
|
||||||
: t("common.buttons.download")}
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingReleaseTypes &&
|
|
||||||
!releaseTypesError &&
|
|
||||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
|
||||||
getCreatableVersion("camoufox") && (
|
|
||||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
|
||||||
✓{" "}
|
|
||||||
{t("createProfile.version.available", {
|
|
||||||
browser: "Camoufox",
|
|
||||||
version:
|
|
||||||
getCreatableVersion("camoufox")?.version,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingReleaseTypes &&
|
|
||||||
!releaseTypesError &&
|
|
||||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
|
||||||
getCreatableVersion("camoufox") &&
|
|
||||||
!isBrowserVersionAvailable("camoufox") &&
|
|
||||||
getBestAvailableVersion("camoufox") && (
|
|
||||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
|
||||||
<p className="flex-1 text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"createProfile.version.upgradeAvailable",
|
|
||||||
{
|
|
||||||
browser: "Camoufox",
|
|
||||||
version:
|
|
||||||
getBestAvailableVersion("camoufox")
|
|
||||||
?.version,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<LoadingButton
|
|
||||||
onClick={() => {
|
|
||||||
void handleDownload("camoufox");
|
|
||||||
}}
|
|
||||||
isLoading={isBrowserCurrentlyDownloading(
|
|
||||||
"camoufox",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isBrowserCurrentlyDownloading(
|
|
||||||
"camoufox",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isBrowserCurrentlyDownloading("camoufox")
|
|
||||||
? t("common.buttons.downloading")
|
|
||||||
: t("common.buttons.download")}
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
|
||||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
|
||||||
{t("createProfile.version.downloading", {
|
|
||||||
browser: "Camoufox",
|
|
||||||
version:
|
|
||||||
getBestAvailableVersion("camoufox")
|
|
||||||
?.version,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{crossOsUnlocked && (
|
|
||||||
<Alert className="border-warning/50 bg-warning/10">
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
{t("createProfile.camoufoxWarning")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SharedCamoufoxConfigForm
|
|
||||||
config={camoufoxConfig}
|
|
||||||
onConfigChange={updateCamoufoxConfig}
|
|
||||||
isCreating
|
|
||||||
browserType="camoufox"
|
|
||||||
crossOsUnlocked={crossOsUnlocked}
|
|
||||||
limitedMode={!crossOsUnlocked}
|
|
||||||
profileVersion={
|
|
||||||
getCreatableVersion("camoufox")?.version
|
|
||||||
}
|
|
||||||
profileBrowser="camoufox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
// Regular Browser Configuration (should not happen in anti-detect tab)
|
// Regular Browser Configuration (should not happen in
|
||||||
|
// the anti-detect tab; Camoufox creation is removed).
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedBrowser && (
|
{selectedBrowser && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps {
|
|||||||
|
|
||||||
interface DownloadToastProps extends BaseToastProps {
|
interface DownloadToastProps extends BaseToastProps {
|
||||||
type: "download";
|
type: "download";
|
||||||
stage?:
|
stage?: "downloading" | "extracting" | "verifying" | "completed";
|
||||||
| "downloading"
|
|
||||||
| "extracting"
|
|
||||||
| "verifying"
|
|
||||||
| "completed"
|
|
||||||
| "downloading (twilight rolling release)";
|
|
||||||
progress?: {
|
progress?: {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
speed?: string;
|
speed?: string;
|
||||||
@@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps {
|
|||||||
browserName?: string;
|
browserName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TwilightUpdateToastProps extends BaseToastProps {
|
|
||||||
type: "twilight-update";
|
|
||||||
browserName?: string;
|
|
||||||
hasUpdate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncProgressToastProps extends BaseToastProps {
|
interface SyncProgressToastProps extends BaseToastProps {
|
||||||
type: "sync-progress";
|
type: "sync-progress";
|
||||||
progress?: {
|
progress?: {
|
||||||
@@ -138,7 +127,6 @@ type ToastProps =
|
|||||||
| DownloadToastProps
|
| DownloadToastProps
|
||||||
| VersionUpdateToastProps
|
| VersionUpdateToastProps
|
||||||
| FetchingToastProps
|
| FetchingToastProps
|
||||||
| TwilightUpdateToastProps
|
|
||||||
| SyncProgressToastProps;
|
| SyncProgressToastProps;
|
||||||
|
|
||||||
function formatBytesCompact(bytes: number): string {
|
function formatBytesCompact(bytes: number): string {
|
||||||
@@ -191,10 +179,6 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
|||||||
return (
|
return (
|
||||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||||
);
|
);
|
||||||
case "twilight-update":
|
|
||||||
return (
|
|
||||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
|
||||||
);
|
|
||||||
case "sync-progress":
|
case "sync-progress":
|
||||||
return (
|
return (
|
||||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||||
@@ -217,7 +201,7 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
const progress = "progress" in props ? props.progress : undefined;
|
const progress = "progress" in props ? props.progress : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
<div className="flex items-start p-3 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -246,7 +230,8 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||||
{progress.percentage.toFixed(1)}%
|
{progress.percentage.toFixed(1)}%
|
||||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||||
{progress.eta && ` • ${progress.eta} remaining`}
|
{progress.eta &&
|
||||||
|
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-muted rounded-full h-1.5">
|
<div className="w-full bg-muted rounded-full h-1.5">
|
||||||
@@ -264,9 +249,10 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
"current_browser" in progress && (
|
"current_browser" in progress && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{progress.current_browser && (
|
{progress.current_browser &&
|
||||||
<>Looking for updates for {progress.current_browser}</>
|
t("versionUpdater.toast.lookingForUpdates", {
|
||||||
)}
|
browser: progress.current_browser,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||||
@@ -293,7 +279,10 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
{progress.phase === "uploading"
|
{progress.phase === "uploading"
|
||||||
? t("appUpdate.toast.uploading")
|
? t("appUpdate.toast.uploading")
|
||||||
: t("appUpdate.toast.downloading")}{" "}
|
: t("appUpdate.toast.downloading")}{" "}
|
||||||
{progress.completed_files}/{progress.total_files} files
|
{t("toasts.progress.filesProgress", {
|
||||||
|
completed: progress.completed_files,
|
||||||
|
total: progress.total_files,
|
||||||
|
})}
|
||||||
{" \u2022 "}
|
{" \u2022 "}
|
||||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||||
{formatBytesCompact(progress.total_bytes)}
|
{formatBytesCompact(progress.total_bytes)}
|
||||||
@@ -304,37 +293,21 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{progress.eta_seconds > 0 &&
|
{progress.eta_seconds > 0 &&
|
||||||
progress.completed_files < progress.total_files && (
|
progress.completed_files < progress.total_files &&
|
||||||
<>
|
` \u2022 ${t("toasts.progress.remaining", {
|
||||||
{" \u2022 ~"}
|
time: `~${formatEtaCompact(progress.eta_seconds)}`,
|
||||||
{formatEtaCompact(progress.eta_seconds)} remaining
|
})}`}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
{progress.failed_count > 0 && (
|
{progress.failed_count > 0 && (
|
||||||
<p className="text-xs text-destructive mt-0.5">
|
<p className="text-xs text-destructive mt-0.5">
|
||||||
{progress.failed_count} file(s) failed
|
{t("toasts.progress.filesFailed", {
|
||||||
|
count: progress.failed_count,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Twilight update progress */}
|
|
||||||
{type === "twilight-update" && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{"hasUpdate" in props && props.hasUpdate
|
|
||||||
? "New twilight build available for download"
|
|
||||||
: "Checking for twilight updates..."}
|
|
||||||
</p>
|
|
||||||
{props.browserName && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{props.browserName} • Rolling Release
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{description && (
|
{description && (
|
||||||
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||||
@@ -355,11 +328,6 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
{t("browserDownload.toast.verifying")}
|
{t("browserDownload.toast.verifying")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{stage === "downloading (twilight rolling release)" && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{t("browserDownload.toast.downloadingRolling")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{action &&
|
{action &&
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function DataTableActionBar<TData>({
|
|||||||
exit={{ opacity: 0, y: 20 }}
|
exit={{ opacity: 0, y: 20 }}
|
||||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export function DeleteConfirmationDialog({
|
|||||||
const profile = profiles.find((p) => p.id === id);
|
const profile = profiles.find((p) => p.id === id);
|
||||||
const displayName = profile ? profile.name : id;
|
const displayName = profile ? profile.name : id;
|
||||||
return (
|
return (
|
||||||
<li key={id} className="text-sm text-muted-foreground">
|
<li
|
||||||
|
key={id}
|
||||||
|
className="text-sm text-muted-foreground truncate"
|
||||||
|
>
|
||||||
• {displayName}
|
• {displayName}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ export function DeleteGroupDialog({
|
|||||||
count: associatedProfiles.length,
|
count: associatedProfiles.length,
|
||||||
})}
|
})}
|
||||||
</Label>
|
</Label>
|
||||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
<ScrollArea className="max-h-[min(8rem,25vh)] overflow-y-auto w-full border rounded-md p-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{associatedProfiles.map((profile) => (
|
{associatedProfiles.map((profile) => (
|
||||||
<div key={profile.id} className="text-sm">
|
<div key={profile.id} className="text-sm truncate">
|
||||||
• {profile.name}
|
• {profile.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function DnsBlocklistDialog({
|
|||||||
{t("dnsBlocklist.settingsDescription")}
|
{t("dnsBlocklist.settingsDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 overflow-y-auto min-h-0 max-h-[40vh]">
|
||||||
{statuses.map((status) => (
|
{statuses.map((status) => (
|
||||||
<div
|
<div
|
||||||
key={status.level}
|
key={status.level}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function ExtensionGroupAssignmentDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("extensions.assignTitle")}:</Label>
|
<Label>{t("extensions.assignTitle")}:</Label>
|
||||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
<div className="p-3 bg-muted rounded-md max-h-[min(8rem,20vh)] overflow-y-auto">
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
{selectedProfiles.map((profileId) => {
|
{selectedProfiles.map((profileId) => {
|
||||||
const profile = profiles.find(
|
const profile = profiles.find(
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { Extension, ExtensionGroup } from "@/types";
|
import type { Extension, ExtensionGroup } from "@/types";
|
||||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
@@ -770,6 +771,7 @@ export function ExtensionManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "compat",
|
id: "compat",
|
||||||
|
size: 56,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
@@ -821,6 +823,7 @@ export function ExtensionManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
size: 80,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -942,6 +945,7 @@ export function ExtensionManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "extensions",
|
id: "extensions",
|
||||||
|
size: 120,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -952,7 +956,7 @@ export function ExtensionManagementDialog({
|
|||||||
const visibleExts = groupExts.slice(0, MAX_VISIBLE_ICONS);
|
const visibleExts = groupExts.slice(0, MAX_VISIBLE_ICONS);
|
||||||
const overflowCount = groupExts.length - MAX_VISIBLE_ICONS;
|
const overflowCount = groupExts.length - MAX_VISIBLE_ICONS;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{visibleExts.map((ext) => (
|
{visibleExts.map((ext) => (
|
||||||
<Tooltip key={ext.id}>
|
<Tooltip key={ext.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -985,7 +989,7 @@ export function ExtensionManagementDialog({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{groupExts.length === 0 && (
|
{groupExts.length === 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground truncate min-w-0">
|
||||||
{t("extensions.noExtensionsInGroup")}
|
{t("extensions.noExtensionsInGroup")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1043,6 +1047,7 @@ export function ExtensionManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
size: 80,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -1111,7 +1116,7 @@ export function ExtensionManagementDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
@@ -1125,7 +1130,7 @@ export function ExtensionManagementDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
<div className="@container relative w-full flex-1 min-h-0 flex flex-col">
|
||||||
{limitedMode && (
|
{limitedMode && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||||
@@ -1150,7 +1155,7 @@ export function ExtensionManagementDialog({
|
|||||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||||
className="flex-1 min-h-0 flex flex-col"
|
className="flex-1 min-h-0 flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3 shrink-0">
|
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||||
<AnimatedTabsList>
|
<AnimatedTabsList>
|
||||||
<AnimatedTabsTrigger
|
<AnimatedTabsTrigger
|
||||||
value="extensions"
|
value="extensions"
|
||||||
@@ -1170,27 +1175,45 @@ export function ExtensionManagementDialog({
|
|||||||
</AnimatedTabsList>
|
</AnimatedTabsList>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activeTab === "extensions" && (
|
{activeTab === "extensions" && (
|
||||||
<RippleButton
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
variant="outline"
|
<RippleButton
|
||||||
disabled={limitedMode}
|
size="sm"
|
||||||
onClick={() =>
|
variant="outline"
|
||||||
document.getElementById("ext-file-input")?.click()
|
disabled={limitedMode}
|
||||||
}
|
onClick={() =>
|
||||||
>
|
document.getElementById("ext-file-input")?.click()
|
||||||
<LuUpload className="size-4" />
|
}
|
||||||
{t("extensions.upload")}
|
aria-label={t("extensions.upload")}
|
||||||
</RippleButton>
|
>
|
||||||
|
<LuUpload className="size-4" />
|
||||||
|
<span className="hidden @2xl:inline">
|
||||||
|
{t("extensions.upload")}
|
||||||
|
</span>
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("extensions.upload")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{activeTab === "groups" && (
|
{activeTab === "groups" && (
|
||||||
<RippleButton
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
disabled={limitedMode}
|
<RippleButton
|
||||||
onClick={() => setShowCreateGroup(true)}
|
size="sm"
|
||||||
>
|
disabled={limitedMode}
|
||||||
<GoPlus className="size-4" />
|
onClick={() => setShowCreateGroup(true)}
|
||||||
{t("extensions.newGroup")}
|
aria-label={t("extensions.newGroup")}
|
||||||
</RippleButton>
|
>
|
||||||
|
<GoPlus className="size-4" />
|
||||||
|
<span className="hidden @2xl:inline">
|
||||||
|
{t("extensions.newGroup")}
|
||||||
|
</span>
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("extensions.newGroup")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1267,14 +1290,20 @@ export function ExtensionManagementDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FadingScrollArea
|
<FadingScrollArea
|
||||||
className="flex-1 min-h-0"
|
className={cn(
|
||||||
|
"flex-1 min-h-0",
|
||||||
|
selectedExtensions.length > 0 && "pb-16",
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--scroll-fade-top-offset": "32px",
|
"--scroll-fade-top-offset": "32px",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table>
|
<Table
|
||||||
|
className="w-full table-fixed"
|
||||||
|
containerClassName="overflow-visible"
|
||||||
|
>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
{extTable.getHeaderGroups().map((headerGroup) => (
|
{extTable.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
@@ -1282,10 +1311,14 @@ export function ExtensionManagementDialog({
|
|||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
style={{
|
style={{
|
||||||
width: header.column.columnDef.size
|
width:
|
||||||
? `${header.column.getSize()}px`
|
header.column.id === "name"
|
||||||
: undefined,
|
? undefined
|
||||||
|
: `${header.column.getSize()}px`,
|
||||||
}}
|
}}
|
||||||
|
className={cn(
|
||||||
|
header.column.id === "name" && "max-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
@@ -1308,10 +1341,14 @@ export function ExtensionManagementDialog({
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{
|
style={{
|
||||||
width: cell.column.columnDef.size
|
width:
|
||||||
? `${cell.column.getSize()}px`
|
cell.column.id === "name"
|
||||||
: undefined,
|
? undefined
|
||||||
|
: `${cell.column.getSize()}px`,
|
||||||
}}
|
}}
|
||||||
|
className={cn(
|
||||||
|
cell.column.id === "name" && "max-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
@@ -1374,14 +1411,20 @@ export function ExtensionManagementDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FadingScrollArea
|
<FadingScrollArea
|
||||||
className="flex-1 min-h-0"
|
className={cn(
|
||||||
|
"flex-1 min-h-0",
|
||||||
|
selectedGroups.length > 0 && "pb-16",
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--scroll-fade-top-offset": "32px",
|
"--scroll-fade-top-offset": "32px",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table>
|
<Table
|
||||||
|
className="w-full table-fixed"
|
||||||
|
containerClassName="overflow-visible"
|
||||||
|
>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
{groupTable.getHeaderGroups().map((headerGroup) => (
|
{groupTable.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
@@ -1389,10 +1432,14 @@ export function ExtensionManagementDialog({
|
|||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
style={{
|
style={{
|
||||||
width: header.column.columnDef.size
|
width:
|
||||||
? `${header.column.getSize()}px`
|
header.column.id === "name"
|
||||||
: undefined,
|
? undefined
|
||||||
|
: `${header.column.getSize()}px`,
|
||||||
}}
|
}}
|
||||||
|
className={cn(
|
||||||
|
header.column.id === "name" && "max-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
@@ -1415,10 +1462,14 @@ export function ExtensionManagementDialog({
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{
|
style={{
|
||||||
width: cell.column.columnDef.size
|
width:
|
||||||
? `${cell.column.getSize()}px`
|
cell.column.id === "name"
|
||||||
: undefined,
|
? undefined
|
||||||
|
: `${cell.column.getSize()}px`,
|
||||||
}}
|
}}
|
||||||
|
className={cn(
|
||||||
|
cell.column.id === "name" && "max-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
@@ -1515,7 +1566,7 @@ export function ExtensionManagementDialog({
|
|||||||
{t("extensions.noExtensionsInGroup")}
|
{t("extensions.noExtensionsInGroup")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
<div className="space-y-1 max-h-[min(40vh,320px)] overflow-y-auto">
|
||||||
{editGroupExtensionIds.map((extId) => {
|
{editGroupExtensionIds.map((extId) => {
|
||||||
const ext = extensions.find((e) => e.id === extId);
|
const ext = extensions.find((e) => e.id === extId);
|
||||||
if (!ext) return null;
|
if (!ext) return null;
|
||||||
@@ -1612,7 +1663,7 @@ export function ExtensionManagementDialog({
|
|||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
{t("extensions.metadata")}
|
{t("extensions.metadata")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||||
{editingExtension.version && (
|
{editingExtension.version && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@@ -1660,7 +1711,7 @@ export function ExtensionManagementDialog({
|
|||||||
href={editingExtension.homepage_url}
|
href={editingExtension.homepage_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline flex items-center gap-1 truncate"
|
className="text-primary hover:underline flex items-center gap-1 min-w-0"
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{editingExtension.homepage_url}
|
{editingExtension.homepage_url}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function GroupAssignmentDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
|
<Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
|
||||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
<div className="p-3 bg-muted rounded-md max-h-[min(8rem,20vh)] overflow-y-auto">
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
{selectedProfiles.map((profileId) => {
|
{selectedProfiles.map((profileId) => {
|
||||||
// Find the profile name for display
|
// Find the profile name for display
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import type { GroupWithCount } from "@/types";
|
|
||||||
|
|
||||||
interface GroupBadgesProps {
|
|
||||||
selectedGroupId: string | null;
|
|
||||||
onGroupSelect: (groupId: string) => void;
|
|
||||||
refreshTrigger?: number;
|
|
||||||
groups: GroupWithCount[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupBadges({
|
|
||||||
selectedGroupId,
|
|
||||||
onGroupSelect,
|
|
||||||
groups,
|
|
||||||
isLoading,
|
|
||||||
}: GroupBadgesProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [showLeftFade, setShowLeftFade] = useState(false);
|
|
||||||
const [showRightFade, setShowRightFade] = useState(false);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null);
|
|
||||||
const hasMovedRef = useRef(false);
|
|
||||||
const clickBlockedRef = useRef(false);
|
|
||||||
|
|
||||||
const checkScrollPosition = useCallback(() => {
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = container;
|
|
||||||
setShowLeftFade(scrollLeft > 0);
|
|
||||||
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
dragStartRef.current = {
|
|
||||||
x: e.clientX,
|
|
||||||
scrollLeft: container.scrollLeft,
|
|
||||||
};
|
|
||||||
hasMovedRef.current = false;
|
|
||||||
setIsDragging(true);
|
|
||||||
container.style.cursor = "grabbing";
|
|
||||||
container.style.userSelect = "none";
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
if (!isDragging || !dragStartRef.current) return;
|
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - dragStartRef.current.x;
|
|
||||||
const distance = Math.abs(deltaX);
|
|
||||||
|
|
||||||
if (distance > 5) {
|
|
||||||
hasMovedRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.scrollLeft = dragStartRef.current.scrollLeft - deltaX;
|
|
||||||
checkScrollPosition();
|
|
||||||
},
|
|
||||||
[isDragging, checkScrollPosition],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
|
||||||
if (!isDragging) return;
|
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (container) {
|
|
||||||
container.style.cursor = "";
|
|
||||||
container.style.userSelect = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
clickBlockedRef.current = hasMovedRef.current;
|
|
||||||
setIsDragging(false);
|
|
||||||
dragStartRef.current = null;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
hasMovedRef.current = false;
|
|
||||||
clickBlockedRef.current = false;
|
|
||||||
}, 100);
|
|
||||||
}, [isDragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDragging) {
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
checkScrollPosition();
|
|
||||||
container.addEventListener("scroll", checkScrollPosition);
|
|
||||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener("scroll", checkScrollPosition);
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [checkScrollPosition]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (groups.length === 0) {
|
|
||||||
setShowLeftFade(false);
|
|
||||||
setShowRightFade(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
checkScrollPosition();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [groups, checkScrollPosition]);
|
|
||||||
|
|
||||||
if (isLoading && !groups.length) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
|
||||||
{t("groups.loading")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative mb-4">
|
|
||||||
{showLeftFade && (
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
|
|
||||||
)}
|
|
||||||
{showRightFade && (
|
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
role="region"
|
|
||||||
aria-label={t("groups.profileGroupsAriaLabel")}
|
|
||||||
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
|
||||||
onScroll={checkScrollPosition}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<Badge
|
|
||||||
key={group.id}
|
|
||||||
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
|
||||||
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (hasMovedRef.current || clickBlockedRef.current) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onGroupSelect(
|
|
||||||
selectedGroupId === group.id ? "default" : group.id,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (isDragging) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{group.name}</span>
|
|
||||||
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
|
|
||||||
{group.count}
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { GroupWithCount, ProfileGroup } from "@/types";
|
import type { GroupWithCount, ProfileGroup } from "@/types";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
@@ -345,7 +346,7 @@ export function GroupManagementDialog({
|
|||||||
groupSyncErrors[group.id],
|
groupSyncErrors[group.id],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 font-medium">
|
<div className="flex items-center gap-2 font-medium min-w-0">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -358,8 +359,8 @@ export function GroupManagementDialog({
|
|||||||
<p>{syncDot.tooltip}</p>
|
<p>{syncDot.tooltip}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<LuFolder className="size-4 text-muted-foreground" />
|
<LuFolder className="size-4 shrink-0 text-muted-foreground" />
|
||||||
{group.name}
|
<span className="truncate">{group.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -552,7 +553,7 @@ export function GroupManagementDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-[min(60rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("groups.management")}</DialogTitle>
|
<DialogTitle>{t("groups.management")}</DialogTitle>
|
||||||
@@ -562,7 +563,7 @@ export function GroupManagementDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
<div className="w-full flex flex-col gap-4 flex-1 min-h-0">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-base font-semibold">
|
<h2 className="text-base font-semibold">
|
||||||
@@ -601,14 +602,20 @@ export function GroupManagementDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FadingScrollArea
|
<FadingScrollArea
|
||||||
className="flex-1 min-h-0"
|
className={cn(
|
||||||
|
"flex-1 min-h-0",
|
||||||
|
selectedGroupsForBulk.length > 0 && "pb-16",
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--scroll-fade-top-offset": "32px",
|
"--scroll-fade-top-offset": "32px",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table>
|
<Table
|
||||||
|
className="w-full table-fixed"
|
||||||
|
containerClassName="overflow-visible"
|
||||||
|
>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
@@ -616,10 +623,14 @@ export function GroupManagementDialog({
|
|||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
style={{
|
style={{
|
||||||
width: header.column.columnDef.size
|
width:
|
||||||
? `${header.column.getSize()}px`
|
header.column.id === "name"
|
||||||
: undefined,
|
? undefined
|
||||||
|
: `${header.column.getSize()}px`,
|
||||||
}}
|
}}
|
||||||
|
className={cn(
|
||||||
|
header.column.id === "name" && "max-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
@@ -642,10 +653,14 @@ export function GroupManagementDialog({
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{
|
style={{
|
||||||
width: cell.column.columnDef.size
|
width:
|
||||||
? `${cell.column.getSize()}px`
|
cell.column.id === "name"
|
||||||
: undefined,
|
? undefined
|
||||||
|
: `${cell.column.getSize()}px`,
|
||||||
}}
|
}}
|
||||||
|
className={cn(
|
||||||
|
cell.column.id === "name" && "max-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ const HomeHeader = ({
|
|||||||
[clearHold],
|
[clearHold],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isTextInputTarget(e.target)) return;
|
||||||
|
if (e.target instanceof Element && e.target.closest("button")) return;
|
||||||
|
clearHold();
|
||||||
|
void getCurrentWindow().toggleMaximize();
|
||||||
|
},
|
||||||
|
[clearHold],
|
||||||
|
);
|
||||||
|
|
||||||
// Horizontal scroll fades for the group filter strip — when the user
|
// Horizontal scroll fades for the group filter strip — when the user
|
||||||
// has more groups than fit, the right edge fades to hint at overflow.
|
// has more groups than fit, the right edge fades to hint at overflow.
|
||||||
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
|
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -156,20 +166,22 @@ const HomeHeader = ({
|
|||||||
const isWindows = platform === "windows";
|
const isWindows = platform === "windows";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: titlebar drag surface; the interactive controls inside are real buttons/inputs
|
||||||
<div
|
<div
|
||||||
ref={dragRootRef}
|
ref={dragRootRef}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerEnd}
|
onPointerUp={handlePointerEnd}
|
||||||
onPointerCancel={handlePointerEnd}
|
onPointerCancel={handlePointerEnd}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
|
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
|
||||||
// Windows: WindowDragArea renders two 44px native-style controls
|
// Windows: WindowDragArea renders three 44px native-style controls
|
||||||
// (minimize + close) fixed at top-right with z-50, total 88px wide.
|
// (minimize + maximize/restore + close) fixed at top-right with
|
||||||
// Reserve 100px on the right edge so the "+ New" button and search
|
// z-50, total 132px wide. Reserve 144px on the right edge so the
|
||||||
// input clear them with a few pixels of breathing room — issues
|
// "+ New" button and search input clear them with a few pixels of
|
||||||
// #358, #361, #362 all reported the same overlap before this fix.
|
// breathing room and never sit underneath the controls.
|
||||||
isWindows ? "pr-[100px]" : "pr-3",
|
isWindows ? "pr-[144px]" : "pr-3",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isMacOS && (
|
{isMacOS && (
|
||||||
@@ -248,6 +260,7 @@ const HomeHeader = ({
|
|||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
title={group.name}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||||
}}
|
}}
|
||||||
@@ -258,7 +271,7 @@ const HomeHeader = ({
|
|||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{group.name}</span>
|
<span className="max-w-40 truncate">{group.name}</span>
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
{group.count}
|
{group.count}
|
||||||
</span>
|
</span>
|
||||||
@@ -297,7 +310,7 @@ const HomeHeader = ({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onSearchQueryChange(e.target.value);
|
onSearchQueryChange(e.target.value);
|
||||||
}}
|
}}
|
||||||
className="pr-7 pl-8 w-52 h-7 text-xs"
|
className="pr-7 pl-8 w-36 min-[860px]:w-52 h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
{searchQuery ? (
|
{searchQuery ? (
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FaFolder } from "react-icons/fa";
|
import { FaFolder } from "react-icons/fa";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { LoadingButton } from "@/components/loading-button";
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
AnimatedTabs,
|
AnimatedTabs,
|
||||||
@@ -34,9 +33,10 @@ import {
|
|||||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||||
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
|
import type { DetectedProfile, WayfernConfig } from "@/types";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
|
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
|
||||||
@@ -70,7 +70,6 @@ export function ImportProfileDialog({
|
|||||||
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
|
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
|
||||||
"select",
|
"select",
|
||||||
);
|
);
|
||||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
|
|
||||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
||||||
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
|
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
|
||||||
|
|
||||||
@@ -91,7 +90,11 @@ export function ImportProfileDialog({
|
|||||||
useBrowserSupport();
|
useBrowserSupport();
|
||||||
const { storedProxies } = useProxyEvents();
|
const { storedProxies } = useProxyEvents();
|
||||||
|
|
||||||
const importableBrowsers = supportedBrowsers;
|
// Firefox-based browsers map to the deprecated Camoufox and can no longer be
|
||||||
|
// imported (the backend rejects them); only offer Chromium-family sources.
|
||||||
|
const importableBrowsers = supportedBrowsers.filter(
|
||||||
|
(browser) => getMappedBrowser(browser) === "wayfern",
|
||||||
|
);
|
||||||
|
|
||||||
const loadDetectedProfiles = useCallback(async () => {
|
const loadDetectedProfiles = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -176,7 +179,7 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
const mappedBrowser =
|
const mappedBrowser =
|
||||||
importMode === "auto-detect" && selectedProfile
|
importMode === "auto-detect" && selectedProfile
|
||||||
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
|
? getMappedBrowser(selectedProfile.mapped_browser)
|
||||||
: getMappedBrowser(browserType);
|
: getMappedBrowser(browserType);
|
||||||
|
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
@@ -186,7 +189,8 @@ export function ImportProfileDialog({
|
|||||||
browserType,
|
browserType,
|
||||||
newProfileName,
|
newProfileName,
|
||||||
proxyId: selectedProxyId ?? null,
|
proxyId: selectedProxyId ?? null,
|
||||||
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
|
// Camoufox import is deprecated/blocked; only Wayfern configs are sent.
|
||||||
|
camoufoxConfig: null,
|
||||||
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,7 +203,10 @@ export function ImportProfileDialog({
|
|||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
if (errorMessage.includes("No downloaded versions found")) {
|
if (parseBackendError(error)) {
|
||||||
|
// Structured backend error (e.g. CAMOUFOX_IMPORT_DEPRECATED) — localize.
|
||||||
|
toast.error(translateBackendError(t, error));
|
||||||
|
} else if (errorMessage.includes("No downloaded versions found")) {
|
||||||
const browserDisplayName = getBrowserDisplayName(browserType);
|
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||||
toast.error(
|
toast.error(
|
||||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||||
@@ -222,7 +229,6 @@ export function ImportProfileDialog({
|
|||||||
manualProfilePath,
|
manualProfilePath,
|
||||||
manualProfileName,
|
manualProfileName,
|
||||||
selectedProxyId,
|
selectedProxyId,
|
||||||
camoufoxConfig,
|
|
||||||
wayfernConfig,
|
wayfernConfig,
|
||||||
onClose,
|
onClose,
|
||||||
selectedProfile,
|
selectedProfile,
|
||||||
@@ -231,7 +237,6 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setCurrentStep("select");
|
setCurrentStep("select");
|
||||||
setCamoufoxConfig({});
|
|
||||||
setWayfernConfig({});
|
setWayfernConfig({});
|
||||||
setSelectedProxyId(undefined);
|
setSelectedProxyId(undefined);
|
||||||
setSelectedDetectedProfile(null);
|
setSelectedDetectedProfile(null);
|
||||||
@@ -262,10 +267,10 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
const currentMappedBrowser = useMemo(() => {
|
const currentMappedBrowser = useMemo(() => {
|
||||||
if (importMode === "auto-detect" && selectedProfile) {
|
if (importMode === "auto-detect" && selectedProfile) {
|
||||||
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
|
return getMappedBrowser(selectedProfile.mapped_browser);
|
||||||
}
|
}
|
||||||
if (importMode === "manual" && manualBrowserType) {
|
if (importMode === "manual" && manualBrowserType) {
|
||||||
return manualBrowserType as "camoufox" | "wayfern";
|
return getMappedBrowser(manualBrowserType);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [importMode, selectedProfile, manualBrowserType]);
|
}, [importMode, selectedProfile, manualBrowserType]);
|
||||||
@@ -301,14 +306,19 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-y-auto flex-1 space-y-6 min-h-0",
|
||||||
|
subPage && "mx-auto w-full max-w-2xl",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{currentStep === "select" && (
|
{currentStep === "select" && (
|
||||||
<AnimatedTabs
|
<AnimatedTabs
|
||||||
value={importMode}
|
value={importMode}
|
||||||
@@ -404,7 +414,7 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
{selectedProfile && (
|
{selectedProfile && (
|
||||||
<div className="p-3 rounded-lg bg-muted">
|
<div className="p-3 rounded-lg bg-muted">
|
||||||
<p className="text-sm">
|
<p className="text-sm break-all">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t("importProfile.pathLabel")}
|
{t("importProfile.pathLabel")}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
@@ -508,7 +518,7 @@ export function ImportProfileDialog({
|
|||||||
<FaFolder className="size-4" />
|
<FaFolder className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground break-all">
|
||||||
{t("importProfile.examplePaths")}
|
{t("importProfile.examplePaths")}
|
||||||
<br />
|
<br />
|
||||||
macOS: ~/Library/Application
|
macOS: ~/Library/Application
|
||||||
@@ -577,27 +587,17 @@ export function ImportProfileDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentMappedBrowser === "camoufox" ? (
|
{/* Only Wayfern profiles are importable now (Camoufox/Firefox
|
||||||
<SharedCamoufoxConfigForm
|
import is deprecated and blocked). */}
|
||||||
config={camoufoxConfig}
|
<WayfernConfigForm
|
||||||
onConfigChange={(key, value) => {
|
config={wayfernConfig}
|
||||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
onConfigChange={(key, value) => {
|
||||||
}}
|
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
isCreating={true}
|
}}
|
||||||
crossOsUnlocked={crossOsUnlocked}
|
isCreating={true}
|
||||||
limitedMode={!crossOsUnlocked}
|
crossOsUnlocked={crossOsUnlocked}
|
||||||
/>
|
limitedMode={!crossOsUnlocked}
|
||||||
) : (
|
/>
|
||||||
<WayfernConfigForm
|
|
||||||
config={wayfernConfig}
|
|
||||||
onConfigChange={(key, value) => {
|
|
||||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
|
||||||
}}
|
|
||||||
isCreating={true}
|
|
||||||
crossOsUnlocked={crossOsUnlocked}
|
|
||||||
limitedMode={!crossOsUnlocked}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +605,9 @@ export function ImportProfileDialog({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 flex gap-2 items-center justify-end",
|
"shrink-0 flex gap-2 items-center justify-end",
|
||||||
subPage ? "pt-2 border-t border-border" : undefined,
|
subPage
|
||||||
|
? "pt-2 border-t border-border mx-auto w-full max-w-2xl"
|
||||||
|
: undefined,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentStep === "select" ? (
|
{currentStep === "select" ? (
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||||
|
|
||||||
interface AppSettings {
|
interface AppSettings {
|
||||||
@@ -307,14 +308,19 @@ export function IntegrationsDialog({
|
|||||||
}}
|
}}
|
||||||
subPage={subPage}
|
subPage={subPage}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
|
<DialogContent className="max-w-3xl max-h-[calc(100vh-5rem)] flex flex-col">
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-y-auto flex-1 min-h-0",
|
||||||
|
subPage && "w-full max-w-3xl mx-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||||
<AnimatedTabsList>
|
<AnimatedTabsList>
|
||||||
<AnimatedTabsTrigger value="api">
|
<AnimatedTabsTrigger value="api">
|
||||||
@@ -327,7 +333,7 @@ export function IntegrationsDialog({
|
|||||||
|
|
||||||
<AnimatedTabsContent
|
<AnimatedTabsContent
|
||||||
value="api"
|
value="api"
|
||||||
className="mt-4 flex flex-col gap-4"
|
className="mt-4 flex flex-col gap-4 @container"
|
||||||
>
|
>
|
||||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
|
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -364,7 +370,7 @@ export function IntegrationsDialog({
|
|||||||
|
|
||||||
{settings.api_enabled && (
|
{settings.api_enabled && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @2xl:grid-cols-2 gap-4">
|
||||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
{t("integrations.apiPortLabel")}
|
{t("integrations.apiPortLabel")}
|
||||||
@@ -581,11 +587,11 @@ export function IntegrationsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3 @container">
|
||||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
{t("integrations.mcp.clientsLabel")}
|
{t("integrations.mcp.clientsLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 @2xl:grid-cols-2 gap-3">
|
||||||
{agents.map((agent) => {
|
{agents.map((agent) => {
|
||||||
const busy = busyAgentIds.has(agent.id);
|
const busy = busyAgentIds.has(agent.id);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export function LocationProxyDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 overflow-y-auto min-h-0 max-h-[calc(100vh-16rem)] pr-1">
|
||||||
{/* Country - always visible */}
|
{/* Country - always visible */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -194,8 +194,16 @@ const MultipleSelector = React.forwardRef<
|
|||||||
) => {
|
) => {
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [dropUp, setDropUp] = React.useState(false);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const updateDropUp = React.useCallback(() => {
|
||||||
|
const rect = inputRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
setDropUp(spaceBelow < 240 && rect.top > spaceBelow);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [selected, setSelected] = React.useState<Option[]>(value ?? []);
|
const [selected, setSelected] = React.useState<Option[]>(value ?? []);
|
||||||
const [options, setOptions] = React.useState<GroupOption>(
|
const [options, setOptions] = React.useState<GroupOption>(
|
||||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||||
@@ -203,6 +211,19 @@ const MultipleSelector = React.forwardRef<
|
|||||||
const [inputValue, setInputValue] = React.useState("");
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
|
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
|
||||||
|
|
||||||
|
// Re-evaluate the flip while the list is open: selecting options grows
|
||||||
|
// the badge row (moving the input down) and window resizes change the
|
||||||
|
// space below — both can invalidate the side chosen on focus.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void selected.length;
|
||||||
|
updateDropUp();
|
||||||
|
window.addEventListener("resize", updateDropUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updateDropUp);
|
||||||
|
};
|
||||||
|
}, [open, selected.length, updateDropUp]);
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -377,7 +398,7 @@ const MultipleSelector = React.forwardRef<
|
|||||||
commandProps?.onKeyDown?.(e);
|
commandProps?.onKeyDown?.(e);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto overflow-visible bg-transparent",
|
"relative h-auto overflow-visible bg-transparent",
|
||||||
commandProps?.className,
|
commandProps?.className,
|
||||||
)}
|
)}
|
||||||
shouldFilter={
|
shouldFilter={
|
||||||
@@ -488,6 +509,7 @@ const MultipleSelector = React.forwardRef<
|
|||||||
inputProps?.onBlur?.(event);
|
inputProps?.onBlur?.(event);
|
||||||
}}
|
}}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
|
updateDropUp();
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
if (triggerSearchOnFocus && onSearch) {
|
if (triggerSearchOnFocus && onSearch) {
|
||||||
void onSearch(debouncedSearchTerm);
|
void onSearch(debouncedSearchTerm);
|
||||||
@@ -511,9 +533,14 @@ const MultipleSelector = React.forwardRef<
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div>
|
||||||
{open && hasAvailableOptions && (
|
{open && hasAvailableOptions && (
|
||||||
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
|
<CommandList
|
||||||
|
className={cn(
|
||||||
|
"absolute z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in",
|
||||||
|
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
loadingIndicator
|
loadingIndicator
|
||||||
) : (
|
) : (
|
||||||
@@ -527,7 +554,7 @@ const MultipleSelector = React.forwardRef<
|
|||||||
<CommandGroup
|
<CommandGroup
|
||||||
key={key}
|
key={key}
|
||||||
heading={key}
|
heading={key}
|
||||||
className="overflow-auto h-24"
|
className="overflow-auto max-h-48"
|
||||||
>
|
>
|
||||||
{dropdowns.map((option) => {
|
{dropdowns.map((option) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
type RowData,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
@@ -81,7 +83,6 @@ import {
|
|||||||
isCrossOsProfile,
|
isCrossOsProfile,
|
||||||
} from "@/lib/browser-utils";
|
} from "@/lib/browser-utils";
|
||||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||||
import { trimName } from "@/lib/name-utils";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
BrowserProfile,
|
BrowserProfile,
|
||||||
@@ -105,6 +106,15 @@ import { TrafficDetailsDialog } from "./traffic-details-dialog";
|
|||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
|
declare module "@tanstack/react-table" {
|
||||||
|
interface ColumnMeta<TData extends RowData, TValue> {
|
||||||
|
// Emit no width for this column so table-fixed hands it all remaining
|
||||||
|
// space. Checking columnDef.size alone can't express this: TanStack
|
||||||
|
// resolves an unspecified size to its 150px default.
|
||||||
|
flexWidth?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
||||||
// causing column definitions to be recreated on every render.
|
// causing column definitions to be recreated on every render.
|
||||||
interface TableMeta {
|
interface TableMeta {
|
||||||
@@ -822,6 +832,96 @@ const NonHoverableTooltip = React.memo<{
|
|||||||
|
|
||||||
NonHoverableTooltip.displayName = "NonHoverableTooltip";
|
NonHoverableTooltip.displayName = "NonHoverableTooltip";
|
||||||
|
|
||||||
|
// CSS-truncated text whose tooltip only appears when the text actually
|
||||||
|
// overflows its column (measured on hover, so it tracks live resizes).
|
||||||
|
const OverflowTooltipText = React.memo<{
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}>(({ text, className }) => {
|
||||||
|
const textRef = React.useRef<HTMLSpanElement | null>(null);
|
||||||
|
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
const el = textRef.current;
|
||||||
|
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
ref={textRef}
|
||||||
|
className={cn("block min-w-0 max-w-full truncate", className)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{isOverflowing && <TooltipContent>{text}</TooltipContent>}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OverflowTooltipText.displayName = "OverflowTooltipText";
|
||||||
|
|
||||||
|
// Must be rendered inside a <Popover>; the tooltip shows the full assignment
|
||||||
|
// name only when it is truncated in the cell.
|
||||||
|
const ProxyCellTrigger = React.memo<{
|
||||||
|
displayName: string;
|
||||||
|
hasAssignment: boolean;
|
||||||
|
vpnBadge: string | null;
|
||||||
|
isDisabled: boolean;
|
||||||
|
}>(({ displayName, hasAssignment, vpnBadge, isDisabled }) => {
|
||||||
|
const textRef = React.useRef<HTMLSpanElement | null>(null);
|
||||||
|
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
const el = textRef.current;
|
||||||
|
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex gap-2 items-center px-2 py-1 rounded min-w-0 max-w-full",
|
||||||
|
isDisabled
|
||||||
|
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||||
|
: "cursor-pointer hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{vpnBadge && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1 py-0 leading-tight shrink-0"
|
||||||
|
>
|
||||||
|
{vpnBadge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
ref={textRef}
|
||||||
|
className={cn(
|
||||||
|
"text-sm min-w-0 truncate",
|
||||||
|
!hasAssignment && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{hasAssignment && isOverflowing && (
|
||||||
|
<TooltipContent>{displayName}</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ProxyCellTrigger.displayName = "ProxyCellTrigger";
|
||||||
|
|
||||||
const NoteCell = React.memo<{
|
const NoteCell = React.memo<{
|
||||||
profile: BrowserProfile;
|
profile: BrowserProfile;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
@@ -2039,12 +2139,12 @@ export function ProfilesDataTable({
|
|||||||
|
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
const tooltipMessage = isRunning
|
const tooltipMessage = isRunning
|
||||||
? "Can't modify running profile"
|
? t("profiles.table.cantModifyRunning")
|
||||||
: isLaunching
|
: isLaunching
|
||||||
? "Can't modify profile while launching"
|
? t("profiles.table.cantModifyLaunching")
|
||||||
: isStopping
|
: isStopping
|
||||||
? "Can't modify profile while stopping"
|
? t("profiles.table.cantModifyStopping")
|
||||||
: "Can't modify profile while browser is updating";
|
: t("profiles.table.cantModifyUpdating");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -2276,7 +2376,9 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
size: 130,
|
// The only column without a fixed width: table-fixed hands it all
|
||||||
|
// remaining space as the window grows or shrinks.
|
||||||
|
meta: { flexWidth: true },
|
||||||
header: ({ column, table }) => {
|
header: ({ column, table }) => {
|
||||||
const meta = table.options.meta as TableMeta;
|
const meta = table.options.meta as TableMeta;
|
||||||
return (
|
return (
|
||||||
@@ -2341,27 +2443,18 @@ export function ProfilesDataTable({
|
|||||||
meta.setRenameError(null);
|
meta.setRenameError(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
|
className="w-full min-w-0 max-w-full h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const display =
|
const display = (
|
||||||
name.length < 14 ? (
|
<OverflowTooltipText
|
||||||
<div className="font-medium text-left leading-none truncate">
|
text={name}
|
||||||
{name}
|
className="font-medium text-left leading-none"
|
||||||
</div>
|
/>
|
||||||
) : (
|
);
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="leading-none block truncate">
|
|
||||||
{trimName(name, 14)}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{name}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
|
|
||||||
const isCrossOs = isCrossOsProfile(profile);
|
const isCrossOs = isCrossOsProfile(profile);
|
||||||
const isCrossOsBlocked = isCrossOs;
|
const isCrossOsBlocked = isCrossOs;
|
||||||
@@ -2528,7 +2621,6 @@ export function ProfilesDataTable({
|
|||||||
? effectiveProxy.name
|
? effectiveProxy.name
|
||||||
: meta.t("profiles.table.notSelected");
|
: meta.t("profiles.table.notSelected");
|
||||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||||
const tooltipText = hasAssignment ? displayName : null;
|
|
||||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||||
|
|
||||||
@@ -2562,42 +2654,12 @@ export function ProfilesDataTable({
|
|||||||
meta.setOpenProxySelectorFor(open ? profile.id : null);
|
meta.setOpenProxySelectorFor(open ? profile.id : null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<ProxyCellTrigger
|
||||||
<TooltipTrigger asChild>
|
displayName={displayName}
|
||||||
<PopoverTrigger asChild>
|
hasAssignment={hasAssignment}
|
||||||
<span
|
vpnBadge={vpnBadge}
|
||||||
className={cn(
|
isDisabled={isDisabled}
|
||||||
"flex gap-2 items-center px-2 py-1 rounded",
|
/>
|
||||||
isDisabled
|
|
||||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
|
||||||
: "cursor-pointer hover:bg-accent/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{vpnBadge && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] px-1 py-0 leading-tight"
|
|
||||||
>
|
|
||||||
{vpnBadge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm",
|
|
||||||
!hasAssignment && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hasAssignment
|
|
||||||
? trimName(displayName, 10)
|
|
||||||
: displayName}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{tooltipText && (
|
|
||||||
<TooltipContent>{tooltipText}</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{!isDisabled && (
|
{!isDisabled && (
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
@@ -2861,15 +2923,29 @@ export function ProfilesDataTable({
|
|||||||
[t, setProfileForInfoDialog],
|
[t, setProfileForInfoDialog],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Low-priority columns leave the table as the container narrows (most
|
||||||
|
// expendable first); their data stays reachable via the profile info
|
||||||
|
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
|
||||||
|
const [columnVisibility, setColumnVisibility] =
|
||||||
|
React.useState<VisibilityState>({});
|
||||||
|
|
||||||
|
// Content columns grow proportionally with the container but never drop
|
||||||
|
// below the compact-layout floor; the name column takes the remainder.
|
||||||
|
// Computed in px from the observed container width because fixed table
|
||||||
|
// layout ignores max()/calc() column widths.
|
||||||
|
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: profiles,
|
data: profiles,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
|
columnVisibility,
|
||||||
},
|
},
|
||||||
onSortingChange: handleSortingChange,
|
onSortingChange: handleSortingChange,
|
||||||
onRowSelectionChange: handleRowSelectionChange,
|
onRowSelectionChange: handleRowSelectionChange,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
enableRowSelection: (row) => {
|
enableRowSelection: (row) => {
|
||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
const isRunning =
|
const isRunning =
|
||||||
@@ -2885,9 +2961,50 @@ export function ProfilesDataTable({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const columnWidth = React.useCallback(
|
||||||
|
(id: string, sizePx: number) => {
|
||||||
|
const proportions: Record<string, { pct: number; floor: number }> = {
|
||||||
|
tags: { pct: 0.12, floor: 100 },
|
||||||
|
note: { pct: 0.1, floor: 80 },
|
||||||
|
proxy: { pct: 0.13, floor: 110 },
|
||||||
|
ext: { pct: 0.11, floor: 95 },
|
||||||
|
dns: { pct: 0.11, floor: 95 },
|
||||||
|
};
|
||||||
|
const p = proportions[id];
|
||||||
|
if (!p) return `${sizePx}px`;
|
||||||
|
return `${Math.max(p.floor, Math.round(containerWidth * p.pct))}px`;
|
||||||
|
},
|
||||||
|
[containerWidth],
|
||||||
|
);
|
||||||
const sortedRows = table.getRowModel().rows;
|
const sortedRows = table.getRowModel().rows;
|
||||||
useScrollFade(scrollParentRef);
|
useScrollFade(scrollParentRef);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = scrollParentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => {
|
||||||
|
const w = el.clientWidth;
|
||||||
|
setContainerWidth(Math.round(w / 8) * 8);
|
||||||
|
setColumnVisibility((prev) => {
|
||||||
|
const next: VisibilityState = {
|
||||||
|
dns: w >= 768,
|
||||||
|
ext: w >= 672,
|
||||||
|
note: w >= 576,
|
||||||
|
tags: w >= 512,
|
||||||
|
};
|
||||||
|
return Object.keys(next).every((k) => prev[k] === next[k])
|
||||||
|
? prev
|
||||||
|
: next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Compact 36px row from the redesign spec; estimateSize must match the
|
// Compact 36px row from the redesign spec; estimateSize must match the
|
||||||
// actual rendered row height or virtualizer placement drifts under scroll.
|
// actual rendered row height or virtualizer placement drifts under scroll.
|
||||||
const ROW_HEIGHT = 36;
|
const ROW_HEIGHT = 36;
|
||||||
@@ -2912,7 +3029,13 @@ export function ProfilesDataTable({
|
|||||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||||
<div
|
<div
|
||||||
ref={scrollParentRef}
|
ref={scrollParentRef}
|
||||||
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
|
className={cn(
|
||||||
|
"overflow-auto relative flex-1 min-h-0 scroll-fade",
|
||||||
|
// Clearance for the floating selection action bar (bottom-6 +
|
||||||
|
// ~46px tall) so the last rows can scroll out from behind it.
|
||||||
|
// Same predicate DataTableActionBar uses for its visibility.
|
||||||
|
table.getFilteredSelectedRowModel().rows.length > 0 && "pb-20",
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
// Sticky table header is 32px tall (h-8); shift the top
|
// Sticky table header is 32px tall (h-8); shift the top
|
||||||
@@ -2922,7 +3045,7 @@ export function ProfilesDataTable({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table className="table-fixed">
|
<Table className="table-fixed" containerClassName="overflow-visible">
|
||||||
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
|
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -2934,9 +3057,12 @@ export function ProfilesDataTable({
|
|||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
style={{
|
style={{
|
||||||
width: header.column.columnDef.size
|
width: header.column.columnDef.meta?.flexWidth
|
||||||
? `${header.column.getSize()}px`
|
? undefined
|
||||||
: undefined,
|
: columnWidth(
|
||||||
|
header.column.id,
|
||||||
|
header.column.getSize(),
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
@@ -2955,7 +3081,7 @@ export function ProfilesDataTable({
|
|||||||
{sortedRows.length === 0 ? (
|
{sortedRows.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={columns.length}
|
colSpan={table.getVisibleLeafColumns().length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t("profiles.table.empty")}
|
{t("profiles.table.empty")}
|
||||||
@@ -2965,7 +3091,7 @@ export function ProfilesDataTable({
|
|||||||
<>
|
<>
|
||||||
{paddingTop > 0 && (
|
{paddingTop > 0 && (
|
||||||
<tr style={{ height: `${paddingTop}px` }}>
|
<tr style={{ height: `${paddingTop}px` }}>
|
||||||
<td colSpan={columns.length} />
|
<td colSpan={table.getVisibleLeafColumns().length} />
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{virtualRows.map((virtualRow) => {
|
{virtualRows.map((virtualRow) => {
|
||||||
@@ -2997,9 +3123,12 @@ export function ProfilesDataTable({
|
|||||||
key={cell.id}
|
key={cell.id}
|
||||||
className="overflow-visible py-0"
|
className="overflow-visible py-0"
|
||||||
style={{
|
style={{
|
||||||
width: cell.column.columnDef.size
|
width: cell.column.columnDef.meta?.flexWidth
|
||||||
? `${cell.column.getSize()}px`
|
? undefined
|
||||||
: undefined,
|
: columnWidth(
|
||||||
|
cell.column.id,
|
||||||
|
cell.column.getSize(),
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -3013,7 +3142,7 @@ export function ProfilesDataTable({
|
|||||||
})}
|
})}
|
||||||
{paddingBottom > 0 && (
|
{paddingBottom > 0 && (
|
||||||
<tr style={{ height: `${paddingBottom}px` }}>
|
<tr style={{ height: `${paddingBottom}px` }}>
|
||||||
<td colSpan={columns.length} />
|
<td colSpan={table.getVisibleLeafColumns().length} />
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
LuClipboardCheck,
|
LuClipboardCheck,
|
||||||
LuCookie,
|
LuCookie,
|
||||||
LuCopy,
|
LuCopy,
|
||||||
|
LuDownload,
|
||||||
LuFingerprint,
|
LuFingerprint,
|
||||||
LuGlobe,
|
LuGlobe,
|
||||||
LuGroup,
|
LuGroup,
|
||||||
@@ -39,6 +42,12 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
@@ -263,9 +272,9 @@ export function ProfileInfoDialog({
|
|||||||
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
||||||
: null;
|
: null;
|
||||||
const networkLabel = vpnName
|
const networkLabel = vpnName
|
||||||
? `VPN: ${vpnName}`
|
? t("profileInfo.network.vpnLabel", { name: vpnName })
|
||||||
: proxyName
|
: proxyName
|
||||||
? `Proxy: ${proxyName}`
|
? t("profileInfo.network.proxyLabel", { name: proxyName })
|
||||||
: t("profileInfo.values.none");
|
: t("profileInfo.values.none");
|
||||||
|
|
||||||
const syncStatus = syncStatuses[profile.id];
|
const syncStatus = syncStatuses[profile.id];
|
||||||
@@ -299,6 +308,10 @@ export function ProfileInfoDialog({
|
|||||||
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
||||||
// a navigation hub.
|
// a navigation hub.
|
||||||
interface ActionItem {
|
interface ActionItem {
|
||||||
|
// Stable, language-independent key used to map sidebar sections to actions.
|
||||||
|
// The sidebar must NOT match on `label` — labels are translated, so English
|
||||||
|
// substring matching hides sections for every non-English user.
|
||||||
|
id?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -311,6 +324,7 @@ export function ProfileInfoDialog({
|
|||||||
|
|
||||||
const actions: ActionItem[] = [
|
const actions: ActionItem[] = [
|
||||||
{
|
{
|
||||||
|
id: "network",
|
||||||
icon: <LuGlobe className="size-4" />,
|
icon: <LuGlobe className="size-4" />,
|
||||||
label: t("profiles.actions.viewNetwork"),
|
label: t("profiles.actions.viewNetwork"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -319,6 +333,7 @@ export function ProfileInfoDialog({
|
|||||||
disabled: isCrossOs,
|
disabled: isCrossOs,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "sync",
|
||||||
icon: <LuRefreshCw className="size-4" />,
|
icon: <LuRefreshCw className="size-4" />,
|
||||||
label: t("profiles.actions.syncSettings"),
|
label: t("profiles.actions.syncSettings"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -337,6 +352,7 @@ export function ProfileInfoDialog({
|
|||||||
runningBadge: isRunning,
|
runningBadge: isRunning,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "fingerprint",
|
||||||
icon: <LuFingerprint className="size-4" />,
|
icon: <LuFingerprint className="size-4" />,
|
||||||
label: t("profiles.actions.changeFingerprint"),
|
label: t("profiles.actions.changeFingerprint"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -359,6 +375,7 @@ export function ProfileInfoDialog({
|
|||||||
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "cookiesCopy",
|
||||||
icon: <LuCopy className="size-4" />,
|
icon: <LuCopy className="size-4" />,
|
||||||
label: t("profiles.actions.copyCookiesToProfile"),
|
label: t("profiles.actions.copyCookiesToProfile"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -372,6 +389,7 @@ export function ProfileInfoDialog({
|
|||||||
!onCopyCookiesToProfile,
|
!onCopyCookiesToProfile,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "cookiesManage",
|
||||||
icon: <LuCookie className="size-4" />,
|
icon: <LuCookie className="size-4" />,
|
||||||
label: t("profileInfo.actions.manageCookies"),
|
label: t("profileInfo.actions.manageCookies"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -395,6 +413,7 @@ export function ProfileInfoDialog({
|
|||||||
hidden: profile.ephemeral === true,
|
hidden: profile.ephemeral === true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "extension",
|
||||||
icon: <LuPuzzle className="size-4" />,
|
icon: <LuPuzzle className="size-4" />,
|
||||||
label: t("profileInfo.actions.assignExtensionGroup"),
|
label: t("profileInfo.actions.assignExtensionGroup"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -419,6 +438,7 @@ export function ProfileInfoDialog({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "hook",
|
||||||
icon: <LuLink className="size-4" />,
|
icon: <LuLink className="size-4" />,
|
||||||
label: t("profiles.actions.launchHook"),
|
label: t("profiles.actions.launchHook"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -461,6 +481,7 @@ export function ProfileInfoDialog({
|
|||||||
destructive: true,
|
destructive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "delete",
|
||||||
icon: <LuTrash2 className="size-4" />,
|
icon: <LuTrash2 className="size-4" />,
|
||||||
label: t("profiles.actions.delete"),
|
label: t("profiles.actions.delete"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -482,7 +503,7 @@ export function ProfileInfoDialog({
|
|||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
hideClose
|
hideClose
|
||||||
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
|
className="max-w-[min(60rem,calc(100%-4rem))] h-[min(clamp(30rem,80vh,48rem),calc(100vh-3rem))] flex flex-col p-0 gap-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* The dialog renders its own custom header, so the accessible title is
|
{/* The dialog renders its own custom header, so the accessible title is
|
||||||
visually hidden but present for screen readers (Radix requires it). */}
|
visually hidden but present for screen readers (Radix requires it). */}
|
||||||
@@ -534,6 +555,7 @@ interface ProfileInfoLayoutProps {
|
|||||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||||
onKillProfile?: (profile: BrowserProfile) => void;
|
onKillProfile?: (profile: BrowserProfile) => void;
|
||||||
visibleActions: {
|
visibleActions: {
|
||||||
|
id?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -579,22 +601,23 @@ function ProfileInfoLayout({
|
|||||||
}: ProfileInfoLayoutProps) {
|
}: ProfileInfoLayoutProps) {
|
||||||
const [section, setSection] = React.useState<ProfileSection>("overview");
|
const [section, setSection] = React.useState<ProfileSection>("overview");
|
||||||
|
|
||||||
// Map sidebar items to existing action labels, so clicking a section
|
// Map sidebar items to existing actions by their stable, language-independent
|
||||||
// simply triggers the existing dialog handler.
|
// `id`, so clicking a section triggers the existing dialog handler. Matching
|
||||||
|
// on `label` would break for every non-English locale (the labels are
|
||||||
|
// translated) and hide whole sections.
|
||||||
const findAction = React.useCallback(
|
const findAction = React.useCallback(
|
||||||
(substr: string) =>
|
(id: string) => visibleActions.find((a) => a.id === id),
|
||||||
visibleActions.find((a) => a.label.toLowerCase().includes(substr)),
|
|
||||||
[visibleActions],
|
[visibleActions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAction = findAction("delete");
|
const deleteAction = findAction("delete");
|
||||||
const fingerprintAction = findAction("fingerprint");
|
const fingerprintAction = findAction("fingerprint");
|
||||||
const cookiesManageAction = findAction("manage cookies");
|
const cookiesManageAction = findAction("cookiesManage");
|
||||||
const cookiesCopyAction = findAction("copy cookies");
|
const cookiesCopyAction = findAction("cookiesCopy");
|
||||||
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
||||||
const extensionAction = findAction("extension");
|
const extensionAction = findAction("extension");
|
||||||
const syncAction = findAction("sync");
|
const syncAction = findAction("sync");
|
||||||
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
const _launchHookAction = findAction("hook");
|
||||||
const _networkAction = findAction("network");
|
const _networkAction = findAction("network");
|
||||||
// Password actions are no longer routed via the legacy action handlers —
|
// Password actions are no longer routed via the legacy action handlers —
|
||||||
// SecuritySectionInline writes directly to the backend instead.
|
// SecuritySectionInline writes directly to the backend instead.
|
||||||
@@ -1149,7 +1172,7 @@ function SyncSectionInline({
|
|||||||
syncMode: mode,
|
syncMode: mode,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1192,7 +1215,9 @@ function SyncSectionInline({
|
|||||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
{t("profileInfo.fields.syncStatus")}
|
{t("profileInfo.fields.syncStatus")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm mt-0.5">{syncStatus.status}</p>
|
<p className="text-sm mt-0.5">
|
||||||
|
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
|
||||||
|
</p>
|
||||||
{syncStatus.error && (
|
{syncStatus.error && (
|
||||||
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1246,7 +1271,7 @@ function NetworkSectionInline({
|
|||||||
setProxyId(nextId);
|
setProxyId(nextId);
|
||||||
if (nextId !== null) setVpnId(null);
|
if (nextId !== null) setVpnId(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1264,7 +1289,7 @@ function NetworkSectionInline({
|
|||||||
setVpnId(nextId);
|
setVpnId(nextId);
|
||||||
if (nextId !== null) setProxyId(null);
|
if (nextId !== null) setProxyId(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1370,7 +1395,7 @@ function ExtensionsSectionInline({
|
|||||||
);
|
);
|
||||||
if (mounted) setGroups(data);
|
if (mounted) setGroups(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setError(String(e));
|
if (mounted) setError(translateBackendError(t as never, e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void load();
|
void load();
|
||||||
@@ -1384,7 +1409,7 @@ function ExtensionsSectionInline({
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
const onChange = async (value: string) => {
|
const onChange = async (value: string) => {
|
||||||
const next = value === "__none__" ? null : value;
|
const next = value === "__none__" ? null : value;
|
||||||
@@ -1397,7 +1422,7 @@ function ExtensionsSectionInline({
|
|||||||
});
|
});
|
||||||
setGroupId(next);
|
setGroupId(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1495,6 +1520,41 @@ function CookiesSectionInline({
|
|||||||
};
|
};
|
||||||
}, [profile.id, isRunning, t]);
|
}, [profile.id, isRunning, t]);
|
||||||
|
|
||||||
|
const [isExporting, setIsExporting] = React.useState(false);
|
||||||
|
|
||||||
|
// Export all of this profile's cookies in one of the same formats import
|
||||||
|
// accepts (JSON or Netscape). The backend formats every cookie; we just pick
|
||||||
|
// a destination file.
|
||||||
|
const handleExport = React.useCallback(
|
||||||
|
async (format: "json" | "netscape") => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const content = await invoke<string>("export_profile_cookies", {
|
||||||
|
profileId: profile.id,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
const ext = format === "json" ? "json" : "txt";
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: `${profile.name}_cookies.${ext}`,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: format === "json" ? "JSON" : "Text",
|
||||||
|
extensions: [ext],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!filePath) return;
|
||||||
|
await writeTextFile(filePath, content);
|
||||||
|
showSuccessToast(t("cookies.export.success"));
|
||||||
|
} catch (e) {
|
||||||
|
showErrorToast(translateBackendError(t as never, e));
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[profile.id, profile.name, t],
|
||||||
|
);
|
||||||
|
|
||||||
const domains = stats?.domains ?? [];
|
const domains = stats?.domains ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1505,6 +1565,41 @@ function CookiesSectionInline({
|
|||||||
{t("profileInfo.sections.cookies")}
|
{t("profileInfo.sections.cookies")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5"
|
||||||
|
disabled={
|
||||||
|
isDisabled ||
|
||||||
|
isRunning ||
|
||||||
|
isExporting ||
|
||||||
|
!stats ||
|
||||||
|
stats.total_count === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LuDownload className="size-3.5" />
|
||||||
|
{t("common.buttons.export")}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
void handleExport("json");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("cookies.export.json")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
void handleExport("netscape");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("cookies.export.netscape")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
{onImportCookies && (
|
{onImportCookies && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1514,7 +1609,7 @@ function CookiesSectionInline({
|
|||||||
onClick={onImportCookies}
|
onClick={onImportCookies}
|
||||||
>
|
>
|
||||||
<LuUpload className="size-3.5" />
|
<LuUpload className="size-3.5" />
|
||||||
{t("cookies.import.title")}
|
{t("common.buttons.import")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCopyCookies && (
|
{onCopyCookies && (
|
||||||
@@ -1526,7 +1621,7 @@ function CookiesSectionInline({
|
|||||||
onClick={onCopyCookies}
|
onClick={onCopyCookies}
|
||||||
>
|
>
|
||||||
<LuCopy className="size-3.5" />
|
<LuCopy className="size-3.5" />
|
||||||
{t("profiles.actions.copyCookies")}
|
{t("common.buttons.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1684,7 +1779,7 @@ function FingerprintSectionInline({
|
|||||||
// Close the dialog once the fingerprint is saved.
|
// Close the dialog once the fingerprint is saved.
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function ProfilePasswordDialog({
|
|||||||
if (!open) onClose();
|
if (!open) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t(titleKey)}</DialogTitle>
|
<DialogTitle>{t(titleKey)}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{(mode === "set" || mode === "change") && (
|
{(mode === "set" || mode === "change") && (
|
||||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
||||||
<p className="font-medium text-warning-foreground">
|
<p className="font-medium text-warning">
|
||||||
{t("profilePassword.warnings.forgetWarningTitle")}
|
{t("profilePassword.warnings.forgetWarningTitle")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function ProfileSelectorDialog({
|
|||||||
successMessage={t("profileSelector.urlCopied")}
|
successMessage={t("profileSelector.urlCopied")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
<div className="p-2 text-sm break-all rounded bg-muted max-h-24 overflow-y-auto">
|
||||||
{url}
|
{url}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||||
|
import { getEntitlements } from "@/lib/entitlements";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
||||||
import { isSyncEnabled } from "@/types";
|
import { isSyncEnabled } from "@/types";
|
||||||
@@ -36,11 +37,7 @@ export function ProfileSyncDialog({
|
|||||||
}: ProfileSyncDialogProps) {
|
}: ProfileSyncDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user: cloudUser } = useCloudAuth();
|
const { user: cloudUser } = useCloudAuth();
|
||||||
const isCloudSyncEligible =
|
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
|
||||||
cloudUser != null &&
|
|
||||||
cloudUser.plan !== "free" &&
|
|
||||||
(cloudUser.subscriptionStatus === "active" ||
|
|
||||||
cloudUser.planPeriod === "lifetime");
|
|
||||||
// Encryption available to everyone except team members who aren't owners
|
// Encryption available to everyone except team members who aren't owners
|
||||||
const canUseEncryption =
|
const canUseEncryption =
|
||||||
cloudUser == null ||
|
cloudUser == null ||
|
||||||
@@ -175,8 +172,8 @@ export function ProfileSyncDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md flex flex-col overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("sync.mode.description", {
|
{t("sync.mode.description", {
|
||||||
@@ -186,115 +183,117 @@ export function ProfileSyncDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isCheckingConfig ? (
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="flex justify-center py-8">
|
{isCheckingConfig ? (
|
||||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
<div className="flex justify-center py-8">
|
||||||
</div>
|
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||||
) : (
|
</div>
|
||||||
<div className="grid gap-4 py-4">
|
) : (
|
||||||
{!hasConfig && (
|
<div className="grid gap-4 py-4">
|
||||||
<div className="p-3 text-sm rounded-md bg-muted">
|
{!hasConfig && (
|
||||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
<div className="p-3 text-sm rounded-md bg-muted">
|
||||||
<Button
|
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
onSyncConfigOpen();
|
onClick={() => {
|
||||||
onClose();
|
onSyncConfigOpen();
|
||||||
}}
|
onClose();
|
||||||
>
|
}}
|
||||||
{t("sync.mode.configureService")}
|
>
|
||||||
</Button>
|
{t("sync.mode.configureService")}
|
||||||
</div>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
{hasConfig && (
|
|
||||||
<>
|
|
||||||
<RadioGroup
|
|
||||||
value={syncMode}
|
|
||||||
onValueChange={handleModeChange}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="grid gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-x-3">
|
|
||||||
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
|
||||||
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
|
||||||
<span className="font-medium">
|
|
||||||
{t("sync.mode.disabled")}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("sync.mode.disabledDescription")}
|
|
||||||
</p>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-x-3">
|
|
||||||
<RadioGroupItem value="Regular" id="sync-regular" />
|
|
||||||
<Label htmlFor="sync-regular" className="cursor-pointer">
|
|
||||||
<span className="font-medium">
|
|
||||||
{t("sync.mode.regular")}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("sync.mode.regularDescription")}
|
|
||||||
</p>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-x-3">
|
|
||||||
<RadioGroupItem
|
|
||||||
value="Encrypted"
|
|
||||||
id="sync-encrypted"
|
|
||||||
disabled={!canUseEncryption}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="sync-encrypted"
|
|
||||||
className={
|
|
||||||
canUseEncryption
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "cursor-not-allowed opacity-50"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="font-medium">
|
|
||||||
{t("sync.mode.encrypted")}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{canUseEncryption
|
|
||||||
? t("sync.mode.encryptedDescription")
|
|
||||||
: t("settings.encryption.requiresProOrOwner")}
|
|
||||||
</p>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
{syncMode === "Encrypted" &&
|
|
||||||
!hasE2ePassword &&
|
|
||||||
userChangedMode && (
|
|
||||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
|
||||||
{t("sync.mode.noPasswordWarning")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t("sync.mode.lastSynced")}</Label>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{formatLastSync(profile.last_sync)}
|
|
||||||
</Badge>
|
|
||||||
{isSyncEnabled(profile) && (
|
|
||||||
<Badge
|
|
||||||
variant={profile.last_sync ? "default" : "secondary"}
|
|
||||||
>
|
|
||||||
{profile.last_sync
|
|
||||||
? t("common.status.synced")
|
|
||||||
: t("common.status.pending")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{hasConfig && (
|
||||||
)}
|
<>
|
||||||
|
<RadioGroup
|
||||||
|
value={syncMode}
|
||||||
|
onValueChange={handleModeChange}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="grid gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-x-3">
|
||||||
|
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
||||||
|
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("sync.mode.disabled")}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sync.mode.disabledDescription")}
|
||||||
|
</p>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-x-3">
|
||||||
|
<RadioGroupItem value="Regular" id="sync-regular" />
|
||||||
|
<Label htmlFor="sync-regular" className="cursor-pointer">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("sync.mode.regular")}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sync.mode.regularDescription")}
|
||||||
|
</p>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-x-3">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="Encrypted"
|
||||||
|
id="sync-encrypted"
|
||||||
|
disabled={!canUseEncryption}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="sync-encrypted"
|
||||||
|
className={
|
||||||
|
canUseEncryption
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "cursor-not-allowed opacity-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("sync.mode.encrypted")}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{canUseEncryption
|
||||||
|
? t("sync.mode.encryptedDescription")
|
||||||
|
: t("settings.encryption.requiresProOrOwner")}
|
||||||
|
</p>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{syncMode === "Encrypted" &&
|
||||||
|
!hasE2ePassword &&
|
||||||
|
userChangedMode && (
|
||||||
|
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||||
|
{t("sync.mode.noPasswordWarning")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("sync.mode.lastSynced")}</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{formatLastSync(profile.last_sync)}
|
||||||
|
</Badge>
|
||||||
|
{isSyncEnabled(profile) && (
|
||||||
|
<Badge
|
||||||
|
variant={profile.last_sync ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{profile.last_sync
|
||||||
|
? t("common.status.synced")
|
||||||
|
: t("common.status.pending")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function ProxyAssignmentDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
|
<Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
|
||||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
<div className="p-3 bg-muted rounded-md max-h-[min(8rem,20vh)] overflow-y-auto">
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
{selectedProfiles.map((profileId) => {
|
{selectedProfiles.map((profileId) => {
|
||||||
const profile = profiles.find(
|
const profile = profiles.find(
|
||||||
@@ -206,7 +206,7 @@ export function ProxyAssignmentDialog({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
id={proxyListboxId}
|
id={proxyListboxId}
|
||||||
className="w-[240px] p-0"
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<Command>
|
<Command>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
|
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -125,7 +125,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("proxies.exportDialog.preview")}</Label>
|
<Label>{t("proxies.exportDialog.preview")}</Label>
|
||||||
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
|
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md bg-muted/30">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||||
{t("common.buttons.loading")}
|
{t("common.buttons.loading")}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export function ProxyFormDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4 @container">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -228,12 +228,12 @@ export function ProxyFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @sm:grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="proxy-username">
|
<Label htmlFor="proxy-username">
|
||||||
{form.proxy_type === "ss"
|
{form.proxy_type === "ss"
|
||||||
? t("proxies.form.cipher")
|
? t("proxies.form.cipher")
|
||||||
: `${t("proxies.form.username")} (${t("proxies.form.usernamePlaceholder")})`}
|
: t("proxies.form.username")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="proxy-username"
|
id="proxy-username"
|
||||||
@@ -252,9 +252,7 @@ export function ProxyFormDialog({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="proxy-password">
|
<Label htmlFor="proxy-password">
|
||||||
{form.proxy_type === "ss"
|
{t("proxies.form.password")}
|
||||||
? t("proxies.form.password")
|
|
||||||
: `${t("proxies.form.password")} (${t("proxies.form.passwordPlaceholder")})`}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="proxy-password"
|
id="proxy-password"
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
|
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -376,12 +376,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<ScrollArea className="h-[200px] border rounded-md">
|
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md">
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
{parsedProxies.map((proxy, i) => (
|
{parsedProxies.map((proxy, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${proxy.original_line}-${i}`}
|
key={`${proxy.original_line}-${i}`}
|
||||||
className="text-xs font-mono p-2 bg-muted/30 rounded"
|
className="text-xs font-mono p-2 bg-muted/30 rounded break-all"
|
||||||
>
|
>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{proxy.proxy_type}://
|
{proxy.proxy_type}://
|
||||||
@@ -407,14 +407,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("proxies.importDialog.ambiguousIntro")}
|
{t("proxies.importDialog.ambiguousIntro")}
|
||||||
</p>
|
</p>
|
||||||
<ScrollArea className="h-[250px] border rounded-md">
|
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
|
||||||
<div className="p-3 space-y-4">
|
<div className="p-3 space-y-4">
|
||||||
{ambiguousProxies.map((proxy, i) => (
|
{ambiguousProxies.map((proxy, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${proxy.line}-${i}`}
|
key={`${proxy.line}-${i}`}
|
||||||
className="space-y-2 pb-3 border-b last:border-0"
|
className="space-y-2 pb-3 border-b last:border-0"
|
||||||
>
|
>
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded block">
|
<code className="text-xs bg-muted px-2 py-1 rounded block break-all">
|
||||||
{proxy.line}
|
{proxy.line}
|
||||||
</code>
|
</code>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ export function ProxyManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
|
size: 28,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -551,11 +552,14 @@ export function ProxyManagementDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">{row.original.name}</span>
|
<span className="font-medium block truncate">
|
||||||
|
{row.original.name}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "protocol",
|
id: "protocol",
|
||||||
|
size: 96,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("proxies.management.protocolCol"),
|
header: () => t("proxies.management.protocolCol"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -564,8 +568,20 @@ export function ProxyManagementDialog({
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "hostPort",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => t("proxies.management.hostPort"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground block truncate">
|
||||||
|
{row.original.proxy_settings.host}:
|
||||||
|
{row.original.proxy_settings.port}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "usage",
|
id: "usage",
|
||||||
|
size: 80,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("proxies.management.usage"),
|
header: () => t("proxies.management.usage"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -574,6 +590,7 @@ export function ProxyManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sync",
|
id: "sync",
|
||||||
|
size: 96,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("proxies.management.syncCol"),
|
header: () => t("proxies.management.syncCol"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -607,6 +624,7 @@ export function ProxyManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
size: 144,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("common.labels.actions"),
|
header: () => t("common.labels.actions"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -775,7 +793,7 @@ export function ProxyManagementDialog({
|
|||||||
vpnSyncErrors[vpn.id],
|
vpnSyncErrors[vpn.id],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 font-medium">
|
<div className="flex items-center gap-2 font-medium min-w-0">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -788,19 +806,21 @@ export function ProxyManagementDialog({
|
|||||||
<p>{syncDot.tooltip}</p>
|
<p>{syncDot.tooltip}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{vpn.name}
|
<span className="truncate">{vpn.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "type",
|
id: "type",
|
||||||
|
size: 96,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("common.labels.type"),
|
header: () => t("common.labels.type"),
|
||||||
cell: () => <Badge variant="outline">WG</Badge>,
|
cell: () => <Badge variant="outline">WG</Badge>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "usage",
|
id: "usage",
|
||||||
|
size: 80,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("proxies.management.usage"),
|
header: () => t("proxies.management.usage"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -809,6 +829,7 @@ export function ProxyManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sync",
|
id: "sync",
|
||||||
|
size: 96,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("proxies.management.syncCol"),
|
header: () => t("proxies.management.syncCol"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -842,6 +863,7 @@ export function ProxyManagementDialog({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
size: 144,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => t("common.labels.actions"),
|
header: () => t("common.labels.actions"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -1068,7 +1090,7 @@ export function ProxyManagementDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[85vh] flex flex-col">
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||||
@@ -1078,251 +1100,355 @@ export function ProxyManagementDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatedTabs
|
<div className="@container w-full flex-1 min-h-0 flex flex-col">
|
||||||
key={initialTab}
|
<AnimatedTabs
|
||||||
defaultValue={initialTab}
|
key={initialTab}
|
||||||
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
defaultValue={initialTab}
|
||||||
className="flex-1 min-h-0 flex flex-col"
|
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
||||||
>
|
className="flex-1 min-h-0 flex flex-col"
|
||||||
<div className="flex items-center justify-between gap-3 shrink-0">
|
|
||||||
<AnimatedTabsList>
|
|
||||||
<AnimatedTabsTrigger value="proxies">
|
|
||||||
<span>{t("proxies.management.tabProxies")}</span>
|
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">
|
|
||||||
{storedProxies.length}
|
|
||||||
</span>
|
|
||||||
</AnimatedTabsTrigger>
|
|
||||||
<AnimatedTabsTrigger value="vpns">
|
|
||||||
<span>{t("proxies.management.tabVpns")}</span>
|
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">
|
|
||||||
{vpnConfigs.length}
|
|
||||||
</span>
|
|
||||||
</AnimatedTabsTrigger>
|
|
||||||
</AnimatedTabsList>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{activeTab === "proxies" && (
|
|
||||||
<>
|
|
||||||
<RippleButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setShowImportDialog(true);
|
|
||||||
}}
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<LuUpload className="size-4" />
|
|
||||||
{t("common.buttons.import")}
|
|
||||||
</RippleButton>
|
|
||||||
<RippleButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setShowExportDialog(true);
|
|
||||||
}}
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
disabled={storedProxies.length === 0}
|
|
||||||
>
|
|
||||||
<LuDownload className="size-4" />
|
|
||||||
{t("common.buttons.export")}
|
|
||||||
</RippleButton>
|
|
||||||
<RippleButton
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCreateProxy}
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<GoPlus className="size-4" />
|
|
||||||
{t("proxies.management.newProxy")}
|
|
||||||
</RippleButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === "vpns" && (
|
|
||||||
<>
|
|
||||||
<RippleButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setShowVpnImportDialog(true);
|
|
||||||
}}
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<LuUpload className="size-4" />
|
|
||||||
{t("common.buttons.import")}
|
|
||||||
</RippleButton>
|
|
||||||
<RippleButton
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCreateVpn}
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<GoPlus className="size-4" />
|
|
||||||
{t("proxies.management.newVpn")}
|
|
||||||
</RippleButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatedTabsContent
|
|
||||||
value="proxies"
|
|
||||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||||
{isLoading ? (
|
<AnimatedTabsList>
|
||||||
<div className="text-sm text-muted-foreground">
|
<AnimatedTabsTrigger value="proxies">
|
||||||
{t("proxies.management.loading")}
|
<span>{t("proxies.management.tabProxies")}</span>
|
||||||
</div>
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
) : storedProxies.length === 0 ? (
|
{storedProxies.length}
|
||||||
<div className="text-sm text-muted-foreground">
|
</span>
|
||||||
{t("proxies.management.noneCreated")}
|
</AnimatedTabsTrigger>
|
||||||
</div>
|
<AnimatedTabsTrigger value="vpns">
|
||||||
) : (
|
<span>{t("proxies.management.tabVpns")}</span>
|
||||||
<FadingScrollArea
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
className="flex-1 min-h-0"
|
{vpnConfigs.length}
|
||||||
style={
|
</span>
|
||||||
{
|
</AnimatedTabsTrigger>
|
||||||
"--scroll-fade-top-offset": "32px",
|
</AnimatedTabsList>
|
||||||
} as React.CSSProperties
|
<div className="flex items-center gap-2">
|
||||||
}
|
{activeTab === "proxies" && (
|
||||||
>
|
<>
|
||||||
<Table className="w-full">
|
<Tooltip>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TooltipTrigger asChild>
|
||||||
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
<RippleButton
|
||||||
<TableRow key={headerGroup.id}>
|
size="sm"
|
||||||
{headerGroup.headers.map((header) => (
|
variant="outline"
|
||||||
<TableHead
|
onClick={() => {
|
||||||
key={header.id}
|
setShowImportDialog(true);
|
||||||
style={{
|
}}
|
||||||
width: header.column.columnDef.size
|
className="flex gap-2 items-center"
|
||||||
? `${header.column.getSize()}px`
|
aria-label={t("common.buttons.import")}
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
header.column.id !== "name" &&
|
|
||||||
header.column.id !== "select" &&
|
|
||||||
"whitespace-nowrap w-px",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{proxiesTable.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
<LuUpload className="size-4" />
|
||||||
<TableCell
|
<span className="hidden @2xl:inline">
|
||||||
key={cell.id}
|
{t("common.buttons.import")}
|
||||||
style={{
|
</span>
|
||||||
width: cell.column.columnDef.size
|
</RippleButton>
|
||||||
? `${cell.column.getSize()}px`
|
</TooltipTrigger>
|
||||||
: undefined,
|
<TooltipContent>
|
||||||
}}
|
<p>{t("common.buttons.import")}</p>
|
||||||
>
|
</TooltipContent>
|
||||||
{flexRender(
|
</Tooltip>
|
||||||
cell.column.columnDef.cell,
|
<Tooltip>
|
||||||
cell.getContext(),
|
<TooltipTrigger asChild>
|
||||||
)}
|
<RippleButton
|
||||||
</TableCell>
|
size="sm"
|
||||||
))}
|
variant="outline"
|
||||||
</TableRow>
|
onClick={() => {
|
||||||
))}
|
setShowExportDialog(true);
|
||||||
</TableBody>
|
}}
|
||||||
</Table>
|
className="flex gap-2 items-center"
|
||||||
</FadingScrollArea>
|
aria-label={t("common.buttons.export")}
|
||||||
)}
|
disabled={storedProxies.length === 0}
|
||||||
|
>
|
||||||
|
<LuDownload className="size-4" />
|
||||||
|
<span className="hidden @2xl:inline">
|
||||||
|
{t("common.buttons.export")}
|
||||||
|
</span>
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("common.buttons.export")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RippleButton
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreateProxy}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
aria-label={t("proxies.management.newProxy")}
|
||||||
|
>
|
||||||
|
<GoPlus className="size-4" />
|
||||||
|
<span className="hidden @2xl:inline">
|
||||||
|
{t("proxies.management.newProxy")}
|
||||||
|
</span>
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("proxies.management.newProxy")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activeTab === "vpns" && (
|
||||||
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RippleButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowVpnImportDialog(true);
|
||||||
|
}}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
aria-label={t("common.buttons.import")}
|
||||||
|
>
|
||||||
|
<LuUpload className="size-4" />
|
||||||
|
<span className="hidden @2xl:inline">
|
||||||
|
{t("common.buttons.import")}
|
||||||
|
</span>
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("common.buttons.import")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RippleButton
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreateVpn}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
aria-label={t("proxies.management.newVpn")}
|
||||||
|
>
|
||||||
|
<GoPlus className="size-4" />
|
||||||
|
<span className="hidden @2xl:inline">
|
||||||
|
{t("proxies.management.newVpn")}
|
||||||
|
</span>
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("proxies.management.newVpn")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedTabsContent>
|
|
||||||
|
|
||||||
<AnimatedTabsContent
|
<AnimatedTabsContent
|
||||||
value="vpns"
|
value="proxies"
|
||||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||||
{isLoadingVpns ? (
|
{isLoading ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("vpns.management.loading")}
|
{t("proxies.management.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : vpnConfigs.length === 0 ? (
|
) : storedProxies.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("vpns.management.noneCreated")}
|
{t("proxies.management.noneCreated")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FadingScrollArea
|
<FadingScrollArea
|
||||||
className="flex-1 min-h-0"
|
className={cn(
|
||||||
style={
|
"flex-1 min-h-0",
|
||||||
{
|
selectedProxies.length > 0 && "pb-16",
|
||||||
"--scroll-fade-top-offset": "32px",
|
)}
|
||||||
} as React.CSSProperties
|
style={
|
||||||
}
|
{
|
||||||
>
|
"--scroll-fade-top-offset": "32px",
|
||||||
<Table className="w-full">
|
} as React.CSSProperties
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
}
|
||||||
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
>
|
||||||
<TableRow key={headerGroup.id}>
|
<Table
|
||||||
{headerGroup.headers.map((header) => (
|
className="w-full table-fixed"
|
||||||
<TableHead
|
containerClassName="overflow-visible"
|
||||||
key={header.id}
|
>
|
||||||
style={{
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
width: header.column.columnDef.size
|
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
||||||
? `${header.column.getSize()}px`
|
<TableRow key={headerGroup.id}>
|
||||||
: undefined,
|
{headerGroup.headers.map((header) => (
|
||||||
}}
|
<TableHead
|
||||||
className={cn(
|
key={header.id}
|
||||||
header.column.id !== "name" &&
|
style={{
|
||||||
header.column.id !== "select" &&
|
width:
|
||||||
"whitespace-nowrap w-px",
|
header.column.id === "name" ||
|
||||||
)}
|
header.column.id === "hostPort"
|
||||||
>
|
? undefined
|
||||||
{header.isPlaceholder
|
: `${header.column.getSize()}px`,
|
||||||
? null
|
}}
|
||||||
: flexRender(
|
className={cn(
|
||||||
header.column.columnDef.header,
|
// name and hostPort emit no width, so
|
||||||
header.getContext(),
|
// fixed layout splits the remaining
|
||||||
)}
|
// space evenly between them (hostPort
|
||||||
</TableHead>
|
// hides below @2xl, leaving name all
|
||||||
))}
|
// of it).
|
||||||
</TableRow>
|
header.column.id === "name" && "max-w-0",
|
||||||
))}
|
header.column.id === "hostPort" &&
|
||||||
</TableHeader>
|
"hidden @2xl:table-cell max-w-0",
|
||||||
<TableBody>
|
(header.column.id === "protocol" ||
|
||||||
{vpnsTable.getRowModel().rows.map((row) => (
|
header.column.id === "type") &&
|
||||||
<TableRow
|
"hidden @2xl:table-cell",
|
||||||
key={row.id}
|
)}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
>
|
||||||
>
|
{header.isPlaceholder
|
||||||
{row.getVisibleCells().map((cell) => (
|
? null
|
||||||
<TableCell
|
: flexRender(
|
||||||
key={cell.id}
|
header.column.columnDef.header,
|
||||||
style={{
|
header.getContext(),
|
||||||
width: cell.column.columnDef.size
|
)}
|
||||||
? `${cell.column.getSize()}px`
|
</TableHead>
|
||||||
: undefined,
|
))}
|
||||||
}}
|
</TableRow>
|
||||||
>
|
))}
|
||||||
{flexRender(
|
</TableHeader>
|
||||||
cell.column.columnDef.cell,
|
<TableBody>
|
||||||
cell.getContext(),
|
{proxiesTable.getRowModel().rows.map((row) => (
|
||||||
)}
|
<TableRow
|
||||||
</TableCell>
|
key={row.id}
|
||||||
))}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
</TableRow>
|
>
|
||||||
))}
|
{row.getVisibleCells().map((cell) => (
|
||||||
</TableBody>
|
<TableCell
|
||||||
</Table>
|
key={cell.id}
|
||||||
</FadingScrollArea>
|
style={{
|
||||||
)}
|
width:
|
||||||
</div>
|
cell.column.id === "name" ||
|
||||||
</AnimatedTabsContent>
|
cell.column.id === "hostPort"
|
||||||
</AnimatedTabs>
|
? undefined
|
||||||
|
: `${cell.column.getSize()}px`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
cell.column.id === "name" && "max-w-0",
|
||||||
|
cell.column.id === "hostPort" &&
|
||||||
|
"hidden @2xl:table-cell max-w-0",
|
||||||
|
(cell.column.id === "protocol" ||
|
||||||
|
cell.column.id === "type") &&
|
||||||
|
"hidden @2xl:table-cell",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</FadingScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AnimatedTabsContent>
|
||||||
|
|
||||||
|
<AnimatedTabsContent
|
||||||
|
value="vpns"
|
||||||
|
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||||
|
{isLoadingVpns ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("vpns.management.loading")}
|
||||||
|
</div>
|
||||||
|
) : vpnConfigs.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("vpns.management.noneCreated")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FadingScrollArea
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-h-0",
|
||||||
|
selectedVpns.length > 0 && "pb-16",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--scroll-fade-top-offset": "32px",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
className="w-full table-fixed"
|
||||||
|
containerClassName="overflow-visible"
|
||||||
|
>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
header.column.id === "name" ||
|
||||||
|
header.column.id === "hostPort"
|
||||||
|
? undefined
|
||||||
|
: `${header.column.getSize()}px`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
// name and hostPort emit no width, so
|
||||||
|
// fixed layout splits the remaining
|
||||||
|
// space evenly between them (hostPort
|
||||||
|
// hides below @2xl, leaving name all
|
||||||
|
// of it).
|
||||||
|
header.column.id === "name" && "max-w-0",
|
||||||
|
header.column.id === "hostPort" &&
|
||||||
|
"hidden @2xl:table-cell max-w-0",
|
||||||
|
(header.column.id === "protocol" ||
|
||||||
|
header.column.id === "type") &&
|
||||||
|
"hidden @2xl:table-cell",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{vpnsTable.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
cell.column.id === "name" ||
|
||||||
|
cell.column.id === "hostPort"
|
||||||
|
? undefined
|
||||||
|
: `${cell.column.getSize()}px`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
cell.column.id === "name" && "max-w-0",
|
||||||
|
cell.column.id === "hostPort" &&
|
||||||
|
"hidden @2xl:table-cell max-w-0",
|
||||||
|
(cell.column.id === "protocol" ||
|
||||||
|
cell.column.id === "type") &&
|
||||||
|
"hidden @2xl:table-cell",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</FadingScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AnimatedTabsContent>
|
||||||
|
</AnimatedTabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
+43
-39
@@ -74,8 +74,6 @@ function useLogoEasterEgg({
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const startX = rect.left;
|
const startX = rect.left;
|
||||||
const startY = rect.top;
|
const startY = rect.top;
|
||||||
const floorY = window.innerHeight;
|
|
||||||
const rightWall = window.innerWidth;
|
|
||||||
|
|
||||||
const clone = el.cloneNode(true) as HTMLElement;
|
const clone = el.cloneNode(true) as HTMLElement;
|
||||||
clone.style.position = "fixed";
|
clone.style.position = "fixed";
|
||||||
@@ -99,6 +97,10 @@ function useLogoEasterEgg({
|
|||||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||||
lastTime = time;
|
lastTime = time;
|
||||||
|
|
||||||
|
// Read live so a mid-animation window resize moves the floor/wall.
|
||||||
|
const floorY = window.innerHeight;
|
||||||
|
const rightWall = window.innerWidth;
|
||||||
|
|
||||||
vy += GRAVITY * dt;
|
vy += GRAVITY * dt;
|
||||||
x += vx * dt;
|
x += vx * dt;
|
||||||
y += vy * dt;
|
y += vy * dt;
|
||||||
@@ -294,7 +296,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
|||||||
ref={logoRef}
|
ref={logoRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("header.donutLogo")}
|
aria-label={t("header.donutLogo")}
|
||||||
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
|
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent shrink-0"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
setIsPressed(true);
|
setIsPressed(true);
|
||||||
@@ -331,43 +333,45 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="size-7" />
|
<div className="size-7 shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-5 h-px bg-border my-1" />
|
<div className="w-5 h-px bg-border my-1 shrink-0" />
|
||||||
|
|
||||||
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
<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">
|
||||||
const active = currentPage === page;
|
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
||||||
return (
|
const active = currentPage === page;
|
||||||
<Tooltip key={page} delayDuration={300}>
|
return (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip key={page} delayDuration={300}>
|
||||||
<button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
onNavigate(page);
|
onClick={() => {
|
||||||
}}
|
onNavigate(page);
|
||||||
aria-label={t(labelKey)}
|
}}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-label={t(labelKey)}
|
||||||
className={cn(
|
aria-current={active ? "page" : undefined}
|
||||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
|
className={cn(
|
||||||
active
|
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||||
? "text-foreground bg-accent"
|
active
|
||||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
? "text-foreground bg-accent"
|
||||||
)}
|
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||||
>
|
)}
|
||||||
{active && (
|
>
|
||||||
<span
|
{active && (
|
||||||
aria-hidden="true"
|
<span
|
||||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
aria-hidden="true"
|
||||||
/>
|
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||||
)}
|
/>
|
||||||
<Icon className="size-3.5" />
|
)}
|
||||||
</button>
|
<Icon className="size-3.5" />
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
||||||
);
|
</Tooltip>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
@@ -381,7 +385,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
|||||||
aria-label={t("rail.more.label")}
|
aria-label={t("rail.more.label")}
|
||||||
aria-expanded={moreOpen}
|
aria-expanded={moreOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid place-items-center size-7 rounded-md transition-colors duration-100",
|
"grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||||
moreOpen
|
moreOpen
|
||||||
? "text-foreground bg-accent"
|
? "text-foreground bg-accent"
|
||||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||||
@@ -403,7 +407,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
|||||||
aria-label={t("rail.settings")}
|
aria-label={t("rail.settings")}
|
||||||
aria-current={currentPage === "settings" ? "page" : undefined}
|
aria-current={currentPage === "settings" ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
|
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||||
currentPage === "settings"
|
currentPage === "settings"
|
||||||
? "text-foreground bg-accent"
|
? "text-foreground bg-accent"
|
||||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ export function SettingsDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
||||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
<DialogContent className="max-w-md max-h-[calc(100vh-5rem)] flex flex-col">
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||||
@@ -643,7 +643,7 @@ export function SettingsDialog({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid overflow-y-auto flex-1 gap-6 min-h-0",
|
"grid overflow-y-auto flex-1 gap-6 min-h-0",
|
||||||
subPage ? "py-2" : "py-4",
|
subPage ? "py-2 w-full max-w-2xl mx-auto" : "py-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Appearance Section */}
|
{/* Appearance Section */}
|
||||||
@@ -748,7 +748,7 @@ export function SettingsDialog({
|
|||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{t("settings.appearance.customColors")}
|
{t("settings.appearance.customColors")}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-3">
|
||||||
{THEME_VARIABLES.map(({ key, label }) => {
|
{THEME_VARIABLES.map(({ key, label }) => {
|
||||||
const colorValue =
|
const colorValue =
|
||||||
customThemeState.colors[key] ?? "#000000";
|
customThemeState.colors[key] ?? "#000000";
|
||||||
@@ -1314,7 +1314,7 @@ export function SettingsDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subPage ? (
|
{subPage ? (
|
||||||
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
|
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border w-full max-w-2xl mx-auto">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isSaving}
|
isLoading={isSaving}
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{/* Navigator Properties */}
|
{/* Navigator Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.navigatorProperties")}</Label>
|
<Label>{t("fingerprint.navigatorProperties")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
|
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -566,7 +566,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{/* Screen Properties */}
|
{/* Screen Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.screenProperties")}</Label>
|
<Label>{t("fingerprint.screenProperties")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="screen-width">
|
<Label htmlFor="screen-width">
|
||||||
{t("fingerprint.screenWidth")}
|
{t("fingerprint.screenWidth")}
|
||||||
@@ -687,7 +687,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{/* Window Properties */}
|
{/* Window Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.windowProperties")}</Label>
|
<Label>{t("fingerprint.windowProperties")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="outer-width">
|
<Label htmlFor="outer-width">
|
||||||
{t("fingerprint.outerWidth")}
|
{t("fingerprint.outerWidth")}
|
||||||
@@ -800,7 +800,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{/* Geolocation */}
|
{/* Geolocation */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.geolocation")}</Label>
|
<Label>{t("fingerprint.geolocation")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="latitude">{t("fingerprint.latitude")}</Label>
|
<Label htmlFor="latitude">{t("fingerprint.latitude")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -860,7 +860,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{/* Locale */}
|
{/* Locale */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.locale")}</Label>
|
<Label>{t("fingerprint.locale")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="locale-language">
|
<Label htmlFor="locale-language">
|
||||||
{t("fingerprint.language")}
|
{t("fingerprint.language")}
|
||||||
@@ -917,7 +917,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{/* WebGL Properties */}
|
{/* WebGL Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.webglProperties")}</Label>
|
<Label>{t("fingerprint.webglProperties")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="webgl-vendor">
|
<Label htmlFor="webgl-vendor">
|
||||||
{t("fingerprint.webglVendor")}
|
{t("fingerprint.webglVendor")}
|
||||||
@@ -1065,7 +1065,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{/* Battery */}
|
{/* Battery */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.battery")}</Label>
|
<Label>{t("fingerprint.battery")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1158,7 +1158,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${className}`}>
|
<div className={`@container space-y-6 ${className}`}>
|
||||||
{forceAdvanced ? (
|
{forceAdvanced ? (
|
||||||
// Advanced mode only (for editing)
|
// Advanced mode only (for editing)
|
||||||
renderAdvancedForm()
|
renderAdvancedForm()
|
||||||
@@ -1265,7 +1265,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
>
|
>
|
||||||
<Label>{t("fingerprint.screenResolution")}</Label>
|
<Label>{t("fingerprint.screenResolution")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="screen-max-width">
|
<Label htmlFor="screen-max-width">
|
||||||
{t("fingerprint.maxWidth")}
|
{t("fingerprint.maxWidth")}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface ShortcutsPageProps {
|
|||||||
|
|
||||||
function Tokens({ tokens }: { tokens: string[] }) {
|
function Tokens({ tokens }: { tokens: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{tokens.map((tok, i) => (
|
{tokens.map((tok, i) => (
|
||||||
<kbd
|
<kbd
|
||||||
key={i}
|
key={i}
|
||||||
@@ -72,7 +72,12 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
|||||||
key={s.id}
|
key={s.id}
|
||||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||||
>
|
>
|
||||||
<span className="text-sm">{t(s.labelKey)}</span>
|
<span
|
||||||
|
className="text-sm truncate min-w-0"
|
||||||
|
title={t(s.labelKey)}
|
||||||
|
>
|
||||||
|
{t(s.labelKey)}
|
||||||
|
</span>
|
||||||
<ShortcutTokens shortcut={s} />
|
<ShortcutTokens shortcut={s} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -92,7 +97,12 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
|||||||
key={target.id}
|
key={target.id}
|
||||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||||
>
|
>
|
||||||
<span className="text-sm">{target.name}</span>
|
<span
|
||||||
|
className="text-sm truncate min-w-0"
|
||||||
|
title={target.name}
|
||||||
|
>
|
||||||
|
{target.name}
|
||||||
|
</span>
|
||||||
<Tokens tokens={formatGroupShortcut(i + 1)} />
|
<Tokens tokens={formatGroupShortcut(i + 1)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function SyncFollowerDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md">
|
||||||
<ScrollArea className="h-[150px]">
|
<ScrollArea className="h-[clamp(120px,30vh,20rem)]">
|
||||||
<div className="space-y-1 p-2">
|
<div className="space-y-1 p-2">
|
||||||
{eligibleProfiles.length === 0 ? (
|
{eligibleProfiles.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const TruncatedDomain = React.memo<{ domain: string }>(({ domain }) => {
|
|||||||
}, [checkTruncation]);
|
}, [checkTruncation]);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<span ref={ref} className="truncate max-w-[200px] block">
|
<span ref={ref} className="truncate block min-w-0 flex-1">
|
||||||
{domain}
|
{domain}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -257,7 +257,7 @@ export function TrafficDetailsDialog({
|
|||||||
if (!open) onClose();
|
if (!open) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-[min(56rem,calc(100%-4rem))]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{t("traffic.title")}
|
{t("traffic.title")}
|
||||||
@@ -303,7 +303,7 @@ export function TrafficDetailsDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[200px] w-full">
|
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||||
<ResponsiveContainer
|
<ResponsiveContainer
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
@@ -509,7 +509,7 @@ export function TrafficDetailsDialog({
|
|||||||
{t("traffic.columnReceived")}
|
{t("traffic.columnReceived")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[180px] overflow-y-auto">
|
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||||
{topDomainsByTraffic.map((domain, index) => (
|
{topDomainsByTraffic.map((domain, index) => (
|
||||||
<div
|
<div
|
||||||
key={domain.domain}
|
key={domain.domain}
|
||||||
@@ -558,7 +558,7 @@ export function TrafficDetailsDialog({
|
|||||||
{t("traffic.columnTotal")}
|
{t("traffic.columnTotal")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[180px] overflow-y-auto">
|
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||||
{topDomainsByRequests.map((domain, index) => (
|
{topDomainsByRequests.map((domain, index) => (
|
||||||
<div
|
<div
|
||||||
key={domain.domain}
|
key={domain.domain}
|
||||||
@@ -591,7 +591,7 @@ export function TrafficDetailsDialog({
|
|||||||
<h3 className="text-sm font-medium mb-2">
|
<h3 className="text-sm font-medium mb-2">
|
||||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||||
</h3>
|
</h3>
|
||||||
<FadingScrollArea className="p-3 max-h-[120px]">
|
<FadingScrollArea className="p-3 max-h-[clamp(120px,15vh,240px)]">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{stats.unique_ips.map((ip) => (
|
{stats.unique_ips.map((ip) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function AnimatedTabsList({
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
data-slot="animated-tabs-list"
|
data-slot="animated-tabs-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex items-center gap-1 rounded-md p-0",
|
"relative inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-md p-0 [scrollbar-width:none]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onMouseLeave={(event) => {
|
onMouseLeave={(event) => {
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ function AutoHeight({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
style={{ overflow: "hidden", ...style }}
|
style={{ overflow: "hidden", maxHeight: "100%", ...style }}
|
||||||
animate={{ height, ...animate }}
|
animate={{ height, ...animate }}
|
||||||
transition={transition}
|
transition={transition}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div ref={ref}>{children}</div>
|
<div ref={ref} className="min-h-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</Comp>
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ChartContainer = React.forwardRef<
|
|||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
"flex aspect-video max-h-[min(45vh,20rem)] w-full justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -64,13 +64,18 @@ export function Combobox({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn("w-full justify-between", className)}
|
className={cn("w-full justify-between", className)}
|
||||||
>
|
>
|
||||||
{value
|
<span className="truncate">
|
||||||
? options.find((option) => option.value === value)?.label
|
{value
|
||||||
: resolvedPlaceholder}
|
? options.find((option) => option.value === value)?.label
|
||||||
|
: resolvedPlaceholder}
|
||||||
|
</span>
|
||||||
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent id={listboxId} className="w-full p-0">
|
<PopoverContent
|
||||||
|
id={listboxId}
|
||||||
|
className="w-(--radix-popover-trigger-width) p-0"
|
||||||
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={resolvedSearchPlaceholder} />
|
<CommandInput placeholder={resolvedSearchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
@@ -91,10 +96,10 @@ export function Combobox({
|
|||||||
value === option.value ? "opacity-100" : "opacity-0",
|
value === option.value ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span>{option.label}</span>
|
<span className="truncate">{option.label}</span>
|
||||||
{option.description && (
|
{option.description && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
{option.description}
|
{option.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function CommandDialog({
|
|||||||
<DialogTitle>{resolvedTitle}</DialogTitle>
|
<DialogTitle>{resolvedTitle}</DialogTitle>
|
||||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent className="overflow-hidden p-0">
|
<DialogContent className="overflow-hidden p-0 sm:max-w-xl">
|
||||||
<Command
|
<Command
|
||||||
filter={filter}
|
filter={filter}
|
||||||
shouldFilter={shouldFilter}
|
shouldFilter={shouldFilter}
|
||||||
@@ -96,7 +96,7 @@ function CommandList({
|
|||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
"max-h-[min(50vh,500px)] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ function SubPageContent({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
background: "var(--background)",
|
background: "var(--background)",
|
||||||
|
containerType: "inline-size",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -254,7 +255,10 @@ function DialogContent({
|
|||||||
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
|
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
|
||||||
|
// gutter even when callers override max-w-*: tailwind-merge drops
|
||||||
|
// a base max-w in favor of the caller's, but leaves width alone.
|
||||||
|
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg max-h-[calc(100vh-3rem)] overflow-y-auto",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -282,7 +286,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-left pr-8", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -293,7 +297,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-row flex-wrap justify-end gap-2 shrink-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -224,13 +224,15 @@ function DropdownMenuSubTrigger({
|
|||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
className,
|
className,
|
||||||
|
collisionPadding = 8,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
collisionPadding={collisionPadding}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border p-1 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function PopoverContent({
|
|||||||
className,
|
className,
|
||||||
align = "center",
|
align = "center",
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
|
collisionPadding = 8,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
@@ -29,8 +30,9 @@ function PopoverContent({
|
|||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
collisionPadding={collisionPadding}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border p-4 shadow-md outline-hidden",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import type * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
||||||
return (
|
return (
|
||||||
<div data-slot="table-container" className="overflow-visible w-full">
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
||||||
|
>
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
data-slot="table"
|
||||||
className={cn("w-full text-sm caption-bottom", className)}
|
className={cn("w-full text-sm caption-bottom", className)}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const TabsList = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
"inline-flex h-10 max-w-full items-center justify-center overflow-x-auto rounded-md bg-muted p-1 text-muted-foreground [scrollbar-width:none]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -168,6 +168,10 @@ function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
|
|||||||
return !("mode" in props) || props.mode === "auto-height";
|
return !("mode" in props) || props.mode === "auto-height";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-height mode animates to a measured pixel height; in a
|
||||||
|
// height-constrained parent (e.g. a dialog capped at the viewport) the pane
|
||||||
|
// itself must carry "overflow-y-auto min-h-0" so overflow scrolls instead of
|
||||||
|
// clipping.
|
||||||
function TabsContents(props: TabsContentsProps) {
|
function TabsContents(props: TabsContentsProps) {
|
||||||
const { value } = useTabs();
|
const { value } = useTabs();
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function TooltipContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
alignOffset={alignOffset}
|
alignOffset={alignOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit max-w-[min(24rem,calc(100vw-2rem))] origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function VpnFormDialog({
|
|||||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea className="max-h-[60vh] pr-4">
|
<ScrollArea className="max-h-[min(60vh,calc(100vh-15rem))] overflow-y-auto pr-4">
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4 py-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
|
<Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("vpns.import.configPreview")}</Label>
|
<Label>{t("vpns.import.configPreview")}</Label>
|
||||||
<ScrollArea className="h-[150px] border rounded-md">
|
<ScrollArea className="h-[min(150px,25vh)] border rounded-md">
|
||||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||||
{vpnPreview.content.slice(0, 1000)}
|
{vpnPreview.content.slice(0, 1000)}
|
||||||
{vpnPreview.content.length > 1000 && "..."}
|
{vpnPreview.content.length > 1000 && "..."}
|
||||||
|
|||||||
@@ -290,8 +290,8 @@ export function WayfernConfigForm({
|
|||||||
{/* User Agent and Platform */}
|
{/* User Agent and Platform */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.userAgentAndPlatform")}</Label>
|
<Label>{t("fingerprint.userAgentAndPlatform")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2 col-span-2">
|
<div className="space-y-2 col-span-full">
|
||||||
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
|
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="user-agent"
|
id="user-agent"
|
||||||
@@ -381,7 +381,7 @@ export function WayfernConfigForm({
|
|||||||
{/* Hardware Properties */}
|
{/* Hardware Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.hardwareProperties")}</Label>
|
<Label>{t("fingerprint.hardwareProperties")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="hardware-concurrency">
|
<Label htmlFor="hardware-concurrency">
|
||||||
{t("fingerprint.hardwareConcurrency")}
|
{t("fingerprint.hardwareConcurrency")}
|
||||||
@@ -439,7 +439,7 @@ export function WayfernConfigForm({
|
|||||||
{/* Screen Properties */}
|
{/* Screen Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.screenProperties")}</Label>
|
<Label>{t("fingerprint.screenProperties")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="screen-width">
|
<Label htmlFor="screen-width">
|
||||||
{t("fingerprint.screenWidth")}
|
{t("fingerprint.screenWidth")}
|
||||||
@@ -561,7 +561,7 @@ export function WayfernConfigForm({
|
|||||||
{/* Window Properties */}
|
{/* Window Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.windowProperties")}</Label>
|
<Label>{t("fingerprint.windowProperties")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="window-outer-width">
|
<Label htmlFor="window-outer-width">
|
||||||
{t("fingerprint.outerWidth")}
|
{t("fingerprint.outerWidth")}
|
||||||
@@ -674,7 +674,7 @@ export function WayfernConfigForm({
|
|||||||
{/* Language & Locale */}
|
{/* Language & Locale */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.languageAndLocale")}</Label>
|
<Label>{t("fingerprint.languageAndLocale")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="language">
|
<Label htmlFor="language">
|
||||||
{t("fingerprint.primaryLanguage")}
|
{t("fingerprint.primaryLanguage")}
|
||||||
@@ -756,7 +756,7 @@ export function WayfernConfigForm({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("fingerprint.timezoneGeolocationDescription")}
|
{t("fingerprint.timezoneGeolocationDescription")}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="timezone">
|
<Label htmlFor="timezone">
|
||||||
{t("fingerprint.timezoneIana")}
|
{t("fingerprint.timezoneIana")}
|
||||||
@@ -853,7 +853,7 @@ export function WayfernConfigForm({
|
|||||||
{/* WebGL Properties */}
|
{/* WebGL Properties */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.webglProperties")}</Label>
|
<Label>{t("fingerprint.webglProperties")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="webgl-vendor">
|
<Label htmlFor="webgl-vendor">
|
||||||
{t("fingerprint.webglVendor")}
|
{t("fingerprint.webglVendor")}
|
||||||
@@ -951,7 +951,7 @@ export function WayfernConfigForm({
|
|||||||
{/* Audio */}
|
{/* Audio */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.audioProperties")}</Label>
|
<Label>{t("fingerprint.audioProperties")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="audio-sample-rate">
|
<Label htmlFor="audio-sample-rate">
|
||||||
{t("fingerprint.sampleRate")}
|
{t("fingerprint.sampleRate")}
|
||||||
@@ -994,7 +994,7 @@ export function WayfernConfigForm({
|
|||||||
{/* Battery */}
|
{/* Battery */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.battery")}</Label>
|
<Label>{t("fingerprint.battery")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1040,7 +1040,7 @@ export function WayfernConfigForm({
|
|||||||
{/* Vendor Info */}
|
{/* Vendor Info */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{t("fingerprint.vendorInfo")}</Label>
|
<Label>{t("fingerprint.vendorInfo")}</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
|
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1114,7 +1114,7 @@ export function WayfernConfigForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${className}`}>
|
<div className={`@container space-y-6 ${className}`}>
|
||||||
{forceAdvanced ? (
|
{forceAdvanced ? (
|
||||||
renderAdvancedForm()
|
renderAdvancedForm()
|
||||||
) : (
|
) : (
|
||||||
@@ -1228,7 +1228,7 @@ export function WayfernConfigForm({
|
|||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
>
|
>
|
||||||
<Label>{t("fingerprint.screenResolution")}</Label>
|
<Label>{t("fingerprint.screenResolution")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="screen-max-width">
|
<Label htmlFor="screen-max-width">
|
||||||
{t("fingerprint.maxWidth")}
|
{t("fingerprint.maxWidth")}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user