mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-20 19:31:16 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1664b2950 | |||
| 4f7910dd23 | |||
| e1c9ce6525 | |||
| cef522649a | |||
| f95816d70d | |||
| b15231d752 | |||
| af792745fc | |||
| 22f976442b | |||
| 809a95c729 | |||
| cea4ece698 | |||
| ac03b70f94 | |||
| 95f84248ab | |||
| dd5357d6c3 | |||
| 7b7849a54a | |||
| cb0ec75d37 | |||
| ae8afbb158 | |||
| 53db00a85a | |||
| eeb5c816bf | |||
| 86d58717b4 | |||
| 06e34527b6 | |||
| 97f1f52a6d | |||
| f95e6332fa | |||
| 86671ceed6 |
@@ -603,7 +603,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@10c894bdeef3618f5666fb506ef7f9491bb964d8 #v1.17.13
|
||||
uses: anomalyco/opencode/github@b1fc8113948b518835c2a39ece49553cffe9b30c #v1.17.18
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -280,6 +280,29 @@ jobs:
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
rm -f $RUNNER_TEMP/build_certificate.p12 || true
|
||||
|
||||
# Runs after every matrix leg (including the portable ZIP upload) so the
|
||||
# sums cover the complete, final asset set. The app self-updater refuses to
|
||||
# install a release it cannot verify against this file.
|
||||
checksums:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Generate and upload SHA256SUMS.txt
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
ASSETS_DIR="/tmp/release-assets"
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir "$ASSETS_DIR"
|
||||
cd "$ASSETS_DIR"
|
||||
sha256sum Donut* > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
gh release upload "$TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
changelog:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
|
||||
@@ -5,6 +5,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Serialize runs: the rolling `nightly` release is deleted and recreated at the
|
||||
# end of each run, and overlapping runs could interleave those steps (or leave
|
||||
# a checksums file describing another run's assets). Queue instead of cancel so
|
||||
# an in-flight delete/create is never aborted halfway.
|
||||
concurrency:
|
||||
group: rolling-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
security-events: write
|
||||
@@ -210,7 +218,11 @@ jobs:
|
||||
id: timestamp
|
||||
shell: bash
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
# Committer date, not wall clock: every job in this run (including
|
||||
# update-nightly-release, which runs much later) must derive the
|
||||
# exact same tag, or a run straddling midnight UTC splits the
|
||||
# release from its checksums.
|
||||
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
@@ -289,7 +301,9 @@ jobs:
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
# Committer date — must match the tag the build matrix computed (see
|
||||
# the timestamp step there), even when this job runs past midnight.
|
||||
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -355,8 +369,16 @@ jobs:
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
|
||||
|
||||
# Rename versioned filenames to stable nightly names
|
||||
# Checksums for the per-commit release (original filenames). The app
|
||||
# self-updater downloads from per-commit nightly releases and refuses
|
||||
# to install anything it cannot verify against this file.
|
||||
# --repo is required: ASSETS_DIR is outside the git checkout, so gh
|
||||
# cannot infer the repository from the working directory.
|
||||
cd "$ASSETS_DIR"
|
||||
sha256sum Donut* > SHA256SUMS.txt
|
||||
gh release upload "$NIGHTLY_TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# Rename versioned filenames to stable nightly names
|
||||
for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
|
||||
for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
|
||||
for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
|
||||
@@ -368,6 +390,10 @@ jobs:
|
||||
for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
|
||||
for f in Donut_*_aarch64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_aarch64.app.tar.gz; done
|
||||
for f in Donut_*_x64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_x64.app.tar.gz; done
|
||||
|
||||
# Checksums for the rolling release (renamed filenames), restricted
|
||||
# to exactly the assets uploaded below.
|
||||
sha256sum Donut_nightly_* Donut_aarch64.app.tar.gz Donut_x64.app.tar.gz > SHA256SUMS.txt
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
|
||||
# Delete existing rolling nightly release and tag
|
||||
@@ -379,6 +405,7 @@ jobs:
|
||||
"$ASSETS_DIR"/Donut_nightly_* \
|
||||
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
|
||||
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
|
||||
"$ASSETS_DIR"/SHA256SUMS.txt \
|
||||
--title "Donut Browser Nightly" \
|
||||
--notes-file /tmp/nightly-notes.md \
|
||||
--prerelease
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
||||
|
||||
@@ -17,7 +17,7 @@ donutbrowser/
|
||||
│ ├── app/ # App router (page.tsx, layout.tsx)
|
||||
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
||||
│ ├── hooks/ # Event-driven React hooks
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh)
|
||||
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
||||
│ └── types.ts # Shared TypeScript interfaces
|
||||
├── src-tauri/ # Rust backend (Tauri)
|
||||
@@ -38,6 +38,10 @@ donutbrowser/
|
||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||
│ │ ├── settings_manager.rs # App settings persistence
|
||||
│ │ ├── cookie_manager.rs # Cookie import/export
|
||||
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
|
||||
│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check
|
||||
│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist
|
||||
│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase
|
||||
│ │ ├── extension_manager.rs # Browser extension management
|
||||
│ │ ├── group_manager.rs # Profile group management
|
||||
│ │ ├── synchronizer.rs # Real-time profile synchronizer
|
||||
@@ -79,12 +83,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria
|
||||
|
||||
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
|
||||
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
|
||||
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
|
||||
- Adding a new string means adding the key to EVERY locale file in `src/i18n/locales/` — currently en, es, fr, ja, ko, pt, ru, tr, vi, zh — not just `en.json`. The English version alone is incomplete work. Don't trust this list: enumerate `src/i18n/locales/*.json` and update every file you find, because a newly added locale is exactly what a hardcoded list silently skips.
|
||||
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
|
||||
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
|
||||
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
|
||||
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
|
||||
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
|
||||
- When adding or removing keys across the locales, use a one-shot Python script in the scratchpad dir that globs `src/i18n/locales/*.json`, mutates each, and writes it back. Sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away. Finish by diffing every locale's flattened key set against `en.json` — zero missing and zero extra, for all of them.
|
||||
|
||||
## Backend error codes (mandatory)
|
||||
|
||||
@@ -98,10 +102,34 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
|
||||
```
|
||||
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
|
||||
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
|
||||
4. Add `backendErrors.fooBar` to all nine locale files.
|
||||
4. Add `backendErrors.fooBar` to every locale file in `src/i18n/locales/`.
|
||||
|
||||
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
|
||||
|
||||
## REST API (`src-tauri/src/api_server.rs`) — endpoints must stay in the OpenAPI spec
|
||||
|
||||
The served `/openapi.json` comes from the hand-maintained `ApiDoc` derive (`#[derive(OpenApi)]` with `paths(...)`, `components(schemas(...))`, `tags(...)`) — NOT from the router. The `OpenApiRouter`-generated spec is discarded (`let (v1_routes, _) = ...`), so a handler registered on the router but missing from `ApiDoc` silently disappears from the spec (this happened to the extension and VPN-export endpoints once).
|
||||
|
||||
**Any endpoint modification — adding, removing, or changing a route, request/response schema, or status code — must be reflected in the OpenAPI spec in the same change:**
|
||||
|
||||
1. Keep the handler's `#[utoipa::path]` annotation accurate (path, request body, every reachable response status).
|
||||
2. Add/remove the handler in `ApiDoc`'s `paths(...)` list and any new schema types in `components(schemas(...))`.
|
||||
3. Extend the `openapi_*` regression tests in `api_server.rs::tests` (they assert spec coverage and that optional fields stay optional).
|
||||
4. `#[schema(value_type = Object)]` on an `Option<T>` field erases the optionality and wrongly marks it required — use `value_type = Option<Object>` (or drop the attribute for natively supported types).
|
||||
|
||||
### Error status conventions (known errors)
|
||||
|
||||
Handlers route manager errors through `manager_error_response`, which maps message content onto a consistent status and passes the text through as the response body:
|
||||
|
||||
- `401` — missing/invalid bearer token (auth middleware; empty body).
|
||||
- `402` — the five automation endpoints (`run`, `open-url`, `kill`, `batch/run`, `batch/stop`) without a paid plan, and expired-proxy (`PROXY_PAYMENT_REQUIRED`) checks.
|
||||
- `404` — entity not found (`… not found` / `*_NOT_FOUND`).
|
||||
- `400` — validation, duplicates, empty names, invalid/unsupported/unavailable input.
|
||||
- `409` — conflicts: browser version already being downloaded, profile locked by another team member (run), browser running during cookie import.
|
||||
- `500` — internal failures (IO, network, poisoned locks).
|
||||
|
||||
Error bodies are plain-text diagnostics; some are the JSON `{"code": ...}` strings shared with the Tauri commands (e.g. `NAME_CANNOT_BE_EMPTY`, `GROUP_ALREADY_EXISTS`). The translated-error rule above applies to Tauri commands, not to REST bodies.
|
||||
|
||||
## Sub-page Dialog mode
|
||||
|
||||
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
|
||||
@@ -151,7 +179,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
|
||||
|
||||
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
||||
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in every locale.
|
||||
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
|
||||
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
|
||||
- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
|
||||
@@ -161,7 +189,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live
|
||||
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
||||
4. Add `shortcuts.yourId` (label) to all nine locale files.
|
||||
4. Add `shortcuts.yourId` (label) to every locale file in `src/i18n/locales/`.
|
||||
|
||||
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
|
||||
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.28.2 (2026-07-12)
|
||||
|
||||
### Features
|
||||
|
||||
- sha256 checksum for self-updates
|
||||
- progress bar for extraction
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- properly handle location spoofing for socks5 proxies
|
||||
|
||||
### Refactoring
|
||||
|
||||
- api cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 2 updates
|
||||
- chore: update flake.nix for v0.28.1 [skip ci] (#493)
|
||||
|
||||
|
||||
## v0.28.1 (2026-07-09)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- do not use system proxy on windows
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.28.0 [skip ci] (#490)
|
||||
|
||||
|
||||
## v0.28.0 (2026-07-08)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut-0.28.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut-0.28.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -135,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>Hassiy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/xenos1337">
|
||||
<img src="https://avatars.githubusercontent.com/u/66328734?v=4" width="100;" alt="xenos1337"/>
|
||||
<br />
|
||||
<sub><b>xenos</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/webees">
|
||||
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
|
||||
@@ -156,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>Huy Le</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/drunkod">
|
||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||
@@ -163,8 +172,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>drunkod</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/JorySeverijnse">
|
||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.28.0";
|
||||
releaseVersion = "0.28.2";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_amd64.AppImage";
|
||||
hash = "sha256-m3C2WJ2xPT8jkC9HYS0vpBOmqkFEwHTQZ08HgwRb/Dg=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage";
|
||||
hash = "sha256-+CqHiPMg4oczNiPg+MC6jvp0CUcK4kb5yeyk+QDbAWY=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.0/Donut_0.28.0_aarch64.AppImage";
|
||||
hash = "sha256-1D+eX51o65RFiTL1IZTYUaWUBys23AIXRtcW9n2vtCg=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage";
|
||||
hash = "sha256-HodokW2ySIpdpW7Hyqpwsm8whQ0hHldlSg11Sl1UW3k=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+1
-2
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.28.1",
|
||||
"version": "0.28.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -61,7 +61,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"framer-motion": "^12.42.2",
|
||||
"i18next": "^26.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"motion": "^12.42.2",
|
||||
|
||||
Generated
-3
@@ -109,9 +109,6 @@ importers:
|
||||
flag-icons:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0
|
||||
framer-motion:
|
||||
specifier: ^12.42.2
|
||||
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
i18next:
|
||||
specifier: ^26.3.4
|
||||
version: 26.3.4(typescript@6.0.3)
|
||||
|
||||
Generated
+1
-1
@@ -1797,7 +1797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.1"
|
||||
version = "0.28.2"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.1"
|
||||
version = "0.28.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -278,7 +278,7 @@ impl ApiClient {
|
||||
{
|
||||
Ok(response) => {
|
||||
if !response.status().is_success() {
|
||||
last_err = Some(format!("HTTP {}", response.status()));
|
||||
last_err = Some(format!("HTTP {}", response.status().as_u16()));
|
||||
} else {
|
||||
match response.json::<WayfernVersionInfo>().await {
|
||||
Ok(info) => {
|
||||
|
||||
+510
-163
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,10 @@ pub struct AppReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
pub size: u64,
|
||||
/// GitHub-computed digest ("sha256:<hex>"); absent on assets uploaded
|
||||
/// before GitHub started calculating digests.
|
||||
#[serde(default)]
|
||||
pub digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -111,6 +115,15 @@ pub struct AppUpdateInfo {
|
||||
pub release_page_url: Option<String>,
|
||||
/// True when a system package manager repo is configured (apt/dnf/zypper)
|
||||
pub repo_update: bool,
|
||||
/// URL of the release's SHA256SUMS.txt asset. The downloaded update is
|
||||
/// verified against it before installation; without it the update is
|
||||
/// refused.
|
||||
#[serde(default)]
|
||||
pub checksums_url: Option<String>,
|
||||
/// GitHub's server-side digest of the chosen asset ("sha256:<hex>"),
|
||||
/// cross-checked in addition to SHA256SUMS.txt when present.
|
||||
#[serde(default)]
|
||||
pub asset_digest: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AppAutoUpdater {
|
||||
@@ -214,6 +227,35 @@ impl AppAutoUpdater {
|
||||
// Find the appropriate asset for current platform
|
||||
let download_url = self.get_download_url_for_platform(&latest_release.assets);
|
||||
|
||||
// Locate the release's checksums file and the chosen asset's
|
||||
// GitHub-computed digest for post-download verification.
|
||||
let checksums_url = Self::find_checksums_url(&latest_release.assets);
|
||||
let asset_digest = download_url.as_deref().and_then(|url| {
|
||||
latest_release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.browser_download_url == url)
|
||||
.and_then(|a| a.digest.clone())
|
||||
});
|
||||
|
||||
// Both release workflows upload SHA256SUMS.txt only after every platform
|
||||
// build finishes, so a release without it is still being assembled (or
|
||||
// its pipeline broke). Downloading now is guaranteed to fail closed, so
|
||||
// treat the release as not ready and retry on a later check instead of
|
||||
// surfacing an error for a healthy in-progress release. Applies only to
|
||||
// the auto-download path — manual/repo notifications don't download.
|
||||
let auto_download_possible = download_url.is_some();
|
||||
#[cfg(target_os = "linux")]
|
||||
let auto_download_possible = auto_download_possible && !self.is_repo_configured();
|
||||
if auto_download_possible && checksums_url.is_none() {
|
||||
log::info!(
|
||||
"Release {} has no {} yet; treating as not ready for auto-update",
|
||||
latest_release.tag_name,
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// On Linux, when a package repo is configured, notify users to update via
|
||||
// their package manager instead of auto-downloading from GitHub.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -230,6 +272,8 @@ impl AppAutoUpdater {
|
||||
manual_update_required,
|
||||
release_page_url: Some(release_page_url),
|
||||
repo_update,
|
||||
checksums_url,
|
||||
asset_digest,
|
||||
};
|
||||
|
||||
log::info!(
|
||||
@@ -255,6 +299,8 @@ impl AppAutoUpdater {
|
||||
manual_update_required: false,
|
||||
release_page_url: Some(release_page_url),
|
||||
repo_update: false,
|
||||
checksums_url,
|
||||
asset_digest,
|
||||
};
|
||||
|
||||
log::info!(
|
||||
@@ -712,6 +758,156 @@ impl AppAutoUpdater {
|
||||
None
|
||||
}
|
||||
|
||||
/// Name of the checksums asset both release workflows publish.
|
||||
const CHECKSUMS_ASSET_NAME: &'static str = "SHA256SUMS.txt";
|
||||
|
||||
fn find_checksums_url(assets: &[AppReleaseAsset]) -> Option<String> {
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| a.name == Self::CHECKSUMS_ASSET_NAME)
|
||||
.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Extract the hex digest for `filename` from standard `sha256sum` output
|
||||
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
|
||||
fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
|
||||
checksums_text.lines().find_map(|line| {
|
||||
let (hash, rest) = line.split_once(char::is_whitespace)?;
|
||||
let name = rest.trim_start().trim_start_matches('*');
|
||||
if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Some(hash.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Fetch the release's SHA256SUMS.txt and return the expected digest for
|
||||
/// `filename`. Called BEFORE the (large) asset download so an unverifiable
|
||||
/// release is rejected without wasting the transfer. Every failure mode
|
||||
/// maps to the UPDATE_CHECKSUMS_UNAVAILABLE code; details go to the log.
|
||||
async fn fetch_expected_checksum(
|
||||
&self,
|
||||
update_info: &AppUpdateInfo,
|
||||
filename: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_CHECKSUMS_UNAVAILABLE",
|
||||
"params": { "version": update_info.new_version }
|
||||
})
|
||||
.to_string()
|
||||
.into()
|
||||
};
|
||||
|
||||
let Some(checksums_url) = update_info.checksums_url.as_deref() else {
|
||||
log::warn!(
|
||||
"No {} asset on release {}",
|
||||
Self::CHECKSUMS_ASSET_NAME,
|
||||
update_info.new_version
|
||||
);
|
||||
return Err(unavailable());
|
||||
};
|
||||
|
||||
let response = match self
|
||||
.client
|
||||
.get(checksums_url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => response,
|
||||
Ok(response) => {
|
||||
log::warn!("Checksums file request failed: HTTP {}", response.status());
|
||||
return Err(unavailable());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Checksums file request failed: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let checksums_text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read checksums file: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else {
|
||||
log::warn!(
|
||||
"No checksum entry for {filename} in {}",
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
);
|
||||
return Err(unavailable());
|
||||
};
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
/// Verify the downloaded update against the expected SHA256SUMS.txt digest
|
||||
/// (and GitHub's server-side asset digest when available) before anything
|
||||
/// is extracted or installed. A corrupt download is deleted so the next
|
||||
/// attempt starts fresh.
|
||||
fn verify_update_checksum(
|
||||
file_path: &Path,
|
||||
filename: &str,
|
||||
expected: &str,
|
||||
asset_digest: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let actual = Self::sha256_file(file_path)?;
|
||||
|
||||
let mut mismatch = !actual.eq_ignore_ascii_case(expected);
|
||||
|
||||
// Cross-check GitHub's server-side digest: SHA256SUMS.txt is computed by
|
||||
// re-downloading assets in CI, so this catches corruption in that step.
|
||||
if !mismatch {
|
||||
if let Some(hex) = asset_digest.and_then(|d| d.strip_prefix("sha256:")) {
|
||||
mismatch = !actual.eq_ignore_ascii_case(hex);
|
||||
}
|
||||
}
|
||||
|
||||
if mismatch {
|
||||
log::error!(
|
||||
"Checksum mismatch for {filename}: expected {expected}, got {actual} (asset digest: {asset_digest:?})"
|
||||
);
|
||||
let _ = fs::remove_file(file_path);
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_CHECKSUM_MISMATCH",
|
||||
"params": { "file": filename }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Checksum verified for {filename}: {actual}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the update file without progress tracking (silent download)
|
||||
async fn download_update_silent(
|
||||
&self,
|
||||
@@ -767,12 +963,24 @@ impl AppAutoUpdater {
|
||||
.unwrap_or("update.dmg")
|
||||
.to_string();
|
||||
|
||||
// Resolve the expected checksum first so an unverifiable release is
|
||||
// rejected before the multi-hundred-MB download, not after.
|
||||
let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;
|
||||
|
||||
log::info!("Downloading update from: {}", update_info.download_url);
|
||||
|
||||
let download_path = self
|
||||
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
|
||||
.await?;
|
||||
|
||||
log::info!("Verifying update checksum...");
|
||||
Self::verify_update_checksum(
|
||||
&download_path,
|
||||
&filename,
|
||||
&expected_sha256,
|
||||
update_info.asset_digest.as_deref(),
|
||||
)?;
|
||||
|
||||
log::info!("Extracting update...");
|
||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
||||
|
||||
@@ -825,7 +1033,10 @@ impl AppAutoUpdater {
|
||||
|
||||
// Handle compound extensions like .tar.gz
|
||||
if file_name.ends_with(".tar.gz") {
|
||||
return self.extractor.extract_tar_gz(archive_path, dest_dir).await;
|
||||
return self
|
||||
.extractor
|
||||
.extract_tar_gz(archive_path, dest_dir, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
let extension = archive_path
|
||||
@@ -837,7 +1048,10 @@ impl AppAutoUpdater {
|
||||
"dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.extractor.extract_dmg(archive_path, dest_dir).await
|
||||
self
|
||||
.extractor
|
||||
.extract_dmg(archive_path, dest_dir, None)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
@@ -901,7 +1115,12 @@ impl AppAutoUpdater {
|
||||
Err("AppImage installation is only supported on Linux".into())
|
||||
}
|
||||
}
|
||||
"zip" => self.extractor.extract_zip(archive_path, dest_dir).await,
|
||||
"zip" => {
|
||||
self
|
||||
.extractor
|
||||
.extract_zip(archive_path, dest_dir, None)
|
||||
.await
|
||||
}
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
}
|
||||
@@ -1024,7 +1243,7 @@ impl AppAutoUpdater {
|
||||
if !log_content.is_empty() {
|
||||
log::info!(
|
||||
"Log file content (last 500 chars): {}",
|
||||
&log_content
|
||||
log_content
|
||||
.chars()
|
||||
.rev()
|
||||
.take(500)
|
||||
@@ -1110,7 +1329,7 @@ impl AppAutoUpdater {
|
||||
// Extract ZIP file
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_zip(installer_path, &temp_extract_dir)
|
||||
.extract_zip(installer_path, &temp_extract_dir, None)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
@@ -1385,7 +1604,7 @@ impl AppAutoUpdater {
|
||||
// Extract tarball
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_tar_gz(tarball_path, &temp_extract_dir)
|
||||
.extract_tar_gz(tarball_path, &temp_extract_dir, None)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
@@ -1754,7 +1973,16 @@ pub async fn download_and_prepare_app_update(
|
||||
updater
|
||||
.download_and_prepare_update(&app_handle, &update_info)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download and prepare app update: {e}"))
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
// Structured error codes (`{"code": ...}`) must reach the frontend
|
||||
// unwrapped so translateBackendError can resolve them.
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("Failed to download and prepare app update: {msg}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1888,6 +2116,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksum_for_file() {
|
||||
let sums = "\
|
||||
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg
|
||||
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
|
||||
not-a-hash Donut_0.29.0_amd64.deb
|
||||
";
|
||||
|
||||
// Plain entry.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
|
||||
Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
|
||||
);
|
||||
// Binary-mode marker is stripped; hash is normalized to lowercase.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
|
||||
Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
|
||||
);
|
||||
// Entries with malformed hashes are rejected rather than trusted.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"),
|
||||
None
|
||||
);
|
||||
// Missing file.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256_file_matches_known_digest() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("data.bin");
|
||||
std::fs::write(&path, b"hello world").unwrap();
|
||||
assert_eq!(
|
||||
AppAutoUpdater::sha256_file(&path).unwrap(),
|
||||
// sha256 of "hello world"
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksums_url() {
|
||||
let assets = vec![
|
||||
AppReleaseAsset {
|
||||
name: "Donut_0.29.0_x64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 1,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "SHA256SUMS.txt".to_string(),
|
||||
browser_download_url: "https://example.com/SHA256SUMS.txt".to_string(),
|
||||
size: 1,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksums_url(&assets).as_deref(),
|
||||
Some("https://example.com/SHA256SUMS.txt")
|
||||
);
|
||||
assert_eq!(AppAutoUpdater::find_checksums_url(&assets[..1]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_asset_digest_is_optional_in_api_json() {
|
||||
// Assets uploaded before GitHub started computing digests omit the field.
|
||||
let without: AppReleaseAsset = serde_json::from_str(
|
||||
r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5}"#,
|
||||
)
|
||||
.expect("asset without digest should deserialize");
|
||||
assert_eq!(without.digest, None);
|
||||
|
||||
let with: AppReleaseAsset = serde_json::from_str(
|
||||
r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5, "digest": "sha256:ab12"}"#,
|
||||
)
|
||||
.expect("asset with digest should deserialize");
|
||||
assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_specific_download_urls() {
|
||||
let updater = AppAutoUpdater::instance();
|
||||
@@ -1899,33 +2208,39 @@ mod tests {
|
||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Windows assets (NSIS naming: _ARCH-setup.exe)
|
||||
AppReleaseAsset {
|
||||
name: "Donut_0.1.0_x64-setup.exe".to_string(),
|
||||
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Linux assets
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.rpm".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2028,11 +2343,13 @@ mod tests {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2073,23 +2390,27 @@ mod tests {
|
||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Windows assets
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
|
||||
browser_download_url: "https://example.com/x64.msi".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Linux assets
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -668,6 +668,7 @@ mod tests {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
@@ -802,13 +803,13 @@ mod tests {
|
||||
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.disabled_browsers.insert("firefox".to_string());
|
||||
state.disabled_browsers.insert("testbrowser".to_string());
|
||||
state
|
||||
.auto_update_downloads
|
||||
.insert("firefox-1.1.0".to_string());
|
||||
.insert("testbrowser-1.1.0".to_string());
|
||||
state.pending_updates.push(UpdateNotification {
|
||||
id: "test".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
affected_profiles: vec!["profile1".to_string()],
|
||||
@@ -830,9 +831,11 @@ mod tests {
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
|
||||
assert_eq!(loaded_state.disabled_browsers.len(), 1);
|
||||
assert!(loaded_state.disabled_browsers.contains("firefox"));
|
||||
assert!(loaded_state.disabled_browsers.contains("testbrowser"));
|
||||
assert_eq!(loaded_state.auto_update_downloads.len(), 1);
|
||||
assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0"));
|
||||
assert!(loaded_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"));
|
||||
assert_eq!(loaded_state.pending_updates.len(), 1);
|
||||
assert_eq!(loaded_state.pending_updates[0].id, "test");
|
||||
}
|
||||
@@ -871,16 +874,16 @@ mod tests {
|
||||
// Initially not disabled (empty state file means default state)
|
||||
let state = AutoUpdateState::default();
|
||||
assert!(
|
||||
!state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should not be disabled initially"
|
||||
!state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should not be disabled initially"
|
||||
);
|
||||
|
||||
// Start update (should disable)
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.disabled_browsers.insert("firefox".to_string());
|
||||
state.disabled_browsers.insert("testbrowser".to_string());
|
||||
state
|
||||
.auto_update_downloads
|
||||
.insert("firefox-1.1.0".to_string());
|
||||
.insert("testbrowser-1.1.0".to_string());
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write state file");
|
||||
|
||||
@@ -889,18 +892,20 @@ mod tests {
|
||||
let loaded_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
assert!(
|
||||
loaded_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be disabled"
|
||||
loaded_state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should be disabled"
|
||||
);
|
||||
assert!(
|
||||
loaded_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should be tracked"
|
||||
loaded_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"),
|
||||
"testbrowser download should be tracked"
|
||||
);
|
||||
|
||||
// Complete update (should enable)
|
||||
let mut state = loaded_state;
|
||||
state.disabled_browsers.remove("firefox");
|
||||
state.auto_update_downloads.remove("firefox-1.1.0");
|
||||
state.disabled_browsers.remove("testbrowser");
|
||||
state.auto_update_downloads.remove("testbrowser-1.1.0");
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write final state file");
|
||||
|
||||
@@ -909,12 +914,14 @@ mod tests {
|
||||
let final_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize final state");
|
||||
assert!(
|
||||
!final_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be enabled again"
|
||||
!final_state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should be enabled again"
|
||||
);
|
||||
assert!(
|
||||
!final_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should not be tracked anymore"
|
||||
!final_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"),
|
||||
"testbrowser download should not be tracked anymore"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -945,7 +952,7 @@ mod tests {
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.pending_updates.push(UpdateNotification {
|
||||
id: "test_notification".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
affected_profiles: vec!["profile1".to_string()],
|
||||
|
||||
@@ -163,6 +163,12 @@ async fn main() {
|
||||
.long("blocklist-file")
|
||||
.help("Path to DNS blocklist file (one domain per line)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("dns-allowlist-mode")
|
||||
.long("dns-allowlist-mode")
|
||||
.num_args(0)
|
||||
.help("Treat --blocklist-file as an allowlist (block all domains not listed)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("local-protocol")
|
||||
.long("local-protocol")
|
||||
@@ -256,6 +262,7 @@ async fn main() {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||
let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode");
|
||||
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
||||
|
||||
match start_proxy_process_with_profile(
|
||||
@@ -264,6 +271,7 @@ async fn main() {
|
||||
profile_id,
|
||||
bypass_rules,
|
||||
blocklist_file,
|
||||
dns_allowlist_mode,
|
||||
local_protocol,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -642,6 +642,7 @@ mod tests {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
@@ -34,24 +34,29 @@ impl BrowserRunner {
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
/// Resolve the DNS blocklist level to a cached file path.
|
||||
/// If a level is set but the cache is missing, fetches on demand (blocks until done).
|
||||
/// Resolve the DNS blocklist level to a cached file path plus whether that
|
||||
/// file should be treated as an allowlist. If a level is set but the cache
|
||||
/// is missing, fetches/compiles on demand (blocks until done).
|
||||
async fn resolve_blocklist_file(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<(Option<String>, bool), String> {
|
||||
let Some(ref level_str) = profile.dns_blocklist else {
|
||||
return Ok(None);
|
||||
return Ok((None, false));
|
||||
};
|
||||
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
|
||||
return Ok(None);
|
||||
return Ok((None, false));
|
||||
};
|
||||
if level == crate::dns_blocklist::BlocklistLevel::None {
|
||||
return Ok(None);
|
||||
return Ok((None, false));
|
||||
}
|
||||
// Only the user's custom list can be an allowlist; the Hagezi tiers are
|
||||
// always block lists.
|
||||
let allowlist_mode = level == crate::dns_blocklist::BlocklistLevel::Custom
|
||||
&& crate::dns_blocklist::CustomDnsConfig::load().allowlist_mode;
|
||||
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
|
||||
Ok(Some(path.to_string_lossy().to_string()))
|
||||
Ok((Some(path.to_string_lossy().to_string()), allowlist_mode))
|
||||
}
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
@@ -247,15 +252,20 @@ impl BrowserRunner {
|
||||
// Start the proxy and get local proxy settings
|
||||
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
|
||||
let profile_id_str = profile.id.to_string();
|
||||
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
|
||||
let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?;
|
||||
// Unique per-launch key: a shared constant here would let concurrent
|
||||
// launches overwrite each other's active_proxies entry, ending with one
|
||||
// browser's worker tracked under another browser's PID.
|
||||
let launch_placeholder_pid = crate::proxy_manager::next_launch_placeholder_pid();
|
||||
let local_proxy = PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
upstream_proxy.as_ref(),
|
||||
0, // Use 0 as temporary PID, will be updated later
|
||||
launch_placeholder_pid,
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
dns_allowlist_mode,
|
||||
// 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
|
||||
@@ -269,6 +279,40 @@ impl BrowserRunner {
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
// If any step below fails before the browser is up, the detached worker
|
||||
// must be stopped here: its config never gets a browser_pid, so neither
|
||||
// the GUI sweeps nor the worker's own watchdog would ever reap it — it
|
||||
// would survive until machine reboot.
|
||||
struct ProxyLaunchGuard {
|
||||
app_handle: tauri::AppHandle,
|
||||
placeholder_pid: u32,
|
||||
profile_name: String,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for ProxyLaunchGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
log::warn!(
|
||||
"Launch failed after local proxy start for profile {}; stopping proxy worker",
|
||||
self.profile_name
|
||||
);
|
||||
let app_handle = self.app_handle.clone();
|
||||
let pid = self.placeholder_pid;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
|
||||
log::warn!("Failed to stop proxy worker after failed launch: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut proxy_launch_guard = ProxyLaunchGuard {
|
||||
app_handle: app_handle.clone(),
|
||||
placeholder_pid: launch_placeholder_pid,
|
||||
profile_name: profile.name.clone(),
|
||||
armed: true,
|
||||
};
|
||||
|
||||
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
|
||||
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
|
||||
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
|
||||
@@ -294,7 +338,7 @@ impl BrowserRunner {
|
||||
config_for_generation.fingerprint = None;
|
||||
|
||||
// Generate a new fingerprint
|
||||
let new_fingerprint = self
|
||||
let (new_fingerprint, geolocation_applied) = self
|
||||
.wayfern_manager
|
||||
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
|
||||
.await
|
||||
@@ -317,14 +361,19 @@ impl BrowserRunner {
|
||||
if wayfern_config.os.is_some() {
|
||||
updated_wayfern_config.os = wayfern_config.os.clone();
|
||||
}
|
||||
// The fresh fingerprint's location matches the current routing; record
|
||||
// its signature so launches keep it in sync with the non-randomize path.
|
||||
updated_wayfern_config.geo_proxy_signature =
|
||||
// Record which routing this fresh fingerprint's geolocation was built
|
||||
// for (provenance only — nothing rewrites the fingerprint from it).
|
||||
// Only when geolocation actually applied; otherwise leave it unset so a
|
||||
// later on-demand match can tell the location was never resolved.
|
||||
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
|
||||
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
upstream_proxy.as_ref(),
|
||||
profile.vpn_id.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
));
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
updated_profile.wayfern_config = Some(updated_wayfern_config.clone());
|
||||
|
||||
log::info!(
|
||||
@@ -332,63 +381,15 @@ impl BrowserRunner {
|
||||
profile.name,
|
||||
updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0)
|
||||
);
|
||||
} else {
|
||||
// Safety net: the stored fingerprint's timezone and geolocation were
|
||||
// computed for whatever proxy was set when the fingerprint was
|
||||
// generated. If the profile's proxy or VPN has changed since (the
|
||||
// common case being a user who forgot to set a proxy at creation and
|
||||
// added one afterwards), that location data is stale and the user would
|
||||
// see the wrong timezone on first launch. When the routing signature no
|
||||
// longer matches, refresh just the location fields of the stored
|
||||
// fingerprint through the current proxy. Wayfern only; the randomize
|
||||
// path above already regenerates the whole fingerprint each launch.
|
||||
let current_geo_sig = crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
upstream_proxy.as_ref(),
|
||||
profile.vpn_id.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
);
|
||||
let geo_enabled = !matches!(
|
||||
wayfern_config.geoip.as_ref(),
|
||||
Some(serde_json::Value::Bool(false))
|
||||
);
|
||||
if geo_enabled
|
||||
&& wayfern_config.geo_proxy_signature.as_deref() != Some(current_geo_sig.as_str())
|
||||
{
|
||||
if let Some(stored_fp) = wayfern_config.fingerprint.clone() {
|
||||
log::info!(
|
||||
"Routing changed for Wayfern profile {} since its fingerprint was generated (was {:?}, now {}); refreshing timezone and geolocation",
|
||||
profile.name,
|
||||
wayfern_config.geo_proxy_signature,
|
||||
current_geo_sig
|
||||
);
|
||||
match crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
|
||||
&stored_fp,
|
||||
wayfern_config.proxy.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(refreshed) => {
|
||||
// Use the refreshed fingerprint for this launch...
|
||||
wayfern_config.fingerprint = Some(refreshed.clone());
|
||||
wayfern_config.geo_proxy_signature = Some(current_geo_sig.clone());
|
||||
// ...and persist it so the corrected location sticks and we do
|
||||
// not refresh again on the next launch with the same proxy.
|
||||
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
|
||||
cfg.fingerprint = Some(refreshed);
|
||||
cfg.geo_proxy_signature = Some(current_geo_sig);
|
||||
updated_profile.wayfern_config = Some(cfg);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"Could not refresh geolocation for Wayfern profile {} (proxy unreachable?); launching with existing location and will retry next launch",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A non-randomize profile keeps its configured fingerprint verbatim, even
|
||||
// when its proxy/VPN routing has changed since the fingerprint was built.
|
||||
// We deliberately do NOT silently rewrite its timezone/language to match
|
||||
// the new exit: that hid every real fingerprint-vs-exit mismatch (a US
|
||||
// fingerprint behind a German exit would be quietly relabelled German
|
||||
// before the launch-time consistency check could see it). The check now
|
||||
// surfaces the mismatch, and the user re-matches on demand via
|
||||
// `match_profile_fingerprint_to_exit`.
|
||||
|
||||
// Create ephemeral dir for ephemeral or password-protected profiles
|
||||
if profile.password_protected {
|
||||
@@ -451,6 +452,10 @@ impl BrowserRunner {
|
||||
format!("Failed to launch Wayfern: {e}").into()
|
||||
})?;
|
||||
|
||||
// Browser is up and using the worker — failures past this point must
|
||||
// not stop it.
|
||||
proxy_launch_guard.armed = false;
|
||||
|
||||
// Get the process ID from launch result
|
||||
let process_id = wayfern_result.processId.unwrap_or(0);
|
||||
log::info!("Wayfern launched successfully with PID: {process_id}");
|
||||
@@ -477,11 +482,18 @@ impl BrowserRunner {
|
||||
updated_profile.process_id = Some(process_id);
|
||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||
|
||||
// Update the proxy manager with the correct PID
|
||||
if let Err(e) = PROXY_MANAGER.update_proxy_pid(0, process_id) {
|
||||
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
|
||||
} else {
|
||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||
// Update the proxy manager with the correct PID. When the browser
|
||||
// reported no PID, keep the entry keyed by its unique placeholder (which
|
||||
// the cleanup sweep skips) rather than remapping to a shared 0 key that
|
||||
// concurrent launches could collide on.
|
||||
if process_id != 0 {
|
||||
if let Err(e) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
|
||||
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||
@@ -1090,6 +1102,10 @@ impl BrowserRunner {
|
||||
crate::profile::password::complete_after_quit_and_wait(profile).await;
|
||||
} else if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
} else if profile.clear_on_close {
|
||||
// Awaited for the same reason as re-encryption above: a queued sync
|
||||
// must see the cleared dir, not the pre-clear snapshot.
|
||||
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -1290,11 +1306,8 @@ pub async fn launch_browser_profile_impl(
|
||||
updated_profile.id
|
||||
);
|
||||
|
||||
// Now update the proxy with the correct PID if we have one
|
||||
if let Some(actual_pid) = updated_profile.process_id {
|
||||
// Update the proxy manager with the correct PID (we always started with temp pid 1)
|
||||
let _ = PROXY_MANAGER.update_proxy_pid(1u32, actual_pid);
|
||||
}
|
||||
// The proxy PID mapping was already reconciled inside launch_browser_internal
|
||||
// (placeholder → real browser PID); nothing is ever keyed by a constant here.
|
||||
|
||||
Ok(updated_profile)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ impl BrowserVersionManager {
|
||||
self.api_client.is_cache_expired(browser)
|
||||
}
|
||||
|
||||
/// Get the latest Wayfern version (cached first)
|
||||
/// Get the latest Wayfern version (fresh cache first)
|
||||
pub async fn get_browser_release_types(
|
||||
&self,
|
||||
browser: &str,
|
||||
@@ -118,17 +118,30 @@ impl BrowserVersionManager {
|
||||
return Err(format!("Unsupported browser: {browser}").into());
|
||||
}
|
||||
|
||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
});
|
||||
// Only trust an unexpired cache. A stale entry can point at a version that
|
||||
// is no longer published — the downloader rejects such requests, so serving
|
||||
// it here would make every download started from this list fail.
|
||||
if !self.api_client.is_cache_expired(browser) {
|
||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
|
||||
|
||||
Ok(BrowserReleaseTypes {
|
||||
stable: detailed_versions.first().map(|v| v.version.clone()),
|
||||
})
|
||||
// Expired or missing cache: fetch fresh, falling back to whatever cache
|
||||
// exists when the network is unavailable.
|
||||
match self.fetch_browser_versions_detailed(browser, false).await {
|
||||
Ok(detailed_versions) => Ok(BrowserReleaseTypes {
|
||||
stable: detailed_versions.first().map(|v| v.version.clone()),
|
||||
}),
|
||||
Err(e) => match self.get_cached_browser_versions_detailed(browser) {
|
||||
Some(cached_versions) => Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
}),
|
||||
None => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch browser versions with optional caching
|
||||
@@ -440,7 +453,7 @@ mod tests {
|
||||
|
||||
assert!(wayfern_info.url.contains("download.wayfern.com"));
|
||||
|
||||
let unsupported_result = service.get_download_info("firefox", "1.0.0");
|
||||
let unsupported_result = service.get_download_info("testbrowser", "1.0.0");
|
||||
assert!(unsupported_result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1427,7 +1427,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// Imported session cookies are promoted to persistent with a far-future
|
||||
// expiry so an imported login survives relaunch (mirrors the Firefox writer).
|
||||
// expiry so an imported login survives relaunch.
|
||||
assert_eq!(has_expires, 1);
|
||||
assert_eq!(is_persistent, 1);
|
||||
// Must be a real future expiry, not 0 (which Chromium reads as 1601-01-01).
|
||||
|
||||
@@ -16,6 +16,9 @@ pub enum BlocklistLevel {
|
||||
Pro,
|
||||
ProPlus,
|
||||
Ultimate,
|
||||
/// User-defined list: compiled from custom source URLs + custom block
|
||||
/// domains, with custom allow domains removed (allowlist overrides).
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl BlocklistLevel {
|
||||
@@ -26,6 +29,7 @@ impl BlocklistLevel {
|
||||
"pro" => Some(Self::Pro),
|
||||
"pro_plus" => Some(Self::ProPlus),
|
||||
"ultimate" => Some(Self::Ultimate),
|
||||
"custom" => Some(Self::Custom),
|
||||
"none" => Some(Self::None),
|
||||
_ => None,
|
||||
}
|
||||
@@ -39,6 +43,7 @@ impl BlocklistLevel {
|
||||
Self::Pro => "pro",
|
||||
Self::ProPlus => "pro_plus",
|
||||
Self::Ultimate => "ultimate",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +55,13 @@ impl BlocklistLevel {
|
||||
Self::Pro => "Pro",
|
||||
Self::ProPlus => "Pro++",
|
||||
Self::Ultimate => "Ultimate",
|
||||
Self::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::None | Self::Custom => None,
|
||||
Self::Light => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
||||
}
|
||||
@@ -80,6 +86,7 @@ impl BlocklistLevel {
|
||||
Self::Pro => Some("pro.txt"),
|
||||
Self::ProPlus => Some("pro.plus.txt"),
|
||||
Self::Ultimate => Some("ultimate.txt"),
|
||||
Self::Custom => Some("custom.txt"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +101,150 @@ impl BlocklistLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// User-defined DNS filtering: extra blocklist source URLs plus manual block /
|
||||
/// allow domain rules. Allow rules override blocks (the standard exceptions
|
||||
/// model). Stored as one JSON blob; the compiled result lands in `custom.txt`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CustomDnsConfig {
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub block_domains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_domains: Vec<String>,
|
||||
/// When true the custom list is a strict allowlist: the browser may only
|
||||
/// reach `allow_domains` (and their subdomains); everything else is blocked.
|
||||
/// Sources/block_domains are ignored in this mode.
|
||||
#[serde(default)]
|
||||
pub allowlist_mode: bool,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn normalize_domain(raw: &str) -> Option<String> {
|
||||
let mut line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
|
||||
return None;
|
||||
}
|
||||
// Hosts-file format ("0.0.0.0 ads.example.com", "127.0.0.1 tracker.net") is
|
||||
// common in public blocklists — take the domain after the sink IP rather
|
||||
// than rejecting the whole line on the embedded space.
|
||||
if let Some((first, rest)) = line.split_once(char::is_whitespace) {
|
||||
if matches!(first, "0.0.0.0" | "127.0.0.1" | "::" | "::1") {
|
||||
line = rest.trim();
|
||||
}
|
||||
}
|
||||
// Strip a trailing comment ("0.0.0.0 ads.example.com # AdGuard"). Public
|
||||
// hosts lists annotate entries this way; without this the whitespace guard
|
||||
// below rejects every annotated line, silently dropping most of the source.
|
||||
for marker in ['#', '!'] {
|
||||
if let Some((before, _)) = line.split_once(marker) {
|
||||
line = before.trim();
|
||||
}
|
||||
}
|
||||
let d = line
|
||||
.trim_start_matches("*.")
|
||||
.trim_start_matches("||")
|
||||
.trim_end_matches('^')
|
||||
.trim_end_matches('.')
|
||||
.to_lowercase();
|
||||
if d.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Reject anything still containing whitespace or a scheme — not a bare domain.
|
||||
if d.contains(char::is_whitespace) || d.contains("://") {
|
||||
return None;
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
impl CustomDnsConfig {
|
||||
fn path() -> PathBuf {
|
||||
app_dirs::data_subdir().join("custom_dns.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
match std::fs::read_to_string(Self::path()) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let path = Self::path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Serialize to a plain-text rule list (uBlock-ish): `! source:` comments for
|
||||
/// source URLs, `@@domain` for allow rules, bare domains for blocks.
|
||||
pub fn to_txt(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for s in &self.sources {
|
||||
out.push_str("! source: ");
|
||||
out.push_str(s);
|
||||
out.push('\n');
|
||||
}
|
||||
for d in &self.allow_domains {
|
||||
out.push_str("@@");
|
||||
out.push_str(d);
|
||||
out.push('\n');
|
||||
}
|
||||
for d in &self.block_domains {
|
||||
out.push_str(d);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a plain-text rule list back into a config (sources from
|
||||
/// `! source:` lines, `@@`-prefixed as allow, bare domains as block).
|
||||
pub fn from_txt(content: &str) -> Self {
|
||||
let mut cfg = CustomDnsConfig::default();
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(src) = line.strip_prefix("! source:") {
|
||||
let src = src.trim();
|
||||
if !src.is_empty() {
|
||||
cfg.sources.push(src.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('#') || line.starts_with('!') {
|
||||
continue;
|
||||
}
|
||||
if let Some(allow) = line.strip_prefix("@@") {
|
||||
if let Some(d) = normalize_domain(allow) {
|
||||
cfg.allow_domains.push(d);
|
||||
}
|
||||
} else if let Some(d) = normalize_domain(line) {
|
||||
cfg.block_domains.push(d);
|
||||
}
|
||||
}
|
||||
cfg.dedup();
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Drop duplicates, preserving first-seen order so an exported rule list
|
||||
/// still reads the way the user wrote it.
|
||||
fn dedup(&mut self) {
|
||||
for v in [
|
||||
&mut self.sources,
|
||||
&mut self.block_domains,
|
||||
&mut self.allow_domains,
|
||||
] {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
v.retain(|item| seen.insert(item.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlocklistCacheStatus {
|
||||
pub level: String,
|
||||
@@ -193,6 +344,19 @@ impl BlocklistManager {
|
||||
}
|
||||
|
||||
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
if level == BlocklistLevel::Custom {
|
||||
// Recompile only when the compiled file is missing or stale. Edits call
|
||||
// compile_custom_blocklist directly and refresh_all_stale keeps sources
|
||||
// current, so recompiling unconditionally would re-download every source
|
||||
// on every profile launch — and this is awaited on the blocking launch
|
||||
// path, so the browser cannot start until it finishes.
|
||||
if let Some(path) = Self::cached_file_path(level) {
|
||||
if Self::is_cache_fresh(level) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
return Self::compile_custom_blocklist().await;
|
||||
}
|
||||
if let Some(path) = Self::cached_file_path(level) {
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
@@ -201,6 +365,120 @@ impl BlocklistManager {
|
||||
Self::fetch_blocklist(level).await
|
||||
}
|
||||
|
||||
/// Compile the user's custom DNS file. In blocklist mode: fetch every source,
|
||||
/// union with the manual block domains, then remove the allow domains
|
||||
/// (allowlist overrides). In allowlist mode: the file is just the allow
|
||||
/// domains — the worker then blocks everything NOT listed. Always rewrites
|
||||
/// `custom.txt`.
|
||||
pub async fn compile_custom_blocklist() -> Result<PathBuf, String> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let config = CustomDnsConfig::load();
|
||||
let mut domains: HashSet<String> = HashSet::new();
|
||||
let path =
|
||||
Self::cached_file_path(BlocklistLevel::Custom).ok_or("No filename for custom level")?;
|
||||
|
||||
if config.allowlist_mode {
|
||||
// Strict allowlist: the compiled file is the set of permitted domains.
|
||||
for d in &config.allow_domains {
|
||||
if let Some(n) = normalize_domain(d) {
|
||||
domains.insert(n);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fetch every source concurrently. Sequential awaits make this the sum of
|
||||
// all source latencies, and it runs on the blocking profile-launch path.
|
||||
let fetches = config.sources.iter().map(|source| async move {
|
||||
match HTTP_CLIENT.get(source).send().await {
|
||||
Ok(resp) if resp.status().is_success() => resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("custom source {source} body read failed: {e}")),
|
||||
Ok(resp) => Err(format!(
|
||||
"custom source {source} returned HTTP {}",
|
||||
resp.status()
|
||||
)),
|
||||
Err(e) => Err(format!("custom source {source} failed: {e}")),
|
||||
}
|
||||
});
|
||||
|
||||
let mut source_failures = 0usize;
|
||||
for result in futures_util::future::join_all(fetches).await {
|
||||
match result {
|
||||
Ok(body) => {
|
||||
for line in body.lines() {
|
||||
if let Some(d) = normalize_domain(line) {
|
||||
domains.insert(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[dns-blocklist] {e}");
|
||||
source_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failed source must never shrink the compiled list. Overwriting with a
|
||||
// short (or empty) result would silently stop blocking whatever that
|
||||
// source contributed — and `is_blocked` fails open on an empty set, so a
|
||||
// single offline launch would disable custom filtering entirely and
|
||||
// destroy the good cached list. Re-seed from the cached file so a network
|
||||
// failure degrades to "stale" instead of "off"; manual rule edits below
|
||||
// still apply, and the next successful compile rewrites cleanly.
|
||||
if source_failures > 0 {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(cached) => {
|
||||
let before = domains.len();
|
||||
for line in cached.lines() {
|
||||
if let Some(d) = normalize_domain(line) {
|
||||
domains.insert(d);
|
||||
}
|
||||
}
|
||||
log::warn!(
|
||||
"[dns-blocklist] {source_failures} custom source(s) failed; retained {} domain(s) from the cached list",
|
||||
domains.len().saturating_sub(before)
|
||||
);
|
||||
}
|
||||
Err(_) => log::warn!(
|
||||
"[dns-blocklist] {source_failures} custom source(s) failed and no cached list exists; custom filtering is incomplete"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
for d in &config.block_domains {
|
||||
if let Some(n) = normalize_domain(d) {
|
||||
domains.insert(n);
|
||||
}
|
||||
}
|
||||
// Allow rules override blocks (exceptions).
|
||||
for d in &config.allow_domains {
|
||||
if let Some(n) = normalize_domain(d) {
|
||||
domains.remove(&n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cache_dir = Self::cache_dir();
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
|
||||
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
|
||||
sorted.sort_unstable();
|
||||
let body = sorted.join("\n");
|
||||
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
std::fs::write(&tmp_path, &body)
|
||||
.map_err(|e| format!("Failed to write custom blocklist: {e}"))?;
|
||||
std::fs::rename(&tmp_path, &path)
|
||||
.map_err(|e| format!("Failed to rename custom blocklist: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"[dns-blocklist] Compiled custom blocklist ({} domains)",
|
||||
domains.len()
|
||||
);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn refresh_all_stale(&self) {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
if !Self::is_cache_fresh(level) {
|
||||
@@ -219,6 +497,13 @@ impl BlocklistManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recompile the custom list too so its sources track upstream changes.
|
||||
let config = CustomDnsConfig::load();
|
||||
if !config.sources.is_empty() || !config.block_domains.is_empty() {
|
||||
if let Err(e) = Self::compile_custom_blocklist().await {
|
||||
log::error!("[dns-blocklist] Failed to recompile custom list: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
||||
@@ -289,6 +574,102 @@ pub async fn refresh_dns_blocklists() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_custom_dns_config() -> Result<CustomDnsConfig, String> {
|
||||
Ok(CustomDnsConfig::load())
|
||||
}
|
||||
|
||||
/// Normalize, persist, recompile and announce a custom DNS config. Shared by
|
||||
/// the set and import commands so both apply the same normalization and the
|
||||
/// same side effects — a step added here reaches both.
|
||||
async fn persist_custom_config(mut config: CustomDnsConfig) -> Result<CustomDnsConfig, String> {
|
||||
config.sources = std::mem::take(&mut config.sources)
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
config.block_domains = config
|
||||
.block_domains
|
||||
.iter()
|
||||
.filter_map(|d| normalize_domain(d))
|
||||
.collect();
|
||||
config.allow_domains = config
|
||||
.allow_domains
|
||||
.iter()
|
||||
.filter_map(|d| normalize_domain(d))
|
||||
.collect();
|
||||
config.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
config.dedup();
|
||||
config
|
||||
.save()
|
||||
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_SAVE_FAILED" }).to_string())?;
|
||||
// Recompile so a profile relaunch picks up the new rules immediately.
|
||||
let _ = BlocklistManager::compile_custom_blocklist().await;
|
||||
let _ = crate::events::emit_empty("custom-dns-changed");
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Persist the custom DNS config and recompile the `custom.txt` list. Domains
|
||||
/// are normalized; blank/comment entries are dropped.
|
||||
#[tauri::command]
|
||||
pub async fn set_custom_dns_config(
|
||||
sources: Vec<String>,
|
||||
block_domains: Vec<String>,
|
||||
allow_domains: Vec<String>,
|
||||
allowlist_mode: bool,
|
||||
) -> Result<CustomDnsConfig, String> {
|
||||
persist_custom_config(CustomDnsConfig {
|
||||
sources,
|
||||
block_domains,
|
||||
allow_domains,
|
||||
allowlist_mode,
|
||||
updated_at: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Import custom DNS rules. `format` is "json" (a full CustomDnsConfig) or
|
||||
/// "txt" (uBlock-ish: `! source:`, `@@allow`, bare block domains).
|
||||
#[tauri::command]
|
||||
pub async fn import_custom_dns_rules(
|
||||
content: String,
|
||||
format: String,
|
||||
) -> Result<CustomDnsConfig, String> {
|
||||
let config = match format.as_str() {
|
||||
"json" => serde_json::from_str::<CustomDnsConfig>(&content)
|
||||
.map_err(|_| serde_json::json!({ "code": "INVALID_DNS_RULES_JSON" }).to_string())?,
|
||||
"txt" => CustomDnsConfig::from_txt(&content),
|
||||
other => {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
|
||||
"params": { "format": other },
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
persist_custom_config(config).await
|
||||
}
|
||||
|
||||
/// Export the custom DNS rules as "json" or "txt".
|
||||
#[tauri::command]
|
||||
pub async fn export_custom_dns_rules(format: String) -> Result<String, String> {
|
||||
let config = CustomDnsConfig::load();
|
||||
match format.as_str() {
|
||||
"json" => serde_json::to_string_pretty(&config)
|
||||
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_EXPORT_FAILED" }).to_string()),
|
||||
"txt" => Ok(config.to_txt()),
|
||||
other => Err(
|
||||
serde_json::json!({
|
||||
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
|
||||
"params": { "format": other },
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -306,6 +687,83 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_config_txt_roundtrip() {
|
||||
let txt = "! source: https://example.com/list.txt\n@@allowed.com\nblocked.com\n*.tracker.net\n";
|
||||
let cfg = CustomDnsConfig::from_txt(txt);
|
||||
assert_eq!(cfg.sources, vec!["https://example.com/list.txt"]);
|
||||
assert_eq!(cfg.allow_domains, vec!["allowed.com"]);
|
||||
assert_eq!(cfg.block_domains, vec!["blocked.com", "tracker.net"]);
|
||||
// Re-exporting produces parseable text.
|
||||
let reparsed = CustomDnsConfig::from_txt(&cfg.to_txt());
|
||||
assert_eq!(reparsed.block_domains, cfg.block_domains);
|
||||
assert_eq!(reparsed.allow_domains, cfg.allow_domains);
|
||||
assert_eq!(reparsed.sources, cfg.sources);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_domain() {
|
||||
assert_eq!(
|
||||
normalize_domain("*.Example.COM"),
|
||||
Some("example.com".into())
|
||||
);
|
||||
assert_eq!(normalize_domain("||ads.net^"), Some("ads.net".into()));
|
||||
assert_eq!(normalize_domain(" "), None);
|
||||
assert_eq!(normalize_domain("# comment"), None);
|
||||
assert_eq!(normalize_domain("http://x.com"), None);
|
||||
// Hosts-file format lines yield the domain, not None.
|
||||
assert_eq!(
|
||||
normalize_domain("0.0.0.0 ads.example.com"),
|
||||
Some("ads.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_domain("127.0.0.1\ttracker.net"),
|
||||
Some("tracker.net".into())
|
||||
);
|
||||
// A bare two-token line that isn't hosts-format is still rejected.
|
||||
assert_eq!(normalize_domain("foo bar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_domain_strips_trailing_comments() {
|
||||
// Public hosts lists (StevenBlack, AdAway) annotate entries inline. Without
|
||||
// comment stripping the whitespace guard drops every annotated line, so a
|
||||
// source compiles down to a fraction of its real domain count.
|
||||
assert_eq!(
|
||||
normalize_domain("0.0.0.0 ads.example.com # AdGuard"),
|
||||
Some("ads.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_domain("127.0.0.1 tracker.net #comment"),
|
||||
Some("tracker.net".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_domain("plain.example.com # trailing note"),
|
||||
Some("plain.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_domain("ads.example.com ! adblock note"),
|
||||
Some("ads.example.com".into())
|
||||
);
|
||||
// A sink IP followed only by a comment has no domain left.
|
||||
assert_eq!(normalize_domain("0.0.0.0 # just a comment"), None);
|
||||
// Full-line comments stay rejected.
|
||||
assert_eq!(normalize_domain("! adblock header"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_level_roundtrip() {
|
||||
assert_eq!(
|
||||
BlocklistLevel::parse_level("custom"),
|
||||
Some(BlocklistLevel::Custom)
|
||||
);
|
||||
assert_eq!(BlocklistLevel::Custom.as_str(), "custom");
|
||||
assert_eq!(BlocklistLevel::Custom.filename(), Some("custom.txt"));
|
||||
assert!(BlocklistLevel::Custom.url().is_none());
|
||||
// Custom must NOT be in the auto-refresh set (no upstream URL).
|
||||
assert!(!BlocklistLevel::all_downloadable().contains(&BlocklistLevel::Custom));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_level_urls_all_present() {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
|
||||
@@ -1057,15 +1057,15 @@ mod tests {
|
||||
fn test_add_and_get_browser() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
|
||||
registry.add_browser(info.clone());
|
||||
|
||||
assert!(registry.is_browser_registered("firefox", "139.0"));
|
||||
assert!(!registry.is_browser_registered("firefox", "140.0"));
|
||||
assert!(registry.is_browser_registered("testbrowser", "139.0"));
|
||||
assert!(!registry.is_browser_registered("testbrowser", "140.0"));
|
||||
assert!(!registry.is_browser_registered("chrome", "139.0"));
|
||||
}
|
||||
|
||||
@@ -1074,19 +1074,19 @@ mod tests {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
let info1 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path1"),
|
||||
};
|
||||
|
||||
let info2 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path2"),
|
||||
};
|
||||
|
||||
let info3 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path3"),
|
||||
};
|
||||
@@ -1095,7 +1095,7 @@ mod tests {
|
||||
registry.add_browser(info2);
|
||||
registry.add_browser(info3);
|
||||
|
||||
let versions = registry.get_downloaded_versions("firefox");
|
||||
let versions = registry.get_downloaded_versions("testbrowser");
|
||||
assert_eq!(versions.len(), 3);
|
||||
assert!(versions.contains(&"139.0".to_string()));
|
||||
assert!(versions.contains(&"140.0".to_string()));
|
||||
@@ -1107,22 +1107,22 @@ mod tests {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Mark download started
|
||||
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
|
||||
registry.mark_download_started("testbrowser", "139.0", PathBuf::from("/test/path"));
|
||||
|
||||
// Should NOT be registered until verification completes
|
||||
assert!(
|
||||
!registry.is_browser_registered("firefox", "139.0"),
|
||||
!registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should NOT be registered after marking as started (only after verification)"
|
||||
);
|
||||
|
||||
// Mark as completed (after verification)
|
||||
registry
|
||||
.mark_download_completed("firefox", "139.0", PathBuf::from("/test/path"))
|
||||
.mark_download_completed("testbrowser", "139.0", PathBuf::from("/test/path"))
|
||||
.expect("Failed to mark download as completed");
|
||||
|
||||
// Should now be registered
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should be registered after verification completes"
|
||||
);
|
||||
}
|
||||
@@ -1131,24 +1131,24 @@ mod tests {
|
||||
fn test_remove_browser() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
|
||||
registry.add_browser(info);
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should be registered after adding"
|
||||
);
|
||||
|
||||
let removed = registry.remove_browser("firefox", "139.0");
|
||||
let removed = registry.remove_browser("testbrowser", "139.0");
|
||||
assert!(
|
||||
removed.is_some(),
|
||||
"Remove operation should return the removed browser info"
|
||||
);
|
||||
assert!(
|
||||
!registry.is_browser_registered("firefox", "139.0"),
|
||||
!registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should not be registered after removal"
|
||||
);
|
||||
}
|
||||
@@ -1182,11 +1182,11 @@ mod tests {
|
||||
fn test_last_version_kept_during_cleanup() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Add a single version for "firefox"
|
||||
// Add a single version for "testbrowser"
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/firefox/139.0"),
|
||||
file_path: PathBuf::from("/test/testbrowser/139.0"),
|
||||
});
|
||||
|
||||
// Add two versions for "chromium"
|
||||
@@ -1206,11 +1206,11 @@ mod tests {
|
||||
.cleanup_unused_binaries_internal(&[], &[])
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
// firefox 139.0 should be kept (last version), chromium should lose one but keep one
|
||||
// testbrowser 139.0 should be kept (last version), chromium should lose one but keep one
|
||||
// The exact one kept depends on iteration order, but at least one must remain
|
||||
assert!(
|
||||
!result.contains(&"firefox 139.0".to_string()),
|
||||
"Last version of firefox should not be cleaned up"
|
||||
!result.contains(&"testbrowser 139.0".to_string()),
|
||||
"Last version of testbrowser should not be cleaned up"
|
||||
);
|
||||
// At most one chromium version should have been cleaned up
|
||||
let chromium_cleaned: Vec<_> = result
|
||||
@@ -1223,10 +1223,10 @@ mod tests {
|
||||
chromium_cleaned
|
||||
);
|
||||
|
||||
// Verify firefox is still registered
|
||||
// Verify testbrowser is still registered
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
"Last firefox version should still be registered"
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Last testbrowser version should still be registered"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1234,7 +1234,7 @@ mod tests {
|
||||
fn test_is_browser_registered_vs_downloaded() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
@@ -1244,14 +1244,14 @@ mod tests {
|
||||
|
||||
// Should be registered (in-memory check)
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should be registered after adding to registry"
|
||||
);
|
||||
|
||||
// is_browser_downloaded should return false in test environment because files don't exist
|
||||
// This tests the difference between registered (in registry) vs downloaded (files exist)
|
||||
assert!(
|
||||
!registry.is_browser_downloaded("firefox", "139.0"),
|
||||
!registry.is_browser_downloaded("testbrowser", "139.0"),
|
||||
"Browser should not be considered downloaded when files don't exist on disk"
|
||||
);
|
||||
}
|
||||
@@ -1277,21 +1277,38 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
}
|
||||
log::info!("ensure_active: No {browser} versions found, will download");
|
||||
|
||||
// Get the latest release type for this browser
|
||||
let release_types = match version_manager.get_browser_release_types(browser).await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get release types for {browser}: {e}");
|
||||
continue;
|
||||
// Resolve the version to download. For wayfern, only the currently
|
||||
// published version is downloadable, so ask the API fresh — the release-type
|
||||
// cache can be stale right after a new version is published, and the
|
||||
// downloader rejects requests for versions that are no longer available.
|
||||
let version = if *browser == "wayfern" {
|
||||
match crate::api_client::ApiClient::instance()
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await
|
||||
{
|
||||
Ok(info) => info.version,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to resolve current {browser} version: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Get the latest release type for this browser
|
||||
let release_types = match version_manager.get_browser_release_types(browser).await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get release types for {browser}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Use stable version (the only release type for these browsers)
|
||||
let version = match release_types.stable {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
log::debug!("No stable version available for {browser} on this platform, skipping");
|
||||
continue;
|
||||
// Use stable version (the only release type for these browsers)
|
||||
match release_types.stable {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
log::debug!("No stable version available for {browser} on this platform, skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1330,8 +1347,8 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
Err(_) => {
|
||||
// The download future itself hung past the overall timeout and was dropped,
|
||||
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
|
||||
// (the future may have re-resolved to a different version, so clear by
|
||||
// browser prefix) and emit a terminal error event so the UI stops spinning.
|
||||
// (by browser prefix, as a catch-all for whatever key was in flight) and
|
||||
// emit a terminal error event so the UI stops spinning.
|
||||
log::warn!(
|
||||
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
|
||||
ATTEMPT_TIMEOUT.as_secs()
|
||||
|
||||
+71
-73
@@ -23,6 +23,22 @@ lazy_static::lazy_static! {
|
||||
std::sync::Arc::new(Mutex::new(std::collections::HashMap::new()));
|
||||
}
|
||||
|
||||
/// Clears a browser-version pair from the in-flight download maps on every
|
||||
/// exit path of `download_browser_full`. A leaked key would permanently report
|
||||
/// "already being downloaded" for that version until app restart.
|
||||
struct InFlightDownload(String);
|
||||
|
||||
impl Drop for InFlightDownload {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut downloading) = DOWNLOADING_BROWSERS.lock() {
|
||||
downloading.remove(&self.0);
|
||||
}
|
||||
if let Ok(mut tokens) = DOWNLOAD_CANCELLATION_TOKENS.lock() {
|
||||
tokens.remove(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadProgress {
|
||||
pub browser: String,
|
||||
@@ -97,7 +113,13 @@ impl Downloader {
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
return Err(
|
||||
format!(
|
||||
"Download failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
@@ -131,10 +153,16 @@ impl Downloader {
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await?;
|
||||
|
||||
// Never substitute: downloading the current build into the requested
|
||||
// version's directory would register a mislabeled install.
|
||||
if version_info.version != version {
|
||||
log::info!(
|
||||
"Wayfern: requested version {version}, using available version {}",
|
||||
version_info.version
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "WAYFERN_VERSION_NOT_AVAILABLE",
|
||||
"params": { "requested": version, "current": version_info.version }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,7 +329,13 @@ impl Downloader {
|
||||
|
||||
// Check if the response is successful (200 OK or 206 Partial Content)
|
||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
return Err(
|
||||
format!(
|
||||
"Download failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Determine total size
|
||||
@@ -504,25 +538,36 @@ impl Downloader {
|
||||
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
|
||||
}
|
||||
|
||||
// For Wayfern, resolve the actual available version from the API
|
||||
let version = if browser_str == "wayfern" {
|
||||
match self
|
||||
// Validate the browser type before touching the in-flight maps so a bad
|
||||
// request can't leave state behind.
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
let browser = create_browser(browser_type.clone());
|
||||
|
||||
// For Wayfern, only the currently published version can be fetched.
|
||||
// Requesting any other not-yet-downloaded version is an error — silently
|
||||
// substituting the latest would install a version the caller never asked
|
||||
// for while the response still echoes the requested one. The fetch must
|
||||
// succeed too: proceeding unverified would let resolve_download_url fetch
|
||||
// the current build into the requested version's directory (a mislabeled
|
||||
// install), and that URL resolution needs the same endpoint anyway.
|
||||
if browser_str == "wayfern" && !self.registry.is_browser_downloaded(&browser_str, &version) {
|
||||
let info = self
|
||||
.api_client
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await
|
||||
{
|
||||
Ok(info) if info.version != version => {
|
||||
log::info!(
|
||||
"Wayfern: requested {version}, using available {}",
|
||||
info.version
|
||||
);
|
||||
info.version
|
||||
}
|
||||
_ => version,
|
||||
.map_err(|e| format!("Failed to determine the current Wayfern version: {e}"))?;
|
||||
if info.version != version {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "WAYFERN_VERSION_NOT_AVAILABLE",
|
||||
"params": { "requested": version, "current": info.version }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
version
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this browser-version pair is already being downloaded
|
||||
let download_key = format!("{browser_str}-{version}");
|
||||
@@ -539,10 +584,8 @@ impl Downloader {
|
||||
tokens.insert(download_key.clone(), token.clone());
|
||||
token
|
||||
};
|
||||
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
let browser = create_browser(browser_type.clone());
|
||||
// Cleared on drop, whatever exit path this function takes.
|
||||
let _in_flight = InFlightDownload(download_key.clone());
|
||||
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
@@ -551,12 +594,6 @@ impl Downloader {
|
||||
let actually_exists = browser.is_version_downloaded(&version, &binaries_dir);
|
||||
|
||||
if actually_exists {
|
||||
// Remove from downloading set since it's already downloaded
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
return Ok(version);
|
||||
} else {
|
||||
// Registry says it's downloaded but files don't exist - clean up registry
|
||||
@@ -575,12 +612,6 @@ impl Downloader {
|
||||
.is_browser_supported(&browser_str)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Remove from downloading set on error
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
return Err(
|
||||
format!(
|
||||
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
|
||||
@@ -630,11 +661,6 @@ impl Downloader {
|
||||
// Clean registry entry and stop here so the UI can show a single, clear error.
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
|
||||
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
|
||||
// "cancelled"; any other failure (network error, stall timeout, bad status)
|
||||
@@ -687,14 +713,6 @@ impl Downloader {
|
||||
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Emit error stage so the UI shows a toast
|
||||
let progress = DownloadProgress {
|
||||
@@ -777,15 +795,6 @@ impl Downloader {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Remove browser-version pair from downloading set on verification failure
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
return Err(error_details.into());
|
||||
}
|
||||
|
||||
@@ -825,16 +834,6 @@ impl Downloader {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Remove browser-version pair from downloading set and cancel token
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
|
||||
{
|
||||
let app_handle_for_update = app_handle.clone();
|
||||
@@ -883,10 +882,9 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
/// Clear all in-progress download bookkeeping for a browser.
|
||||
///
|
||||
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||
/// by an outer timeout) before its own error path could run. Because
|
||||
/// `download_browser_full` may re-resolve to a different version than requested, this
|
||||
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
|
||||
/// key is left behind regardless of which version was actually in flight.
|
||||
/// by an outer timeout) before its own error path could run. Matches by the
|
||||
/// `"{browser}-"` key prefix rather than an exact version so no stuck key is left
|
||||
/// behind even when the caller doesn't know which version was actually in flight.
|
||||
pub fn clear_download_state_for_browser(browser: &str) {
|
||||
let prefix = format!("{browser}-");
|
||||
{
|
||||
@@ -909,7 +907,7 @@ pub async fn download_browser(
|
||||
downloader
|
||||
.download_browser_full(&app_handle, browser_str, version)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download browser: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to download browser"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -280,6 +280,7 @@ mod tests {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
|
||||
@@ -280,12 +280,21 @@ impl ExtensionManager {
|
||||
let (manifest_name, version, description, author, homepage_url) =
|
||||
extract_manifest_metadata(&file_data, &file_type);
|
||||
|
||||
let final_name = if manifest_name.is_some() {
|
||||
manifest_name.clone().unwrap_or(name)
|
||||
} else {
|
||||
name
|
||||
// An empty/whitespace-only manifest name counts as absent so the
|
||||
// user-provided name still applies.
|
||||
let final_name = match manifest_name.clone() {
|
||||
Some(n) if !n.trim().is_empty() => n,
|
||||
_ => name,
|
||||
};
|
||||
|
||||
if final_name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let ext = Extension {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: final_name,
|
||||
@@ -510,6 +519,14 @@ impl ExtensionManager {
|
||||
}
|
||||
|
||||
pub fn create_group(&self, name: String) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut data = self.load_groups_data()?;
|
||||
|
||||
if data.groups.iter().any(|g| g.name == name) {
|
||||
@@ -566,6 +583,14 @@ impl ExtensionManager {
|
||||
name: Option<String>,
|
||||
extension_ids: Option<Vec<String>>,
|
||||
) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
||||
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut data = self.load_groups_data()?;
|
||||
|
||||
if let Some(ref new_name) = name {
|
||||
@@ -1080,7 +1105,7 @@ pub async fn add_extension(
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.add_extension(name, file_name, file_data)
|
||||
.map_err(|e| format!("Failed to add extension: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to add extension"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1120,7 +1145,7 @@ pub async fn create_extension_group(name: String) -> Result<ExtensionGroup, Stri
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.create_group(name)
|
||||
.map_err(|e| format!("Failed to create extension group: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to create extension group"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1132,7 +1157,7 @@ pub async fn update_extension_group(
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.update_group(&group_id, name, extension_ids)
|
||||
.map_err(|e| format!("Failed to update extension group: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to update extension group"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+314
-27
@@ -45,6 +45,137 @@ fn has_quarantine_attr(path: &Path) -> bool {
|
||||
result >= 0
|
||||
}
|
||||
|
||||
/// Best-effort recursive size of a file tree. Uses `symlink_metadata` so
|
||||
/// symlinks inside .app bundles are not followed (`cp -R` copies them as
|
||||
/// links, so following them would overcount and could loop).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
let Ok(meta) = fs::symlink_metadata(path) else {
|
||||
return 0;
|
||||
};
|
||||
if meta.is_file() {
|
||||
return meta.len();
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
return 0;
|
||||
};
|
||||
entries.flatten().map(|entry| dir_size(&entry.path())).sum()
|
||||
}
|
||||
|
||||
const PROGRESS_REPORT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
/// Emits throttled "extracting" progress on the `download-progress` channel so
|
||||
/// the UI can render a moving bar during long extractions.
|
||||
pub struct ExtractionReporter {
|
||||
browser: String,
|
||||
version: String,
|
||||
last_emit: std::sync::Mutex<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl ExtractionReporter {
|
||||
pub fn new(browser: String, version: String) -> Self {
|
||||
// Backdate so the first report goes out immediately.
|
||||
let backdated = std::time::Instant::now()
|
||||
.checked_sub(PROGRESS_REPORT_INTERVAL)
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
Self {
|
||||
browser,
|
||||
version,
|
||||
last_emit: std::sync::Mutex::new(backdated),
|
||||
}
|
||||
}
|
||||
|
||||
/// Report byte-level progress where bytes map linearly onto the whole job.
|
||||
pub fn report_bytes(&self, done: u64, total: u64) {
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let done = done.min(total);
|
||||
self.report((done as f64 / total as f64) * 100.0, done, Some(total));
|
||||
}
|
||||
|
||||
/// Report a pre-computed percentage (multi-phase extractions where byte
|
||||
/// counts don't map linearly onto overall progress).
|
||||
pub fn report_percentage(&self, percentage: f64) {
|
||||
self.report(percentage, 0, None);
|
||||
}
|
||||
|
||||
/// Force a final 100% event so the bar lands full before the next stage.
|
||||
pub fn finish(&self) {
|
||||
self.emit(100.0, 0, None);
|
||||
}
|
||||
|
||||
fn report(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option<u64>) {
|
||||
{
|
||||
let mut last = self.last_emit.lock().unwrap();
|
||||
if last.elapsed() < PROGRESS_REPORT_INTERVAL {
|
||||
return;
|
||||
}
|
||||
*last = std::time::Instant::now();
|
||||
}
|
||||
self.emit(percentage, downloaded_bytes, total_bytes);
|
||||
}
|
||||
|
||||
fn emit(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option<u64>) {
|
||||
let progress = DownloadProgress {
|
||||
browser: self.browser.clone(),
|
||||
version: self.version.clone(),
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
percentage: percentage.clamp(0.0, 100.0),
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "extracting".to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader that passes cumulative bytes read to a callback on every read.
|
||||
/// Throttling is the reporter's job, so the callback can fire freely.
|
||||
struct ProgressReader<R, F: FnMut(u64)> {
|
||||
inner: R,
|
||||
bytes_read: u64,
|
||||
on_read: F,
|
||||
}
|
||||
|
||||
impl<R, F: FnMut(u64)> ProgressReader<R, F> {
|
||||
fn new(inner: R, on_read: F) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
bytes_read: 0,
|
||||
on_read,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read, F: FnMut(u64)> Read for ProgressReader<R, F> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let n = self.inner.read(buf)?;
|
||||
self.bytes_read += n as u64;
|
||||
(self.on_read)(self.bytes_read);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap an archive file so compressed bytes consumed report linearly against
|
||||
/// its on-disk size — shared by the streaming tar decoders, where stream
|
||||
/// position maps monotonically onto overall extraction progress.
|
||||
fn progress_file_reader(
|
||||
file: File,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> io::Result<impl Read + '_> {
|
||||
let compressed_total = file.metadata()?.len();
|
||||
Ok(ProgressReader::new(file, move |read| {
|
||||
if let Some(reporter) = progress {
|
||||
reporter.report_bytes(read, compressed_total);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub struct Extractor;
|
||||
|
||||
impl Extractor {
|
||||
@@ -145,6 +276,11 @@ impl Extractor {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Reports incremental extraction progress to the UI. Formats without a
|
||||
// measurable byte stream (MSI, plain EXE/AppImage copies) simply never
|
||||
// report, and the frontend falls back to an indeterminate bar.
|
||||
let reporter = ExtractionReporter::new(browser_type.as_str().to_string(), version.to_string());
|
||||
|
||||
log::info!(
|
||||
"Starting extraction of {} for browser {} version {}",
|
||||
archive_path.display(),
|
||||
@@ -166,7 +302,7 @@ impl Extractor {
|
||||
"dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.extract_dmg(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_dmg(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("DMG extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
@@ -177,22 +313,22 @@ impl Extractor {
|
||||
}
|
||||
}
|
||||
"zip" => {
|
||||
self.extract_zip(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_zip(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("ZIP extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
"tar.xz" => {
|
||||
self.extract_tar_xz(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_tar_xz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("TAR.XZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
"tar.bz2" => {
|
||||
self.extract_tar_bz2(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_tar_bz2(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("TAR.BZ2 extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
"tar.gz" => {
|
||||
self.extract_tar_gz(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_tar_gz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("TAR.GZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
@@ -237,6 +373,8 @@ impl Extractor {
|
||||
|
||||
match extraction_result {
|
||||
Ok(path) => {
|
||||
reporter.finish();
|
||||
|
||||
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
|
||||
// but only if there's actually something to remove. Calling the
|
||||
// modify-class `removexattr` syscall on a file without quarantine still
|
||||
@@ -381,6 +519,7 @@ impl Extractor {
|
||||
&self,
|
||||
dmg_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!(
|
||||
"Extracting DMG: {} to {}",
|
||||
@@ -454,20 +593,42 @@ impl Extractor {
|
||||
|
||||
log::info!("Copying .app to: {}", app_path.display());
|
||||
|
||||
// The copy is the long pole of DMG extraction; size up the source once so
|
||||
// we can report real progress by polling the destination while cp runs.
|
||||
// report_bytes no-ops on a 0 total, so the None path skips both walks.
|
||||
let total_size = if progress.is_some() {
|
||||
dir_size(&app_entry)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// `-X` strips extended attributes (notably com.apple.quarantine) during
|
||||
// the copy itself. Without it, `cp -R` preserves quarantine from the
|
||||
// mounted DMG, which then has to be removed with `xattr -dr` — and that
|
||||
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
|
||||
// Management TCC notification ("Donut.app was prevented from modifying
|
||||
// apps on your Mac"). Stripping at copy time is silent.
|
||||
let output = Command::new("cp")
|
||||
.args([
|
||||
"-RX",
|
||||
app_entry.to_str().unwrap(),
|
||||
app_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
let copy_src = app_entry.to_str().unwrap().to_string();
|
||||
let copy_dst = app_path.to_str().unwrap().to_string();
|
||||
let mut copy_task = tokio::spawn(async move {
|
||||
Command::new("cp")
|
||||
.args(["-RX", ©_src, ©_dst])
|
||||
.output()
|
||||
.await
|
||||
});
|
||||
|
||||
let output = loop {
|
||||
tokio::select! {
|
||||
result = &mut copy_task => {
|
||||
break result.map_err(|e| format!("Copy task failed: {e}"))??;
|
||||
}
|
||||
() = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
|
||||
if let Some(reporter) = progress {
|
||||
reporter.report_bytes(dir_size(&app_path), total_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -598,6 +759,7 @@ impl Extractor {
|
||||
&self,
|
||||
zip_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting ZIP archive: {}", zip_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
@@ -610,6 +772,15 @@ impl Extractor {
|
||||
|
||||
log::info!("ZIP archive contains {} files", archive.len());
|
||||
|
||||
// Total uncompressed size, known from the central directory without any
|
||||
// decompression. None for archives using data descriptors — those get no
|
||||
// byte progress (the UI falls back to an indeterminate bar).
|
||||
let total_uncompressed: Option<u64> = archive
|
||||
.decompressed_size()
|
||||
.and_then(|total| u64::try_from(total).ok())
|
||||
.filter(|total| *total > 0);
|
||||
let mut extracted_bytes: u64 = 0;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
@@ -639,8 +810,16 @@ impl Extractor {
|
||||
|
||||
let mut outfile = File::create(&outpath)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", outpath.display(), e))?;
|
||||
io::copy(&mut entry, &mut outfile)
|
||||
let entry_size = entry.size();
|
||||
let already_extracted = extracted_bytes;
|
||||
let mut reader = ProgressReader::new(&mut entry, |read| {
|
||||
if let (Some(reporter), Some(total)) = (progress, total_uncompressed) {
|
||||
reporter.report_bytes(already_extracted + read, total);
|
||||
}
|
||||
});
|
||||
io::copy(&mut reader, &mut outfile)
|
||||
.map_err(|e| format!("Failed to extract file {}: {}", outpath.display(), e))?;
|
||||
extracted_bytes = extracted_bytes.saturating_add(entry_size);
|
||||
|
||||
// Set executable permissions on Unix-like systems based on stored mode
|
||||
#[cfg(unix)]
|
||||
@@ -670,12 +849,14 @@ impl Extractor {
|
||||
&self,
|
||||
tar_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting tar.gz archive: {}", tar_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
|
||||
let file = File::open(tar_path)?;
|
||||
let gz_decoder = flate2::read::GzDecoder::new(BufReader::new(file));
|
||||
let counted = progress_file_reader(file, progress)?;
|
||||
let gz_decoder = flate2::read::GzDecoder::new(BufReader::new(counted));
|
||||
let mut archive = tar::Archive::new(gz_decoder);
|
||||
|
||||
archive.unpack(dest_dir)?;
|
||||
@@ -693,12 +874,14 @@ impl Extractor {
|
||||
&self,
|
||||
tar_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting tar.bz2 archive: {}", tar_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
|
||||
let file = File::open(tar_path)?;
|
||||
let bz2_decoder = bzip2::read::BzDecoder::new(BufReader::new(file));
|
||||
let counted = progress_file_reader(file, progress)?;
|
||||
let bz2_decoder = bzip2::read::BzDecoder::new(BufReader::new(counted));
|
||||
let mut archive = tar::Archive::new(bz2_decoder);
|
||||
|
||||
archive.unpack(dest_dir)?;
|
||||
@@ -716,6 +899,7 @@ impl Extractor {
|
||||
&self,
|
||||
tar_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting tar.xz archive: {}", tar_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
@@ -726,17 +910,34 @@ impl Extractor {
|
||||
// Read the entire file into memory for lzma-rs
|
||||
let mut compressed_data = Vec::new();
|
||||
buf_reader.read_to_end(&mut compressed_data)?;
|
||||
let compressed_total = compressed_data.len() as u64;
|
||||
|
||||
// Decompress using lzma-rs
|
||||
// Two phases with no shared byte scale: CPU-bound LZMA decompression
|
||||
// dominates, so compressed bytes consumed map to 0–80%, and the tar
|
||||
// unpack of the decompressed data to 80–100%.
|
||||
let mut decompressed_data = Vec::new();
|
||||
lzma_rs::xz_decompress(
|
||||
&mut std::io::Cursor::new(compressed_data),
|
||||
&mut decompressed_data,
|
||||
)?;
|
||||
let counted_input = ProgressReader::new(std::io::Cursor::new(compressed_data), |read| {
|
||||
if let Some(reporter) = progress {
|
||||
if compressed_total > 0 {
|
||||
reporter
|
||||
.report_percentage(80.0 * read.min(compressed_total) as f64 / compressed_total as f64);
|
||||
}
|
||||
}
|
||||
});
|
||||
lzma_rs::xz_decompress(&mut BufReader::new(counted_input), &mut decompressed_data)?;
|
||||
|
||||
// Create tar archive from decompressed data
|
||||
let cursor = std::io::Cursor::new(decompressed_data);
|
||||
let mut archive = tar::Archive::new(cursor);
|
||||
let decompressed_total = decompressed_data.len() as u64;
|
||||
let counted_tar = ProgressReader::new(std::io::Cursor::new(decompressed_data), |read| {
|
||||
if let Some(reporter) = progress {
|
||||
if decompressed_total > 0 {
|
||||
reporter.report_percentage(
|
||||
80.0 + 20.0 * read.min(decompressed_total) as f64 / decompressed_total as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut archive = tar::Archive::new(counted_tar);
|
||||
|
||||
archive.unpack(dest_dir)?;
|
||||
|
||||
@@ -1472,6 +1673,86 @@ mod tests {
|
||||
assert_eq!(result.unwrap(), "msi");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_zip_reports_progress() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturingEmitter(Mutex<Vec<serde_json::Value>>);
|
||||
|
||||
impl crate::events::EventEmitter for CapturingEmitter {
|
||||
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
|
||||
if event == "download-progress" {
|
||||
self.0.lock().unwrap().push(payload);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let captured = Arc::new(CapturingEmitter::default());
|
||||
// The global emitter can only be set once per process; if another test ever
|
||||
// claims it first we lose observability, so only assert on captured events
|
||||
// when this test's emitter actually won.
|
||||
let emitter_installed = crate::events::set_global_emitter(captured.clone()).is_ok();
|
||||
|
||||
let extractor = Extractor::instance();
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let dest_dir = temp_dir.path().join("extracted");
|
||||
|
||||
// A payload comfortably larger than io::copy's 8KB chunks so the counting
|
||||
// reader fires multiple times.
|
||||
let payload = vec![0x42u8; 256 * 1024];
|
||||
let zip_path = temp_dir.path().join("test.zip");
|
||||
{
|
||||
let file = std::fs::File::create(&zip_path).expect("Failed to create test zip file");
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
|
||||
zip
|
||||
.start_file("data.bin", options)
|
||||
.expect("Failed to start zip file");
|
||||
zip.write_all(&payload).expect("Failed to write to zip");
|
||||
zip.finish().expect("Failed to finish zip");
|
||||
}
|
||||
|
||||
let reporter =
|
||||
ExtractionReporter::new("test-browser-zip-progress".to_string(), "1.0.0".to_string());
|
||||
let result = extractor
|
||||
.extract_zip(&zip_path, &dest_dir, Some(&reporter))
|
||||
.await;
|
||||
|
||||
// Extraction itself must have worked even if no executable is found.
|
||||
assert!(dest_dir.join("data.bin").exists(), "payload not extracted");
|
||||
if let Err(e) = result {
|
||||
assert!(
|
||||
e.to_string().contains("executable"),
|
||||
"unexpected extraction error: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
if emitter_installed {
|
||||
let events = captured.0.lock().unwrap();
|
||||
let ours: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|p| p["browser"] == "test-browser-zip-progress")
|
||||
.collect();
|
||||
assert!(
|
||||
!ours.is_empty(),
|
||||
"expected at least one extracting progress event"
|
||||
);
|
||||
for p in &ours {
|
||||
assert_eq!(p["stage"], "extracting");
|
||||
assert_eq!(p["version"], "1.0.0");
|
||||
let pct = p["percentage"].as_f64().unwrap();
|
||||
assert!(
|
||||
(0.0..=100.0).contains(&pct),
|
||||
"percentage out of range: {pct}"
|
||||
);
|
||||
assert_eq!(p["total_bytes"].as_u64(), Some(payload.len() as u64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_zip_with_test_archive() {
|
||||
let extractor = Extractor::instance();
|
||||
@@ -1496,7 +1777,7 @@ mod tests {
|
||||
zip.finish().expect("Failed to finish zip");
|
||||
}
|
||||
|
||||
let result = extractor.extract_zip(&zip_path, &dest_dir).await;
|
||||
let result = extractor.extract_zip(&zip_path, &dest_dir, None).await;
|
||||
|
||||
// The result might fail because we're looking for executables, but the extraction should work
|
||||
// Let's check if the file was extracted regardless of the result
|
||||
@@ -1545,7 +1826,9 @@ mod tests {
|
||||
tar.finish().expect("Failed to finish tar");
|
||||
}
|
||||
|
||||
let result = extractor.extract_tar_gz(&tar_gz_path, &dest_dir).await;
|
||||
let result = extractor
|
||||
.extract_tar_gz(&tar_gz_path, &dest_dir, None)
|
||||
.await;
|
||||
|
||||
// Check if the file was extracted
|
||||
let extracted_file = dest_dir.join("test.txt");
|
||||
@@ -1596,7 +1879,9 @@ mod tests {
|
||||
tar.finish().expect("Failed to finish tar");
|
||||
}
|
||||
|
||||
let result = extractor.extract_tar_bz2(&tar_bz2_path, &dest_dir).await;
|
||||
let result = extractor
|
||||
.extract_tar_bz2(&tar_bz2_path, &dest_dir, None)
|
||||
.await;
|
||||
|
||||
// Check if the file was extracted
|
||||
let extracted_file = dest_dir.join("test.txt");
|
||||
@@ -1657,7 +1942,9 @@ mod tests {
|
||||
.expect("Failed to write compressed data");
|
||||
}
|
||||
|
||||
let result = extractor.extract_tar_xz(&tar_xz_path, &dest_dir).await;
|
||||
let result = extractor
|
||||
.extract_tar_xz(&tar_xz_path, &dest_dir, None)
|
||||
.await;
|
||||
|
||||
// Check if the file was extracted
|
||||
let extracted_file = dest_dir.join("test.txt");
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
|
||||
//! with the bundled MaxMind database (the same source the fingerprint generator
|
||||
//! uses), then compare its timezone and country against the profile
|
||||
//! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint
|
||||
//! behind a German exit IP) is a strong anti-bot tell even though the real
|
||||
//! device never leaks — so we warn the user after launch and offer to match the
|
||||
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
|
||||
//! a real mismatch always surfaces here.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
|
||||
/// Exit-node lookups are cached per proxy for this long. A stored proxy's exit
|
||||
/// geolocation is stable enough that re-resolving the exit IP through the proxy
|
||||
/// on every launch is wasteful.
|
||||
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedExit {
|
||||
fetched_at: u64,
|
||||
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
|
||||
/// its id, so without this an entry outlives the endpoint it describes: the
|
||||
/// check would compare a re-generated fingerprint against the *old* exit and
|
||||
/// either warn about a correct profile or — worse — call a genuinely
|
||||
/// mismatched one consistent, which is exactly the tell it exists to catch.
|
||||
proxy_url: String,
|
||||
timezone: Option<String>,
|
||||
country_code: Option<String>,
|
||||
ip: Option<String>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ConsistencyResult {
|
||||
/// True when everything we could check lines up (or there was nothing to
|
||||
/// check — no proxy assigned).
|
||||
pub consistent: bool,
|
||||
/// True when we actually reached an exit node and compared something.
|
||||
pub checked: bool,
|
||||
pub exit_ip: Option<String>,
|
||||
pub exit_country_code: Option<String>,
|
||||
pub exit_timezone: Option<String>,
|
||||
pub fingerprint_timezone: Option<String>,
|
||||
pub fingerprint_language: Option<String>,
|
||||
/// One of "timezone", "language" — the dimensions that disagree.
|
||||
pub mismatches: Vec<String>,
|
||||
}
|
||||
|
||||
impl ConsistencyResult {
|
||||
fn skip() -> Self {
|
||||
Self {
|
||||
consistent: true,
|
||||
checked: false,
|
||||
exit_ip: None,
|
||||
exit_country_code: None,
|
||||
exit_timezone: None,
|
||||
fingerprint_timezone: None,
|
||||
fingerprint_language: None,
|
||||
mismatches: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// URL for handing this proxy to reqwest, or None for upstreams reqwest can't
|
||||
/// drive (Shadowsocks).
|
||||
fn proxy_url(settings: &crate::browser::ProxySettings) -> Option<String> {
|
||||
match settings.proxy_type.to_lowercase().as_str() {
|
||||
"http" | "https" | "socks4" | "socks5" => Some(
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the fingerprint's language is plausible for the exit country.
|
||||
///
|
||||
/// Validated against the same CLDR data the fingerprint generator samples from
|
||||
/// (`geolocation::LocaleSelector`), not a hand-written country->language table.
|
||||
/// The generator picks a language at random weighted by CLDR speaker share, so
|
||||
/// any table naming one "expected" language per country flags fingerprints
|
||||
/// Donut itself produced — roughly 10% of US profiles legitimately get `es-US`
|
||||
/// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR
|
||||
/// data and the check is skipped.
|
||||
fn language_matches_country(cc: &str, language: &str) -> Option<bool> {
|
||||
crate::geolocation::locale_selector()?.region_speaks(cc, language)
|
||||
}
|
||||
|
||||
/// Extract (timezone, language) from a profile's stored fingerprint JSON.
|
||||
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
|
||||
let Some(config) = &profile.wayfern_config else {
|
||||
return (None, None);
|
||||
};
|
||||
let Some(fp_str) = &config.fingerprint else {
|
||||
return (None, None);
|
||||
};
|
||||
let Ok(fp) = serde_json::from_str::<serde_json::Value>(fp_str) else {
|
||||
return (None, None);
|
||||
};
|
||||
let timezone = fp
|
||||
.get("timezone")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let language = fp
|
||||
.get("language")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
(timezone, language)
|
||||
}
|
||||
|
||||
/// Run the check for a profile. No-ops (consistent, unchecked) when the
|
||||
/// profile has no proxy or the exit node can't be reached.
|
||||
pub async fn check_profile_consistency(
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<ConsistencyResult, String> {
|
||||
let Some(proxy_id) = &profile.proxy_id else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(url) = proxy_url(&settings) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
|
||||
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
|
||||
// it was measured through the proxy's current endpoint and credentials.
|
||||
let cached = {
|
||||
let cache = EXIT_CACHE.lock().unwrap();
|
||||
cache
|
||||
.get(proxy_id)
|
||||
.filter(|c| c.proxy_url == url && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS)
|
||||
.cloned()
|
||||
};
|
||||
|
||||
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
|
||||
(c.timezone, c.country_code, c.ip)
|
||||
} else {
|
||||
// Resolve the exit IP through the proxy, then geolocate it with the SAME
|
||||
// bundled MaxMind database the fingerprint generator (and the on-demand
|
||||
// match) use. Using one geo source everywhere means the check can never
|
||||
// disagree with what generation produced — a second source (e.g. ip-api)
|
||||
// routinely reports a different IANA zone for the same IP in multi-zone
|
||||
// countries, which would flag correctly-generated fingerprints and would
|
||||
// leave the "match to proxy" fix unable to satisfy the check.
|
||||
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
|
||||
.await
|
||||
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
let ip = Some(exit_ip);
|
||||
EXIT_CACHE.lock().unwrap().insert(
|
||||
proxy_id.clone(),
|
||||
CachedExit {
|
||||
fetched_at: now,
|
||||
proxy_url: url.clone(),
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: ip.clone(),
|
||||
},
|
||||
);
|
||||
(tz, cc, ip)
|
||||
}
|
||||
// Reached the exit but couldn't place it (database missing, or a private
|
||||
// exit IP). Skip rather than warn on an unknown location — the same
|
||||
// database gates fingerprint geo, so there's nothing to disagree with.
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate exit IP: {e}");
|
||||
return Ok(ConsistencyResult::skip());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
|
||||
if !exit.eq_ignore_ascii_case(fp) {
|
||||
mismatches.push("timezone".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
|
||||
if language_matches_country(cc, lang) == Some(false) {
|
||||
mismatches.push("language".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ConsistencyResult {
|
||||
consistent: mismatches.is_empty(),
|
||||
checked: true,
|
||||
exit_ip,
|
||||
exit_country_code: exit_cc,
|
||||
exit_timezone: exit_tz,
|
||||
fingerprint_timezone: fp_tz,
|
||||
fingerprint_language: fp_lang,
|
||||
mismatches,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_profile_fingerprint_consistency(
|
||||
profile_id: String,
|
||||
) -> Result<ConsistencyResult, String> {
|
||||
let profiles = crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||
check_profile_consistency(&profile).await
|
||||
}
|
||||
|
||||
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
|
||||
/// language, coordinates) matches `exit_ip`, and persist it. This is the
|
||||
/// on-demand resolution for the consistency warning: launches no longer rewrite
|
||||
/// the fingerprint silently, so the user opts into matching it here.
|
||||
///
|
||||
/// `exit_ip` is the exit the consistency check already resolved, applied via
|
||||
/// MaxMind directly — no second proxy round-trip — and it forces geolocation
|
||||
/// even when the profile has geo spoofing disabled, since the user explicitly
|
||||
/// asked to match. Takes effect on the next launch of the profile.
|
||||
#[tauri::command]
|
||||
pub async fn match_profile_fingerprint_to_exit(
|
||||
profile_id: String,
|
||||
exit_ip: String,
|
||||
) -> Result<(), String> {
|
||||
let manager = crate::profile::ProfileManager::instance();
|
||||
let mut profile = manager
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||
|
||||
let mut config = profile
|
||||
.wayfern_config
|
||||
.clone()
|
||||
.filter(|c| c.fingerprint.is_some())
|
||||
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
|
||||
let fingerprint = config.fingerprint.clone().unwrap();
|
||||
|
||||
let geoip_override = serde_json::Value::String(exit_ip);
|
||||
let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
|
||||
&fingerprint,
|
||||
None,
|
||||
Some(&geoip_override),
|
||||
)
|
||||
.await
|
||||
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
|
||||
|
||||
config.fingerprint = Some(refreshed);
|
||||
profile.wayfern_config = Some(config);
|
||||
manager.save_profile(&profile).map_err(|e| {
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn language_check_accepts_the_main_language_of_the_country() {
|
||||
assert_eq!(language_matches_country("US", "en-US"), Some(true));
|
||||
assert_eq!(language_matches_country("de", "de-DE"), Some(true));
|
||||
assert_eq!(language_matches_country("BR", "pt-BR"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_check_accepts_what_the_fingerprint_generator_emits() {
|
||||
// These are not the "expected" language for the country, but the generator
|
||||
// samples the CLDR distribution and produces them routinely — CLDR puts es
|
||||
// at 9.6% in the US and fr at 30% in Canada. Flagging them warns the user
|
||||
// about a fingerprint Donut itself created.
|
||||
assert_eq!(language_matches_country("US", "es-US"), Some(true));
|
||||
assert_eq!(language_matches_country("CA", "fr-CA"), Some(true));
|
||||
assert_eq!(language_matches_country("CA", "en-CA"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_check_flags_us_fingerprint_behind_armenian_exit() {
|
||||
// The reported scenario: a US (en) fingerprint routed through an Armenian
|
||||
// exit. Armenia's CLDR lists hy/ku/az, never en, so this must flag.
|
||||
assert_eq!(language_matches_country("AM", "en-US"), Some(false));
|
||||
// And a fingerprint actually matched to Armenia must not flag.
|
||||
assert_eq!(language_matches_country("AM", "hy-AM"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_check_still_flags_implausible_combinations() {
|
||||
// Nothing in CLDR associates these with the country, so they remain the
|
||||
// anti-bot tell the check exists to surface. (Japan lists ja/ryu/ko only.)
|
||||
assert_eq!(language_matches_country("JP", "pt-BR"), Some(false));
|
||||
assert_eq!(language_matches_country("JP", "de-DE"), Some(false));
|
||||
assert_eq!(language_matches_country("BR", "ru-RU"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_check_tolerates_minority_languages_the_generator_can_emit() {
|
||||
// CLDR lists ja for Brazil (0.21%, the Japanese-Brazilian community) and de
|
||||
// for the US (0.47%), and the generator samples both. They are weak signals
|
||||
// but flagging them would contradict our own fingerprints, so the check
|
||||
// accepts anything CLDR lists at all. That is the deliberate ceiling on
|
||||
// this dimension — timezone, compared exactly, carries the real signal.
|
||||
assert_eq!(language_matches_country("BR", "ja-JP"), Some(true));
|
||||
assert_eq!(language_matches_country("US", "de-DE"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_check_skips_countries_without_cldr_data() {
|
||||
// The old hand-written table returned None for ~180 countries and silently
|
||||
// skipped the check; only genuinely unknown territories should do that now.
|
||||
assert_eq!(language_matches_country("ZZ", "en-US"), None);
|
||||
// Countries the old table never covered are now checked.
|
||||
assert!(language_matches_country("ID", "id-ID").is_some());
|
||||
assert!(language_matches_country("CH", "de-CH").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||
let http = crate::browser::ProxySettings {
|
||||
proxy_type: "http".into(),
|
||||
host: "h".into(),
|
||||
port: 8080,
|
||||
username: Some("u".into()),
|
||||
password: Some("p".into()),
|
||||
};
|
||||
assert_eq!(proxy_url(&http).as_deref(), Some("http://u:p@h:8080"));
|
||||
|
||||
// A password with URL-reserved characters must not break the authority —
|
||||
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
|
||||
let reserved = crate::browser::ProxySettings {
|
||||
proxy_type: "http".into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("user".into()),
|
||||
password: Some("ab/cd@ef".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&reserved).as_deref(),
|
||||
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
// Username-only proxies keep their auth.
|
||||
let user_only = crate::browser::ProxySettings {
|
||||
proxy_type: "socks5".into(),
|
||||
host: "h".into(),
|
||||
port: 1080,
|
||||
username: Some("justuser".into()),
|
||||
password: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&user_only).as_deref(),
|
||||
Some("socks5://justuser@h:1080")
|
||||
);
|
||||
|
||||
let ss = crate::browser::ProxySettings {
|
||||
proxy_type: "ss".into(),
|
||||
host: "h".into(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
assert_eq!(proxy_url(&ss), None);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use rand::RngExt;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const TERRITORY_INFO_XML: &str = include_str!("territory_info.xml");
|
||||
|
||||
@@ -41,6 +42,27 @@ pub enum GeolocationError {
|
||||
Ip(#[from] IpError),
|
||||
}
|
||||
|
||||
/// The language part of a locale or language tag: `"en"` from `"en-US"`,
|
||||
/// `"zh"` from `"zh_Hant"`.
|
||||
pub fn primary_subtag(tag: &str) -> &str {
|
||||
tag.split(['-', '_']).next().unwrap_or(tag)
|
||||
}
|
||||
|
||||
/// Shared selector over the bundled CLDR territory data. Parsing the XML costs
|
||||
/// real time, and the data is immutable, so build it once.
|
||||
pub fn locale_selector() -> Option<&'static LocaleSelector> {
|
||||
static SELECTOR: OnceLock<Option<LocaleSelector>> = OnceLock::new();
|
||||
SELECTOR
|
||||
.get_or_init(|| match LocaleSelector::new() {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to build the CLDR locale selector: {e}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Locale {
|
||||
pub language: String,
|
||||
@@ -152,6 +174,28 @@ impl LocaleSelector {
|
||||
Ok(Self { territories })
|
||||
}
|
||||
|
||||
/// Whether CLDR associates `language` with `region` at all.
|
||||
///
|
||||
/// Returns `None` for a territory with no language data, so callers can skip
|
||||
/// rather than guess. The test is deliberately "is this language listed for
|
||||
/// this country", not "is this the country's main language": `from_region`
|
||||
/// samples the whole listed distribution weighted by speaker share, so every
|
||||
/// listed language is one this selector itself can emit. Anything stricter
|
||||
/// would flag fingerprints Donut generated on purpose — CLDR puts `es` at
|
||||
/// 9.6% in the US and `fr` at 30% in Canada, and those get picked.
|
||||
pub fn region_speaks(&self, region: &str, language: &str) -> Option<bool> {
|
||||
let languages = self.territories.get(®ion.to_uppercase())?;
|
||||
if languages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let primary = primary_subtag(language);
|
||||
Some(
|
||||
languages
|
||||
.iter()
|
||||
.any(|l| primary_subtag(&l.language).eq_ignore_ascii_case(primary)),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> {
|
||||
let region_upper = region.to_uppercase();
|
||||
|
||||
@@ -81,6 +81,14 @@ impl GroupManager {
|
||||
_app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if group with this name already exists
|
||||
@@ -127,6 +135,14 @@ impl GroupManager {
|
||||
id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if another group with this name already exists
|
||||
|
||||
+34
-17
@@ -28,7 +28,9 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
|
||||
"https://ipecho.net/plain",
|
||||
];
|
||||
|
||||
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5));
|
||||
// 10s rather than 5s: residential proxies that allocate an exit on first
|
||||
// connect routinely need more than 5s for the initial request.
|
||||
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
let client = if let Some(proxy_url) = proxy {
|
||||
let proxy = reqwest::Proxy::all(proxy_url)
|
||||
@@ -46,25 +48,40 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Overall deadline across all endpoints. Without it, a proxy that accepts
|
||||
// connections but stalls holds callers for the full 6 x 10s; slow-but-live
|
||||
// proxies still get the whole 10s on the endpoints that fit the budget.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
|
||||
for url in &urls {
|
||||
match client.get(*url).send().await {
|
||||
Ok(response) if response.status().is_success() => match response.text().await {
|
||||
Ok(text) => {
|
||||
let ip = text.trim().to_string();
|
||||
if validate_ip(&ip) {
|
||||
return Ok(ip);
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
errors.push(format!("{}: skipped (30s overall deadline reached)", url));
|
||||
continue;
|
||||
}
|
||||
|
||||
let attempt = async {
|
||||
match client.get(*url).send().await {
|
||||
Ok(response) if response.status().is_success() => match response.text().await {
|
||||
Ok(text) => {
|
||||
let ip = text.trim().to_string();
|
||||
if validate_ip(&ip) {
|
||||
Ok(ip)
|
||||
} else {
|
||||
Err(format!("{}: response is not an IP address", url))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{}: {}", url, e));
|
||||
}
|
||||
},
|
||||
Ok(response) => {
|
||||
errors.push(format!("{}: HTTP {}", url, response.status()));
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{}: {}", url, e));
|
||||
Err(e) => Err(format!("{}: {}", url, e)),
|
||||
},
|
||||
Ok(response) => Err(format!("{}: HTTP {}", url, response.status())),
|
||||
Err(e) => Err(format!("{}: {}", url, e)),
|
||||
}
|
||||
};
|
||||
|
||||
match tokio::time::timeout(remaining, attempt).await {
|
||||
Ok(Ok(ip)) => return Ok(ip),
|
||||
Ok(Err(e)) => errors.push(e),
|
||||
Err(_) => errors.push(format!("{}: timed out (30s overall deadline reached)", url)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-7
@@ -29,6 +29,7 @@ mod downloader;
|
||||
mod ephemeral_dirs;
|
||||
mod extension_manager;
|
||||
mod extraction;
|
||||
mod fingerprint_consistency;
|
||||
mod geoip_downloader;
|
||||
mod geolocation;
|
||||
mod group_manager;
|
||||
@@ -68,9 +69,10 @@ use browser_runner::{
|
||||
|
||||
use profile::manager::{
|
||||
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
|
||||
list_browser_profiles, rename_profile, update_profile_dns_blocklist, update_profile_launch_hook,
|
||||
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
|
||||
update_profile_tags, update_profile_vpn, update_profile_window_color, update_wayfern_config,
|
||||
list_browser_profiles, rename_profile, update_profile_clear_on_close,
|
||||
update_profile_dns_blocklist, update_profile_launch_hook, update_profile_note,
|
||||
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
|
||||
update_profile_window_color, update_wayfern_config,
|
||||
};
|
||||
|
||||
use profile::password::{
|
||||
@@ -125,7 +127,10 @@ use app_auto_updater::{
|
||||
restart_application,
|
||||
};
|
||||
|
||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
use profile_importer::{
|
||||
cleanup_profile_import_scratch, detect_existing_profiles, import_browser_profiles,
|
||||
scan_folder_for_profiles, scan_profile_archive,
|
||||
};
|
||||
|
||||
use extension_manager::{
|
||||
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
|
||||
@@ -245,6 +250,18 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prefix a command error with context, but pass structured `{"code": ...}`
|
||||
/// backend errors through untouched — the frontend can only translate a code
|
||||
/// when the JSON is the entire message (see src/lib/backend-errors.ts).
|
||||
pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("{context}: {msg}")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -254,7 +271,7 @@ async fn create_stored_proxy(
|
||||
if let Some(settings) = proxy_settings {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(&app_handle, name, settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
.map_err(|e| wrap_backend_error(e, "Failed to create stored proxy"))
|
||||
} else {
|
||||
Err("proxy_settings is required".to_string())
|
||||
}
|
||||
@@ -274,7 +291,7 @@ async fn update_stored_proxy(
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
.map_err(|e| wrap_backend_error(e, "Failed to update stored proxy"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -769,6 +786,13 @@ async fn clear_all_traffic_stats() -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to clear traffic stats: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn clear_profile_traffic_stats(profile_id: String) -> Result<(), String> {
|
||||
crate::traffic_stats::delete_traffic_stats(&profile_id);
|
||||
let _ = events::emit_empty("traffic-stats-changed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_traffic_stats_for_period(
|
||||
profile_id: String,
|
||||
@@ -1202,6 +1226,7 @@ async fn generate_sample_fingerprint(
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
@@ -1213,6 +1238,7 @@ async fn generate_sample_fingerprint(
|
||||
manager
|
||||
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
|
||||
.await
|
||||
.map(|(fingerprint, _geolocation_applied)| fingerprint)
|
||||
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
|
||||
} else {
|
||||
Err(format!(
|
||||
@@ -2028,6 +2054,16 @@ pub fn run() {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Clear-on-close for natural exits (user closed the window).
|
||||
// The explicit kill path in browser_runner.rs handles
|
||||
// app-driven stops. Must also run before
|
||||
// `mark_profile_stopped` so a queued sync sees the cleared
|
||||
// dir rather than re-uploading the wiped browsing data.
|
||||
if !is_running {
|
||||
crate::profile::clear_on_close::clear_profile_browsing_data(&profile)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Notify sync scheduler of running state changes
|
||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||
if is_running {
|
||||
@@ -2222,6 +2258,7 @@ pub fn run() {
|
||||
update_profile_vpn,
|
||||
update_profile_tags,
|
||||
update_profile_note,
|
||||
update_profile_clear_on_close,
|
||||
update_profile_launch_hook,
|
||||
update_profile_window_color,
|
||||
update_profile_proxy_bypass_rules,
|
||||
@@ -2255,7 +2292,10 @@ pub fn run() {
|
||||
download_and_prepare_app_update,
|
||||
restart_application,
|
||||
detect_existing_profiles,
|
||||
import_browser_profile,
|
||||
import_browser_profiles,
|
||||
scan_folder_for_profiles,
|
||||
scan_profile_archive,
|
||||
cleanup_profile_import_scratch,
|
||||
check_missing_binaries,
|
||||
check_missing_geoip_database,
|
||||
ensure_all_binaries_exist,
|
||||
@@ -2300,7 +2340,10 @@ pub fn run() {
|
||||
get_all_traffic_snapshots,
|
||||
get_profile_traffic_snapshot,
|
||||
clear_all_traffic_stats,
|
||||
clear_profile_traffic_stats,
|
||||
get_traffic_stats_for_period,
|
||||
fingerprint_consistency::check_profile_fingerprint_consistency,
|
||||
fingerprint_consistency::match_profile_fingerprint_to_exit,
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
set_profile_sync_mode,
|
||||
@@ -2376,6 +2419,10 @@ pub fn run() {
|
||||
// DNS blocklist commands
|
||||
dns_blocklist::get_dns_blocklist_cache_status,
|
||||
dns_blocklist::refresh_dns_blocklists,
|
||||
dns_blocklist::get_custom_dns_config,
|
||||
dns_blocklist::set_custom_dns_config,
|
||||
dns_blocklist::import_custom_dns_rules,
|
||||
dns_blocklist::export_custom_dns_rules,
|
||||
// Profile password commands
|
||||
set_profile_password,
|
||||
change_profile_password,
|
||||
|
||||
@@ -638,6 +638,64 @@ impl McpServer {
|
||||
"required": ["name", "browser"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "detect_browser_profiles".to_string(),
|
||||
description: "Detect importable Chromium-family browser profiles (Chrome, Chromium, Brave) on this machine, or scan a custom folder for profile directories".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description": "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child."
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "import_browser_profiles".to_string(),
|
||||
description: "Bulk-import browser profiles from on-disk profile folders (e.g. paths returned by detect_browser_profiles). Each imported profile becomes a Wayfern profile; items are isolated so one failure doesn't stop the rest".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the source profile directory"
|
||||
},
|
||||
"new_profile_name": {
|
||||
"type": "string",
|
||||
"description": "Name for the imported profile"
|
||||
},
|
||||
"proxy_id": {
|
||||
"type": "string",
|
||||
"description": "Optional proxy UUID to assign to this profile"
|
||||
},
|
||||
"vpn_id": {
|
||||
"type": "string",
|
||||
"description": "Optional VPN UUID to assign to this profile"
|
||||
}
|
||||
},
|
||||
"required": ["source_path", "new_profile_name"]
|
||||
},
|
||||
"description": "Profiles to import"
|
||||
},
|
||||
"group_id": {
|
||||
"type": "string",
|
||||
"description": "Optional group UUID assigned to every imported profile"
|
||||
},
|
||||
"duplicate_strategy": {
|
||||
"type": "string",
|
||||
"enum": ["skip", "rename"],
|
||||
"description": "How to handle an already-taken profile name (default: rename with a numeric suffix)"
|
||||
}
|
||||
},
|
||||
"required": ["items"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_profile".to_string(),
|
||||
description: "Update an existing browser profile's settings".to_string(),
|
||||
@@ -677,6 +735,10 @@ impl McpServer {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Proxy bypass rules (replaces existing rules)"
|
||||
},
|
||||
"clear_on_close": {
|
||||
"type": "boolean",
|
||||
"description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles."
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
@@ -1731,6 +1793,9 @@ impl McpServer {
|
||||
self.handle_batch_stop_profiles(arguments).await
|
||||
}
|
||||
"create_profile" => self.handle_create_profile(arguments).await,
|
||||
// Profile import (free, like create_profile — importing is not automation)
|
||||
"detect_browser_profiles" => self.handle_detect_browser_profiles(arguments).await,
|
||||
"import_browser_profiles" => self.handle_import_browser_profiles(arguments).await,
|
||||
"update_profile" => self.handle_update_profile(arguments).await,
|
||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||
"list_tags" => self.handle_list_tags().await,
|
||||
@@ -2490,6 +2555,14 @@ impl McpServer {
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) {
|
||||
pm.update_profile_clear_on_close(app_handle, profile_id, clear_on_close)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update clear-on-close: {e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -3201,6 +3274,95 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
// Profile import handlers
|
||||
async fn handle_detect_browser_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let importer = crate::profile_importer::ProfileImporter::instance();
|
||||
let profiles = match arguments.get("folder").and_then(|v| v.as_str()) {
|
||||
Some(folder) => importer.scan_folder(std::path::Path::new(folder)),
|
||||
None => importer.detect_existing_profiles(),
|
||||
}
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to detect profiles: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&profiles).unwrap_or_else(|_| "[]".to_string())
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_import_browser_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let items: Vec<crate::profile_importer::ImportProfileItem> = arguments
|
||||
.get("items")
|
||||
.cloned()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing items".to_string(),
|
||||
})
|
||||
.and_then(|v| {
|
||||
serde_json::from_value(v).map_err(|e| McpError {
|
||||
code: -32602,
|
||||
message: format!("Invalid items: {e}"),
|
||||
})
|
||||
})?;
|
||||
|
||||
let group_id = arguments
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let duplicate_strategy = arguments
|
||||
.get("duplicate_strategy")
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<crate::profile_importer::DuplicateStrategy>)
|
||||
.transpose()
|
||||
.map_err(|e| McpError {
|
||||
code: -32602,
|
||||
message: format!("Invalid duplicate_strategy: {e}"),
|
||||
})?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Clone the handle instead of holding the inner lock across a potentially
|
||||
// multi-GB copy.
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner.app_handle.clone().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
};
|
||||
|
||||
let result = crate::profile_importer::ProfileImporter::instance()
|
||||
.import_profiles(&app_handle, items, group_id, duplicate_strategy, None)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to import profiles: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Import complete: {} imported, {} skipped, {} failed\n{}",
|
||||
result.imported_count,
|
||||
result.skipped_count,
|
||||
result.failed_count,
|
||||
serde_json::to_string_pretty(&result.results).unwrap_or_default()
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// Cookie management handlers
|
||||
async fn handle_import_profile_cookies(
|
||||
&self,
|
||||
@@ -5304,6 +5466,9 @@ mod tests {
|
||||
assert!(tool_names.contains(&"run_profile"));
|
||||
assert!(tool_names.contains(&"kill_profile"));
|
||||
assert!(tool_names.contains(&"get_profile_status"));
|
||||
// Profile import tools
|
||||
assert!(tool_names.contains(&"detect_browser_profiles"));
|
||||
assert!(tool_names.contains(&"import_browser_profiles"));
|
||||
// Group tools
|
||||
assert!(tool_names.contains(&"list_groups"));
|
||||
assert!(tool_names.contains(&"get_group"));
|
||||
|
||||
@@ -15,8 +15,7 @@ use std::process::Command;
|
||||
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: `-profile <path>`; some launchers
|
||||
// pass the path as its own arg).
|
||||
// Exact argument equality (some launchers pass the path as its own arg).
|
||||
if *arg == profile_path {
|
||||
return true;
|
||||
}
|
||||
@@ -85,59 +84,6 @@ pub mod macos {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
profiles_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let pid = profile.process_id.unwrap();
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
|
||||
// First try: Use Firefox remote command
|
||||
log::info!("Trying Firefox remote command for PID: {pid}");
|
||||
let browser = create_browser(browser_type);
|
||||
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
|
||||
let remote_args = vec![
|
||||
"-profile".to_string(),
|
||||
profile_data_path.to_string_lossy().to_string(),
|
||||
"-new-tab".to_string(),
|
||||
url.to_string(),
|
||||
];
|
||||
|
||||
let remote_output = Command::new(executable_path).args(&remote_args).output();
|
||||
|
||||
match remote_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("Firefox remote command succeeded");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::info!(
|
||||
"Firefox remote command failed with stderr: {stderr}, trying AppleScript fallback"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("Firefox remote command error: {e}, trying AppleScript fallback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Firefox `-new-tab` remote command failed. We intentionally do NOT
|
||||
// fall back to an AppleScript `System Events` keystroke path: that would
|
||||
// send Apple Events to another application and trigger the macOS TCC
|
||||
// "<Donut> wants control of <Browser>" / "prevented from modifying other
|
||||
// apps" prompts. Donut must never touch other apps on the user's Mac.
|
||||
Err(
|
||||
format!(
|
||||
"Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
profile_data_path: Option<&str>,
|
||||
@@ -399,77 +345,6 @@ pub mod windows {
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
profiles_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let browser = create_browser(browser_type);
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
|
||||
// For Windows, try using the -requestPending approach for Firefox
|
||||
let mut cmd = Command::new(executable_path);
|
||||
cmd.args([
|
||||
"-profile",
|
||||
&profile_data_path.to_string_lossy(),
|
||||
"-requestPending",
|
||||
"-new-tab",
|
||||
url,
|
||||
]);
|
||||
|
||||
// Set working directory
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Fallback: try without -requestPending
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
let mut fallback_cmd = Command::new(executable_path);
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
fallback_cmd.args([
|
||||
"-profile",
|
||||
&profile_data_path.to_string_lossy(),
|
||||
"-new-tab",
|
||||
url,
|
||||
]);
|
||||
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
fallback_cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let fallback_output = fallback_cmd.output()?;
|
||||
|
||||
if !fallback_output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing browser: {}",
|
||||
String::from_utf8_lossy(&fallback_output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_chromium(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
@@ -588,7 +463,7 @@ pub mod linux {
|
||||
let mut cmd = Command::new(executable_path);
|
||||
cmd.args(args);
|
||||
|
||||
// For Firefox-based browsers, ensure library path includes the installation directory
|
||||
// Ensure the library path includes the installation directory
|
||||
if let Some(install_dir) = executable_path.parent() {
|
||||
let mut ld_library_path = Vec::new();
|
||||
|
||||
@@ -606,16 +481,15 @@ pub mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// For Firefox specifically, add common system library paths that might be needed
|
||||
let firefox_lib_paths = [
|
||||
"/usr/lib/firefox",
|
||||
// Add common system library paths that might be needed
|
||||
let system_lib_paths = [
|
||||
"/usr/lib/x86_64-linux-gnu",
|
||||
"/usr/lib/aarch64-linux-gnu",
|
||||
"/lib/x86_64-linux-gnu",
|
||||
"/lib/aarch64-linux-gnu",
|
||||
];
|
||||
|
||||
for lib_path in &firefox_lib_paths {
|
||||
for lib_path in &system_lib_paths {
|
||||
let path = std::path::Path::new(lib_path);
|
||||
if path.exists() {
|
||||
ld_library_path.push(lib_path.to_string());
|
||||
@@ -686,41 +560,6 @@ pub mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
profiles_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let browser = create_browser(browser_type);
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
let output = Command::new(executable_path)
|
||||
.args([
|
||||
"-profile",
|
||||
&profile_data_path.to_string_lossy(),
|
||||
"-new-tab",
|
||||
url,
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing browser: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_chromium(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Clear-on-close: wipe a profile's browsing data when the browser exits,
|
||||
//! keeping extensions and bookmarks (and the settings files Chromium needs to
|
||||
//! keep those working). The middle ground between a fully persistent profile
|
||||
//! and a RAM-backed ephemeral one.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
/// Entries kept inside a Chromium profile directory (one with `Preferences`).
|
||||
/// Everything else — cookies, History, caches, storage, sessions, autofill,
|
||||
/// saved logins — is browsing data and gets removed.
|
||||
const PROFILE_KEEP: &[&str] = &[
|
||||
"Bookmarks",
|
||||
"Bookmarks.bak",
|
||||
"Extensions",
|
||||
"Extension State",
|
||||
"Extension Rules",
|
||||
"Extension Scripts",
|
||||
"Extension Cookies",
|
||||
"Local Extension Settings",
|
||||
"Managed Extension Settings",
|
||||
// Preferences hold the extension registry + user settings; deleting them
|
||||
// disables every installed extension, so they stay.
|
||||
"Preferences",
|
||||
"Secure Preferences",
|
||||
];
|
||||
|
||||
/// Entries kept at the user-data-dir top level (outside profile subdirs).
|
||||
const TOP_KEEP: &[&str] = &["Local State", "First Run"];
|
||||
|
||||
fn is_kept(name: &str) -> bool {
|
||||
PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name)
|
||||
}
|
||||
|
||||
/// Whether a top-level entry name is one of Chromium's profile directories.
|
||||
///
|
||||
/// Identity must not rest on `Preferences` existing. Chromium writes it lazily,
|
||||
/// so a crash — or a user deleting a corrupt copy, a standard troubleshooting
|
||||
/// step since it regenerates — leaves a populated `Default/` without it. Such a
|
||||
/// directory would then be taken for junk and removed wholesale, destroying the
|
||||
/// Extensions and Bookmarks this feature exists to preserve.
|
||||
fn is_profile_dir_name(name: &str) -> bool {
|
||||
matches!(name, "Default" | "Guest Profile" | "System Profile")
|
||||
|| name
|
||||
.strip_prefix("Profile ")
|
||||
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn remove_entry(path: &Path) {
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log::warn!("clear-on-close: failed to remove {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe browsing data inside a Chromium profile dir, keeping `PROFILE_KEEP`
|
||||
/// (and `TOP_KEEP`, harmless at this level).
|
||||
fn clear_chromium_profile_dir(profile_dir: &Path) -> usize {
|
||||
let mut cleared = 0usize;
|
||||
let Ok(entries) = fs::read_dir(profile_dir) else {
|
||||
return 0;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if is_kept(name) {
|
||||
continue;
|
||||
}
|
||||
remove_entry(&entry.path());
|
||||
cleared += 1;
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
/// Clear browsing data in a Wayfern user-data directory. Handles both
|
||||
/// layouts: profile content at the root (imported profiles copy a Chromium
|
||||
/// profile dir directly) and the standard `Default` / `Profile N` subdirs
|
||||
/// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache,
|
||||
/// component_crx_cache, …) are removed; `Local State` stays because it holds
|
||||
/// the os_crypt key extensions may rely on.
|
||||
pub fn clear_user_data_dir(user_data_dir: &Path) -> usize {
|
||||
if !user_data_dir.exists() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Root itself is a profile dir (legacy/imported layout).
|
||||
if user_data_dir.join("Preferences").exists() {
|
||||
return clear_chromium_profile_dir(user_data_dir);
|
||||
}
|
||||
|
||||
let mut cleared = 0usize;
|
||||
let Ok(entries) = fs::read_dir(user_data_dir) else {
|
||||
return 0;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if is_kept(name) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() && (path.join("Preferences").exists() || is_profile_dir_name(name)) {
|
||||
// A profile subdir (Default / Profile N) — clear inside, keep the dir.
|
||||
cleared += clear_chromium_profile_dir(&path);
|
||||
} else {
|
||||
remove_entry(&path);
|
||||
cleared += 1;
|
||||
}
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
/// Clear a profile's browsing data if its `clear_on_close` flag is set.
|
||||
/// No-ops for ephemeral (already wiped) and password-protected (dir is
|
||||
/// re-encrypted; plaintext never persists) profiles. Runs the filesystem work
|
||||
/// on a blocking thread.
|
||||
pub async fn clear_profile_browsing_data(profile: &BrowserProfile) {
|
||||
if !profile.clear_on_close || profile.ephemeral || profile.password_protected {
|
||||
return;
|
||||
}
|
||||
|
||||
let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir();
|
||||
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let name = profile.name.clone();
|
||||
|
||||
let cleared = tokio::task::spawn_blocking(move || clear_user_data_dir(&user_data_dir))
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
log::info!("clear-on-close: cleared {cleared} browsing-data entries for profile '{name}'");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(dir: &Path, name: &str) {
|
||||
fs::write(dir.join(name), "x").unwrap();
|
||||
}
|
||||
|
||||
fn mkdir(dir: &Path, name: &str) {
|
||||
fs::create_dir_all(dir.join(name)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_root_profile_layout_keeping_extensions_and_bookmarks() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
touch(dir, "Preferences");
|
||||
touch(dir, "Secure Preferences");
|
||||
touch(dir, "Bookmarks");
|
||||
touch(dir, "History");
|
||||
touch(dir, "Web Data");
|
||||
touch(dir, "Login Data");
|
||||
mkdir(dir, "Extensions");
|
||||
mkdir(dir, "Local Extension Settings");
|
||||
mkdir(dir, "Cache");
|
||||
mkdir(dir, "Network");
|
||||
touch(&dir.join("Network"), "Cookies");
|
||||
mkdir(dir, "Local Storage");
|
||||
|
||||
let cleared = clear_user_data_dir(dir);
|
||||
assert!(
|
||||
cleared >= 5,
|
||||
"should clear History/WebData/Login/Cache/Network/LocalStorage, got {cleared}"
|
||||
);
|
||||
|
||||
assert!(dir.join("Preferences").exists());
|
||||
assert!(dir.join("Bookmarks").exists());
|
||||
assert!(dir.join("Extensions").exists());
|
||||
assert!(dir.join("Local Extension Settings").exists());
|
||||
assert!(!dir.join("History").exists());
|
||||
assert!(!dir.join("Web Data").exists());
|
||||
assert!(!dir.join("Login Data").exists());
|
||||
assert!(!dir.join("Cache").exists());
|
||||
assert!(!dir.join("Network").exists(), "Network/Cookies must go");
|
||||
assert!(!dir.join("Local Storage").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_standard_user_data_layout() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
touch(dir, "Local State");
|
||||
mkdir(dir, "ShaderCache");
|
||||
mkdir(dir, "Default");
|
||||
let default = dir.join("Default");
|
||||
touch(&default, "Preferences");
|
||||
touch(&default, "Bookmarks");
|
||||
touch(&default, "History");
|
||||
mkdir(&default, "Extensions");
|
||||
mkdir(&default, "IndexedDB");
|
||||
|
||||
clear_user_data_dir(dir);
|
||||
|
||||
assert!(dir.join("Local State").exists());
|
||||
assert!(!dir.join("ShaderCache").exists());
|
||||
assert!(default.join("Preferences").exists());
|
||||
assert!(default.join("Bookmarks").exists());
|
||||
assert!(default.join("Extensions").exists());
|
||||
assert!(!default.join("History").exists());
|
||||
assert!(!default.join("IndexedDB").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_subdir_without_preferences_keeps_extensions_and_bookmarks() {
|
||||
// Chromium writes Preferences lazily, so a crash (or a user removing a
|
||||
// corrupt copy) can leave a populated Default/ without it. Identifying
|
||||
// profile dirs by Preferences alone would delete the whole directory.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
touch(dir, "Local State");
|
||||
mkdir(dir, "Default");
|
||||
let default = dir.join("Default");
|
||||
touch(&default, "Bookmarks");
|
||||
touch(&default, "History");
|
||||
mkdir(&default, "Extensions");
|
||||
mkdir(&default, "Local Extension Settings");
|
||||
mkdir(&default, "IndexedDB");
|
||||
|
||||
clear_user_data_dir(dir);
|
||||
|
||||
assert!(default.exists(), "the profile dir must survive");
|
||||
assert!(default.join("Bookmarks").exists());
|
||||
assert!(default.join("Extensions").exists());
|
||||
assert!(default.join("Local Extension Settings").exists());
|
||||
// Browsing data inside it is still cleared.
|
||||
assert!(!default.join("History").exists());
|
||||
assert!(!default.join("IndexedDB").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_profile_dirs_are_recognised() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
mkdir(dir, "Profile 2");
|
||||
let p2 = dir.join("Profile 2");
|
||||
touch(&p2, "Bookmarks");
|
||||
touch(&p2, "History");
|
||||
|
||||
clear_user_data_dir(dir);
|
||||
|
||||
assert!(p2.join("Bookmarks").exists());
|
||||
assert!(!p2.join("History").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_profile_dirs_are_still_removed_wholesale() {
|
||||
// The permissive branch must not turn into "never delete a directory":
|
||||
// top-level caches are browsing data and have to go.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
mkdir(dir, "ShaderCache");
|
||||
mkdir(dir, "component_crx_cache");
|
||||
mkdir(dir, "Profileless");
|
||||
|
||||
clear_user_data_dir(dir);
|
||||
|
||||
assert!(!dir.join("ShaderCache").exists());
|
||||
assert!(!dir.join("component_crx_cache").exists());
|
||||
assert!(
|
||||
!dir.join("Profileless").exists(),
|
||||
"a name that merely starts with 'Profile' is not a profile dir"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_dir_name_matching() {
|
||||
assert!(is_profile_dir_name("Default"));
|
||||
assert!(is_profile_dir_name("Profile 1"));
|
||||
assert!(is_profile_dir_name("Profile 42"));
|
||||
assert!(is_profile_dir_name("Guest Profile"));
|
||||
assert!(is_profile_dir_name("System Profile"));
|
||||
assert!(!is_profile_dir_name("Profile"));
|
||||
assert!(!is_profile_dir_name("Profile "));
|
||||
assert!(!is_profile_dir_name("Profile abc"));
|
||||
assert!(!is_profile_dir_name("ShaderCache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dir_is_a_noop() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
assert_eq!(clear_user_data_dir(&tmp.path().join("nope")), 0);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,14 @@ impl ProfileManager {
|
||||
dns_blocklist: Option<String>,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
}
|
||||
@@ -168,6 +176,11 @@ impl ProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the fingerprint's location fields are known to match the
|
||||
// profile's routing. Provided fingerprints keep the old stamping
|
||||
// behavior; for generated ones this comes from the geolocation lookup.
|
||||
let mut geolocation_applied = true;
|
||||
|
||||
// Generate fingerprint if not already provided
|
||||
if config.fingerprint.is_none() {
|
||||
log::info!("Generating fingerprint for Wayfern profile: {name}");
|
||||
@@ -200,6 +213,7 @@ impl ProfileManager {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
@@ -209,8 +223,9 @@ impl ProfileManager {
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(generated_fingerprint) => {
|
||||
Ok((generated_fingerprint, geo_applied)) => {
|
||||
config.fingerprint = Some(generated_fingerprint);
|
||||
geolocation_applied = geo_applied;
|
||||
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -226,15 +241,27 @@ impl ProfileManager {
|
||||
// Record which proxy/geoip the fingerprint's location data was computed
|
||||
// for. On launch this is compared against the profile's current routing
|
||||
// so a proxy that was changed after creation triggers a location refresh
|
||||
// instead of showing a stale timezone.
|
||||
config.geo_proxy_signature = Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
|
||||
.as_ref(),
|
||||
None,
|
||||
config.geoip.as_ref(),
|
||||
));
|
||||
// instead of showing a stale timezone. Only stamped when geolocation
|
||||
// actually succeeded: on failure the fingerprint carries the HOST
|
||||
// timezone/locale, and a stamped signature would match at launch and
|
||||
// suppress the refresh that repairs it — latching the leak permanently.
|
||||
config.geo_proxy_signature = if geolocation_applied {
|
||||
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
|
||||
.as_ref(),
|
||||
None,
|
||||
config.geoip.as_ref(),
|
||||
))
|
||||
} else {
|
||||
if !matches!(config.geoip.as_ref(), Some(serde_json::Value::Bool(false))) {
|
||||
log::warn!(
|
||||
"Geolocation could not be applied for Wayfern profile {name}; leaving geo signature unset so the next launch refreshes location through the profile's proxy"
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// Clear the proxy from config after fingerprint generation
|
||||
config.proxy = None;
|
||||
@@ -273,6 +300,7 @@ impl ProfileManager {
|
||||
created_by_email: None,
|
||||
dns_blocklist,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -381,6 +409,14 @@ impl ProfileManager {
|
||||
profile_id: &str,
|
||||
new_name: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if new_name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if new name already exists (case insensitive)
|
||||
let existing_profiles = self.list_profiles()?;
|
||||
if existing_profiles
|
||||
@@ -712,6 +748,44 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_clear_on_close(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
clear_on_close: bool,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Ephemeral profiles are already wiped on close; password-protected ones
|
||||
// re-encrypt and never persist plaintext — the flag is meaningless there.
|
||||
if clear_on_close && (profile.ephemeral || profile.password_protected) {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "CLEAR_ON_CLOSE_UNAVAILABLE" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
profile.clear_on_close = clear_on_close;
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_window_color(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
@@ -976,6 +1050,7 @@ impl ProfileManager {
|
||||
created_by_email: None,
|
||||
dns_blocklist: source.dns_blocklist,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -1267,7 +1342,7 @@ impl ProfileManager {
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
// Match the Chromium --user-data-dir flag or an exact profile path argument
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
});
|
||||
@@ -1305,7 +1380,7 @@ impl ProfileManager {
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
// Match the Chromium --user-data-dir flag or an exact profile path argument
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
});
|
||||
@@ -1631,7 +1706,7 @@ pub async fn create_browser_profile_with_group(
|
||||
launch_hook,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to create profile"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1703,6 +1778,17 @@ pub fn update_profile_window_color(
|
||||
.map_err(|e| format!("Failed to update profile window color: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_clear_on_close(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
clear_on_close: bool,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
ProfileManager::instance()
|
||||
.update_profile_clear_on_close(&app_handle, &profile_id, clear_on_close)
|
||||
.map_err(crate::profile_importer::error_to_code_string)
|
||||
}
|
||||
|
||||
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
|
||||
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
|
||||
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
|
||||
@@ -1781,7 +1867,7 @@ pub fn rename_profile(
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.rename_profile(&app_handle, &profile_id, &new_name)
|
||||
.map_err(|e| format!("Failed to rename profile: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to rename profile"))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod clear_on_close;
|
||||
pub mod encryption;
|
||||
pub mod manager;
|
||||
pub mod password;
|
||||
|
||||
@@ -72,6 +72,10 @@ pub struct BrowserProfile {
|
||||
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
|
||||
#[serde(default)]
|
||||
pub password_protected: bool,
|
||||
/// Wipe browsing data (except extensions and bookmarks) when the browser
|
||||
/// exits. Ignored for ephemeral and password-protected profiles.
|
||||
#[serde(default)]
|
||||
pub clear_on_close: bool,
|
||||
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
|
||||
/// profiles that pre-date this field — those are treated as ancient by
|
||||
/// any staleness check.
|
||||
|
||||
+848
-122
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,25 @@ pub fn now_secs() -> u64 {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Keys in `active_proxies` at or above this value are in-flight launch
|
||||
/// placeholders, not real browser PIDs. Real OS PIDs never reach this range
|
||||
/// (Linux pid_max caps at 2^22, macOS at ~100k, Windows PIDs are small DWORD
|
||||
/// multiples of 4), so a placeholder can never shadow a live process ID.
|
||||
pub const LAUNCH_PLACEHOLDER_PID_MIN: u32 = u32::MAX - 0x00FF_FFFF;
|
||||
|
||||
static NEXT_LAUNCH_PLACEHOLDER_PID: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(u32::MAX);
|
||||
|
||||
/// Allocate a unique `active_proxies` key for a launch in flight, so concurrent
|
||||
/// launches can never overwrite each other's placeholder entry.
|
||||
pub fn next_launch_placeholder_pid() -> u32 {
|
||||
NEXT_LAUNCH_PLACEHOLDER_PID.fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn is_launch_placeholder_pid(pid: u32) -> bool {
|
||||
pid >= LAUNCH_PLACEHOLDER_PID_MIN
|
||||
}
|
||||
|
||||
impl StoredProxy {
|
||||
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
@@ -406,6 +425,10 @@ impl ProxyManager {
|
||||
name: String,
|
||||
proxy_settings: ProxySettings,
|
||||
) -> Result<StoredProxy, String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -795,6 +818,10 @@ impl ProxyManager {
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
) -> Result<StoredProxy, String> {
|
||||
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
|
||||
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
|
||||
}
|
||||
|
||||
// First, check for conflicts without holding a mutable reference
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -1032,8 +1059,10 @@ impl ProxyManager {
|
||||
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
|
||||
}
|
||||
|
||||
// Build proxy URL string from ProxySettings
|
||||
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
// Build proxy URL string from ProxySettings. Credentials are percent-encoded:
|
||||
// a password containing `/`, `#`, `?` or `@` otherwise breaks the URL
|
||||
// authority and silently retargets the request at the wrong host.
|
||||
pub fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
let mut url = format!("{}://", proxy_settings.proxy_type);
|
||||
|
||||
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
|
||||
@@ -1073,9 +1102,17 @@ impl ProxyManager {
|
||||
.map_err(|e| e.to_string());
|
||||
|
||||
let ip_result = match proxy_start_result {
|
||||
Ok(proxy_config) => {
|
||||
Ok(mut proxy_config) => {
|
||||
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
|
||||
let config_id = proxy_config.id.clone();
|
||||
// Tie the check worker's lifetime to this GUI process: the worker's
|
||||
// PID watchdog self-exits when browser_pid dies, so if the app is
|
||||
// killed mid-check the worker follows instead of idling until the
|
||||
// next app launch.
|
||||
proxy_config.browser_pid = Some(std::process::id());
|
||||
if !crate::proxy_storage::update_proxy_config(&proxy_config) {
|
||||
log::warn!("Failed to tag check worker {config_id} with app PID for self-expiry");
|
||||
}
|
||||
// Wrap in a timeout so the check worker doesn't stay alive indefinitely
|
||||
// if the upstream is slow or unreachable.
|
||||
let result = tokio::time::timeout(
|
||||
@@ -1298,10 +1335,9 @@ impl ProxyManager {
|
||||
("socks5", rest) // Default socks to socks5
|
||||
} else if let Some(rest) = line.strip_prefix("ss://") {
|
||||
("ss", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("shadowsocks://") {
|
||||
("ss", rest)
|
||||
} else {
|
||||
return None;
|
||||
let rest = line.strip_prefix("shadowsocks://")?;
|
||||
("ss", rest)
|
||||
};
|
||||
|
||||
// Check if there's auth (contains @)
|
||||
@@ -1365,13 +1401,12 @@ impl ProxyManager {
|
||||
let host_port = &line[at_pos + 1..];
|
||||
|
||||
// Parse auth
|
||||
let (username, password) = if let Some(colon_pos) = auth.find(':') {
|
||||
let (username, password) = {
|
||||
let colon_pos = auth.find(':')?;
|
||||
(
|
||||
Some(auth[..colon_pos].to_string()),
|
||||
Some(auth[colon_pos + 1..].to_string()),
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Parse host:port
|
||||
@@ -1487,6 +1522,7 @@ impl ProxyManager {
|
||||
profile_id: Option<&str>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
dns_allowlist_mode: bool,
|
||||
// Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in
|
||||
// the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme.
|
||||
local_protocol: &str,
|
||||
@@ -1620,6 +1656,9 @@ impl ProxyManager {
|
||||
// Add blocklist file path if provided
|
||||
if let Some(ref path) = blocklist_file {
|
||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||
if dns_allowlist_mode {
|
||||
proxy_cmd = proxy_cmd.arg("--dns-allowlist-mode");
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the worker which protocol to serve the browser (http or socks5)
|
||||
@@ -1690,6 +1729,10 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
// The detached worker is already running with its config on disk, but
|
||||
// it was never registered in active_proxies — no cleanup pass could
|
||||
// ever find it, so it must be killed before this error propagates.
|
||||
let _ = crate::proxy_runner::stop_proxy_process(&proxy_info.id).await;
|
||||
return Err(format!(
|
||||
"Local proxy on 127.0.0.1:{} did not become ready in time",
|
||||
proxy_info.local_port
|
||||
@@ -1862,9 +1905,9 @@ impl ProxyManager {
|
||||
/// Persist the real browser PID onto the worker's on-disk config so the
|
||||
/// detached worker can self-terminate when that browser dies, independent of
|
||||
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the
|
||||
/// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during
|
||||
/// launch that collides across concurrent launches, which could tag a live
|
||||
/// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse
|
||||
/// PID-keyed `active_proxies` map: the latter is keyed by a per-launch
|
||||
/// placeholder until `update_proxy_pid` runs, so it is not a reliable way to
|
||||
/// find the worker for a profile mid-launch. Safe on the reuse
|
||||
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
|
||||
/// of 0 (launch failed to report a PID) is ignored so the worker never
|
||||
/// self-exits against a bogus PID.
|
||||
@@ -2084,9 +2127,9 @@ impl ProxyManager {
|
||||
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for (browser_pid, proxy_id, profile_id) in snapshot {
|
||||
snapshot_pids.insert(browser_pid);
|
||||
// The sentinel PID=0 is used as a placeholder during launch,
|
||||
// before update_proxy_pid has recorded the real browser PID.
|
||||
if browser_pid == 0 {
|
||||
// Launch placeholders (and the legacy 0 sentinel) are not real
|
||||
// browser PIDs — update_proxy_pid hasn't recorded the real one yet.
|
||||
if browser_pid == 0 || is_launch_placeholder_pid(browser_pid) {
|
||||
continue;
|
||||
}
|
||||
if system
|
||||
@@ -2752,6 +2795,35 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("No proxy found for PID 777"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_placeholder_pids_unique_and_reconcile_independently() {
|
||||
let a = next_launch_placeholder_pid();
|
||||
let b = next_launch_placeholder_pid();
|
||||
assert_ne!(a, b);
|
||||
assert!(is_launch_placeholder_pid(a));
|
||||
assert!(is_launch_placeholder_pid(b));
|
||||
// Real PIDs (and the legacy 0 sentinel) are never in the placeholder range.
|
||||
assert!(!is_launch_placeholder_pid(0));
|
||||
assert!(!is_launch_placeholder_pid(1));
|
||||
assert!(!is_launch_placeholder_pid(4_194_304)); // Linux pid_max
|
||||
assert!(!is_launch_placeholder_pid(std::process::id()));
|
||||
|
||||
// Two concurrent launches with distinct placeholder keys: finishing
|
||||
// launch A must not remap or evict launch B's in-flight entry.
|
||||
let pm = ProxyManager::new();
|
||||
pm.insert_active_proxy(a, make_proxy_info("px_launch_a", 9201, Some("prof_a")));
|
||||
pm.insert_active_proxy(b, make_proxy_info("px_launch_b", 9202, Some("prof_b")));
|
||||
|
||||
pm.update_proxy_pid(a, 3001).unwrap();
|
||||
assert_eq!(pm.get_active_proxy(3001).unwrap().id, "px_launch_a");
|
||||
assert_eq!(pm.get_active_proxy(b).unwrap().id, "px_launch_b");
|
||||
|
||||
pm.update_proxy_pid(b, 3002).unwrap();
|
||||
assert_eq!(pm.get_active_proxy(3002).unwrap().id, "px_launch_b");
|
||||
assert!(pm.get_active_proxy(a).is_none());
|
||||
assert!(pm.get_active_proxy(b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_proxy_id_mapping_tracks_active_proxy() {
|
||||
let pm = ProxyManager::new();
|
||||
@@ -2924,6 +2996,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
dns_allowlist_mode: false,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
@@ -2937,6 +3010,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
dns_allowlist_mode: false,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
@@ -2978,6 +3052,7 @@ mod tests {
|
||||
profile_id: Some("prof_abc".to_string()),
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
dns_allowlist_mode: false,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
@@ -3298,6 +3373,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
dns_allowlist_mode: false,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
@@ -160,15 +160,17 @@ pub async fn start_proxy_process(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, false, None).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn start_proxy_process_with_profile(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
profile_id: Option<String>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
dns_allowlist_mode: bool,
|
||||
local_protocol: Option<String>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
let id = generate_proxy_id();
|
||||
@@ -185,6 +187,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
.with_profile_id(profile_id.clone())
|
||||
.with_bypass_rules(bypass_rules)
|
||||
.with_blocklist_file(blocklist_file)
|
||||
.with_dns_allowlist_mode(dns_allowlist_mode)
|
||||
.with_local_protocol(local_protocol);
|
||||
save_proxy_config(&config)?;
|
||||
|
||||
@@ -370,24 +373,21 @@ pub async fn start_proxy_process_with_profile(
|
||||
attempts += 1;
|
||||
if attempts >= max_attempts {
|
||||
// Try to get the config one more time for better error message
|
||||
if let Some(config) = get_proxy_config(&id) {
|
||||
let detail = if let Some(config) = get_proxy_config(&id) {
|
||||
// Check if process is still running
|
||||
let process_running = config.pid.map(is_process_running).unwrap_or(false);
|
||||
return Err(
|
||||
format!(
|
||||
"Proxy worker failed to start in time. Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
|
||||
config.id, config.local_url, config.local_port, config.pid, process_running
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
return Err(
|
||||
format!(
|
||||
"Proxy worker failed to start in time. Config not found for id: {}",
|
||||
id
|
||||
"Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
|
||||
config.id, config.local_url, config.local_port, config.pid, process_running
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
format!("Config not found for id: {}", id)
|
||||
};
|
||||
// The detached worker (if it did spawn) would otherwise outlive this
|
||||
// failed start with nothing tracking it — callers only get an error
|
||||
// string, so this is the last place that can still kill it.
|
||||
let _ = stop_proxy_process(&id).await;
|
||||
return Err(format!("Proxy worker failed to start in time. {detail}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+633
-373
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@ pub struct ProxyConfig {
|
||||
pub bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocklist_file: Option<String>,
|
||||
/// When true, `blocklist_file` is treated as an ALLOW list: the browser may
|
||||
/// only reach domains in the file; everything else is blocked.
|
||||
#[serde(default)]
|
||||
pub dns_allowlist_mode: bool,
|
||||
/// Protocol the local worker serves to the browser: "socks5" (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.
|
||||
@@ -42,6 +46,7 @@ impl ProxyConfig {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
dns_allowlist_mode: false,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
}
|
||||
@@ -62,6 +67,11 @@ impl ProxyConfig {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_dns_allowlist_mode(mut self, allowlist_mode: bool) -> Self {
|
||||
self.dns_allowlist_mode = allowlist_mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
|
||||
self.local_protocol = local_protocol;
|
||||
self
|
||||
@@ -167,11 +177,18 @@ pub fn generate_proxy_id() -> String {
|
||||
}
|
||||
|
||||
pub fn is_process_running(pid: u32) -> bool {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
||||
let pid = sysinfo::Pid::from_u32(pid);
|
||||
// Refresh only the queried PID with the minimal refresh kind: this is a
|
||||
// pure existence check, and callers (worker supervisors every 15s, GUI
|
||||
// cleanup loops) must not pay for a full system process-table scan.
|
||||
let mut system = System::new();
|
||||
system.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[pid]),
|
||||
true,
|
||||
ProcessRefreshKind::nothing(),
|
||||
);
|
||||
system.process(sysinfo::Pid::from_u32(pid)).is_some()
|
||||
system.process(pid).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+282
-13
@@ -13,13 +13,21 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Both commands enforce the same DNS blocklist and feed the same traffic
|
||||
//! tracker as the HTTP front-end: CONNECT via `handle_connect`, and every UDP
|
||||
//! datagram via [`UdpRelayContext`]. Without that, QUIC and WebRTC would be an
|
||||
//! unfiltered, unmetered side channel around the TCP filter.
|
||||
|
||||
use crate::proxy_server::{
|
||||
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
|
||||
};
|
||||
use crate::traffic_stats::get_traffic_tracker;
|
||||
use crate::traffic_stats::{get_traffic_tracker, LiveTrafficTracker};
|
||||
use async_socks5::{AddrKind, Auth, SocksDatagram};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use url::Url;
|
||||
@@ -37,6 +45,127 @@ 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 often per-domain UDP byte totals are pushed into the traffic tracker.
|
||||
// Datagram relay is a per-packet path, so totals accumulate locally and flush
|
||||
// on this interval rather than taking the tracker's domain write lock per
|
||||
// packet. Global byte counters are plain atomics and update inline, so live
|
||||
// bandwidth stays real-time exactly as it does for TCP tunnels.
|
||||
const UDP_STATS_FLUSH_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
// Cap on remembered destination->host flows per association. A long-lived
|
||||
// association fanning out to many peers must not grow this map without bound.
|
||||
const UDP_FLOW_MAP_MAX: usize = 1024;
|
||||
|
||||
/// Per-association filtering and traffic accounting for the UDP relay loops.
|
||||
///
|
||||
/// UDP has no per-datagram error channel (RFC 1928 §7 has no reply code), so a
|
||||
/// blocked destination is dropped silently — which is what the browser sees for
|
||||
/// any unreachable UDP peer, and makes it fall back to proxied TCP.
|
||||
struct UdpRelayContext {
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
tracker: Option<Arc<LiveTrafficTracker>>,
|
||||
/// Pending per-domain (sent, received) deltas awaiting flush.
|
||||
pending: HashMap<String, (u64, u64)>,
|
||||
/// Destinations already counted as a request, so each is recorded once.
|
||||
seen: std::collections::HashSet<String>,
|
||||
/// Maps a peer key back to the destination host we sent to, so reply bytes
|
||||
/// are attributed to the domain rather than to a bare IP.
|
||||
flow: HashMap<String, String>,
|
||||
last_flush: Instant,
|
||||
}
|
||||
|
||||
impl UdpRelayContext {
|
||||
fn new(blocklist_matcher: BlocklistMatcher) -> Self {
|
||||
Self {
|
||||
blocklist_matcher,
|
||||
tracker: get_traffic_tracker(),
|
||||
pending: HashMap::new(),
|
||||
seen: std::collections::HashSet::new(),
|
||||
flow: HashMap::new(),
|
||||
last_flush: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the DNS blocklist to a datagram destination. Mirrors the TCP path
|
||||
/// exactly: the host string is matched whether it is a domain or an IP
|
||||
/// literal, so allowlist mode rejects unlisted IP destinations here too.
|
||||
fn is_blocked(&self, host: &str) -> bool {
|
||||
self.blocklist_matcher.is_blocked(host)
|
||||
}
|
||||
|
||||
/// Account a datagram relayed browser -> destination.
|
||||
fn record_sent(&mut self, host: &str, peer_key: String, bytes: u64) {
|
||||
let Some(tracker) = &self.tracker else {
|
||||
return;
|
||||
};
|
||||
tracker.add_bytes_sent(bytes);
|
||||
if self.seen.insert(host.to_string()) {
|
||||
// First datagram to this destination: count it once so UDP peers show up
|
||||
// in the domain list the way CONNECT targets do.
|
||||
tracker.record_request(host, 0, 0);
|
||||
}
|
||||
if self.flow.len() < UDP_FLOW_MAP_MAX {
|
||||
self.flow.insert(peer_key, host.to_string());
|
||||
}
|
||||
self.pending.entry(host.to_string()).or_insert((0, 0)).0 += bytes;
|
||||
self.maybe_flush();
|
||||
}
|
||||
|
||||
/// Account a datagram relayed destination -> browser. `peer_key` is looked up
|
||||
/// against the flows recorded by `record_sent`; an unmatched reply (an
|
||||
/// upstream that reports a different address than we addressed) is attributed
|
||||
/// to `fallback_host`.
|
||||
fn record_received(&mut self, peer_key: &str, fallback_host: &str, bytes: u64) {
|
||||
let Some(tracker) = &self.tracker else {
|
||||
return;
|
||||
};
|
||||
tracker.add_bytes_received(bytes);
|
||||
let host = self
|
||||
.flow
|
||||
.get(peer_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_host.to_string());
|
||||
self.pending.entry(host).or_insert((0, 0)).1 += bytes;
|
||||
self.maybe_flush();
|
||||
}
|
||||
|
||||
fn maybe_flush(&mut self) {
|
||||
if self.last_flush.elapsed() >= UDP_STATS_FLUSH_INTERVAL {
|
||||
self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Push accumulated per-domain totals into the tracker.
|
||||
fn flush(&mut self) {
|
||||
let Some(tracker) = &self.tracker else {
|
||||
self.pending.clear();
|
||||
return;
|
||||
};
|
||||
for (domain, (sent, recv)) in self.pending.drain() {
|
||||
tracker.update_domain_bytes(&domain, sent, recv);
|
||||
}
|
||||
self.last_flush = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Host portion of a UDP destination, for blocklist matching. An IP literal is
|
||||
/// rendered without its port so it matches the same way a CONNECT host does.
|
||||
fn addrkind_host(addr: &AddrKind) -> String {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => s.ip().to_string(),
|
||||
AddrKind::Domain(domain, _) => domain.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable key identifying a UDP peer, used to tie replies back to the
|
||||
/// destination host they belong to.
|
||||
fn addrkind_key(addr: &AddrKind) -> String {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => s.to_string(),
|
||||
AddrKind::Domain(domain, port) => format!("{domain}:{port}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
|
||||
/// IP never leaks.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -108,7 +237,7 @@ pub async fn handle_socks5_connection(
|
||||
.await;
|
||||
}
|
||||
CMD_UDP_ASSOCIATE => {
|
||||
handle_udp_associate(stream, upstream_url).await;
|
||||
handle_udp_associate(stream, upstream_url, blocklist_matcher).await;
|
||||
}
|
||||
other => {
|
||||
log::debug!("SOCKS5 unsupported command {other:#04x}");
|
||||
@@ -294,8 +423,13 @@ async fn handle_connect(
|
||||
///
|
||||
/// `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>) {
|
||||
/// the browser closes it. Every relayed datagram is filtered and metered via
|
||||
/// [`UdpRelayContext`], matching the TCP CONNECT path.
|
||||
async fn handle_udp_associate(
|
||||
mut control: TcpStream,
|
||||
upstream_url: Option<String>,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
let mode = udp_mode(upstream_url.as_deref());
|
||||
|
||||
if mode == UdpMode::Refuse {
|
||||
@@ -344,7 +478,7 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
|
||||
run_udp_relay_direct(control, relay, out).await;
|
||||
run_udp_relay_direct(control, relay, out, UdpRelayContext::new(blocklist_matcher)).await;
|
||||
}
|
||||
UdpMode::Socks5Upstream => {
|
||||
// Establish the upstream association FIRST; if the upstream refuses UDP,
|
||||
@@ -367,7 +501,13 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
|
||||
run_udp_relay_socks5(control, relay, datagram).await;
|
||||
run_udp_relay_socks5(
|
||||
control,
|
||||
relay,
|
||||
datagram,
|
||||
UdpRelayContext::new(blocklist_matcher),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
UdpMode::Refuse => unreachable!("handled above"),
|
||||
}
|
||||
@@ -389,10 +529,20 @@ async fn associate_upstream(
|
||||
None
|
||||
};
|
||||
|
||||
let proxy_stream = TcpStream::connect((host, port)).await?;
|
||||
let proxy_stream = tokio::time::timeout(
|
||||
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
|
||||
TcpStream::connect((host, port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("upstream SOCKS5 connect to {host}:{port} timed out"))??;
|
||||
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?;
|
||||
let datagram = tokio::time::timeout(
|
||||
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
|
||||
SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "upstream SOCKS5 UDP ASSOCIATE handshake timed out")??;
|
||||
Ok(datagram)
|
||||
}
|
||||
|
||||
@@ -467,7 +617,12 @@ fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
|
||||
|
||||
/// 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) {
|
||||
async fn run_udp_relay_direct(
|
||||
mut control: TcpStream,
|
||||
relay: UdpSocket,
|
||||
out: UdpSocket,
|
||||
mut ctx: UdpRelayContext,
|
||||
) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
let mut from_target = vec![0u8; UDP_BUF];
|
||||
@@ -490,23 +645,37 @@ async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: Udp
|
||||
if header.frag != 0 {
|
||||
continue; // fragmentation unsupported
|
||||
}
|
||||
let host = addrkind_host(&header.dst);
|
||||
if ctx.is_blocked(&host) {
|
||||
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
|
||||
continue;
|
||||
}
|
||||
let payload = &from_client[header.data_offset..n];
|
||||
// Resolve after the blocklist check so a blocked host is never even
|
||||
// looked up, and count bytes against the resolved peer so replies from
|
||||
// it are attributed back to this host.
|
||||
let dst = match resolve_addr(&header.dst).await {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
let _ = out.send_to(payload, dst).await;
|
||||
if out.send_to(payload, dst).await.is_ok() {
|
||||
ctx.record_sent(&host, dst.to_string(), payload.len() as u64);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
if relay.send_to(&resp, client).await.is_ok() {
|
||||
ctx.record_received(&peer.to_string(), &peer.ip().to_string(), n as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.flush();
|
||||
}
|
||||
|
||||
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
|
||||
@@ -514,6 +683,7 @@ async fn run_udp_relay_socks5(
|
||||
mut control: TcpStream,
|
||||
relay: UdpSocket,
|
||||
datagram: SocksDatagram<TcpStream>,
|
||||
mut ctx: UdpRelayContext,
|
||||
) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
@@ -536,19 +706,33 @@ async fn run_udp_relay_socks5(
|
||||
if header.frag != 0 {
|
||||
continue;
|
||||
}
|
||||
let host = addrkind_host(&header.dst);
|
||||
if ctx.is_blocked(&host) {
|
||||
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
|
||||
continue;
|
||||
}
|
||||
let peer_key = addrkind_key(&header.dst);
|
||||
let payload = from_client[header.data_offset..n].to_vec();
|
||||
let _ = datagram.send_to(&payload, header.dst).await;
|
||||
if datagram.send_to(&payload, header.dst).await.is_ok() {
|
||||
ctx.record_sent(&host, peer_key, payload.len() as u64);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
if relay.send_to(&resp, client).await.is_ok() {
|
||||
// The upstream usually reports the peer by IP even when we
|
||||
// addressed a domain, so an unmatched flow falls back to that IP.
|
||||
ctx.record_received(&addrkind_key(&peer), &addrkind_host(&peer), n as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.flush();
|
||||
}
|
||||
|
||||
/// Resolve a UDP destination to a concrete socket address for direct relay.
|
||||
@@ -637,6 +821,91 @@ mod tests {
|
||||
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addrkind_host_strips_port_for_blocklist_matching() {
|
||||
// The blocklist matches CONNECT hosts without a port, so UDP destinations
|
||||
// must be rendered the same way or IP rules would never match.
|
||||
assert_eq!(
|
||||
addrkind_host(&AddrKind::Ip(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
|
||||
443
|
||||
))),
|
||||
"1.2.3.4"
|
||||
);
|
||||
assert_eq!(
|
||||
addrkind_host(&AddrKind::Domain("ads.example.com".into(), 443)),
|
||||
"ads.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addrkind_key_distinguishes_ports() {
|
||||
assert_eq!(
|
||||
addrkind_key(&AddrKind::Domain("example.com".into(), 443)),
|
||||
"example.com:443"
|
||||
);
|
||||
assert_eq!(
|
||||
addrkind_key(&AddrKind::Ip(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
|
||||
53
|
||||
))),
|
||||
"1.2.3.4:53"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_relay_context_blocks_like_the_tcp_path() {
|
||||
let dir = std::env::temp_dir().join(format!("donut-udp-blocklist-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("block.txt");
|
||||
std::fs::write(&path, "ads.example.com\n").unwrap();
|
||||
|
||||
let matcher = BlocklistMatcher::from_file(path.to_str().unwrap()).unwrap();
|
||||
let ctx = UdpRelayContext::new(matcher);
|
||||
assert!(ctx.is_blocked("ads.example.com"));
|
||||
// Subdomains are blocked by the parent rule, as on the CONNECT path.
|
||||
assert!(ctx.is_blocked("cdn.ads.example.com"));
|
||||
assert!(!ctx.is_blocked("example.com"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_relay_context_allowlist_mode_blocks_unlisted_ip_destinations() {
|
||||
// Parity check: allowlist mode blocks a bare IP destination over UDP the
|
||||
// same way is_blocked does for a CONNECT to an IP literal — otherwise QUIC
|
||||
// to a hardcoded IP would walk straight through a strict allowlist.
|
||||
let dir = std::env::temp_dir().join(format!("donut-udp-allowlist-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("allow.txt");
|
||||
std::fs::write(&path, "trusted.example.com\n").unwrap();
|
||||
|
||||
let matcher = BlocklistMatcher::from_file_with_mode(path.to_str().unwrap(), true).unwrap();
|
||||
let ctx = UdpRelayContext::new(matcher);
|
||||
assert!(!ctx.is_blocked("trusted.example.com"));
|
||||
assert!(ctx.is_blocked("evil.example.com"));
|
||||
assert!(ctx.is_blocked(&addrkind_host(&AddrKind::Ip(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
|
||||
443
|
||||
)))));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_relay_context_attributes_replies_to_the_destination_host() {
|
||||
// No tracker is initialised in tests, so this exercises the flow map that
|
||||
// decides which domain reply bytes belong to.
|
||||
let mut ctx = UdpRelayContext::new(BlocklistMatcher::new());
|
||||
ctx.flow.insert("1.2.3.4:443".into(), "example.com".into());
|
||||
assert_eq!(
|
||||
ctx.flow.get("1.2.3.4:443").map(String::as_str),
|
||||
Some("example.com")
|
||||
);
|
||||
// An unknown peer has no flow and falls back to the peer's own host.
|
||||
assert!(!ctx.flow.contains_key("9.9.9.9:53"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_udp_response_prefixes_header() {
|
||||
let resp = build_udp_response(
|
||||
|
||||
+103
-33
@@ -347,7 +347,12 @@ pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box<dyn std::error
|
||||
let key = get_stats_storage_key(stats);
|
||||
let file_path = storage_dir.join(format!("{key}.json"));
|
||||
let content = serde_json::to_string(stats)?;
|
||||
fs::write(&file_path, content)?;
|
||||
|
||||
// Write atomically via temp file + rename so readers never observe a
|
||||
// partially-written file (same pattern as write_session_snapshot)
|
||||
let temp_path = storage_dir.join(format!("{key}.json.tmp"));
|
||||
fs::write(&temp_path, content)?;
|
||||
fs::rename(&temp_path, &file_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -370,15 +375,18 @@ pub fn load_traffic_stats_by_profile(profile_id: &str) -> Option<TrafficStats> {
|
||||
load_traffic_stats(profile_id)
|
||||
}
|
||||
|
||||
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
|
||||
pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
||||
/// Load all traffic stats files keyed by storage key, migrating old
|
||||
/// proxy-id based files to profile-id based ones. Only writes to disk when a
|
||||
/// migration or merge actually changed data; the common case is read-only.
|
||||
fn collect_traffic_stats() -> HashMap<String, TrafficStats> {
|
||||
let storage_dir = get_traffic_stats_dir();
|
||||
|
||||
if !storage_dir.exists() {
|
||||
return Vec::new();
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let mut stats_map: HashMap<String, TrafficStats> = HashMap::new();
|
||||
let mut dirty_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&storage_dir) {
|
||||
@@ -399,12 +407,14 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
||||
if let Some(existing) = stats_map.get_mut(&key) {
|
||||
// Merge stats from this file into existing
|
||||
merge_traffic_stats(existing, &s);
|
||||
dirty_keys.insert(key);
|
||||
if is_old_proxy_file {
|
||||
files_to_delete.push(path.clone());
|
||||
}
|
||||
} else {
|
||||
stats_map.insert(key.clone(), s);
|
||||
if is_old_proxy_file {
|
||||
dirty_keys.insert(key);
|
||||
files_to_delete.push(path.clone());
|
||||
}
|
||||
}
|
||||
@@ -414,10 +424,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
||||
}
|
||||
}
|
||||
|
||||
// Save merged stats and delete old files
|
||||
for stats in stats_map.values() {
|
||||
if let Err(e) = save_traffic_stats(stats) {
|
||||
log::warn!("Failed to save merged traffic stats: {}", e);
|
||||
// Persist only entries actually changed by a merge or migration
|
||||
for key in &dirty_keys {
|
||||
if let Some(stats) = stats_map.get(key) {
|
||||
if let Err(e) = save_traffic_stats(stats) {
|
||||
log::warn!("Failed to save merged traffic stats: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +439,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
||||
}
|
||||
}
|
||||
|
||||
stats_map.into_values().collect()
|
||||
stats_map
|
||||
}
|
||||
|
||||
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
|
||||
pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
||||
collect_traffic_stats().into_values().collect()
|
||||
}
|
||||
|
||||
/// Merge traffic stats from source into destination
|
||||
@@ -487,27 +504,82 @@ fn merge_traffic_stats(dest: &mut TrafficStats, src: &TrafficStats) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete traffic stats by id (profile_id or proxy_id)
|
||||
/// Delete traffic stats by id (profile_id or proxy_id).
|
||||
///
|
||||
/// Removes the session snapshot and any temp orphans alongside the main file.
|
||||
/// The snapshot is what a running worker writes every second, and
|
||||
/// `get_all_traffic_snapshots_realtime` rebuilds an entry straight from it when
|
||||
/// no main file exists — so clearing only `{id}.json` resurrects the very
|
||||
/// totals the user asked to erase, and leaves the snapshot's copy on disk.
|
||||
pub fn delete_traffic_stats(id: &str) -> bool {
|
||||
let storage_dir = get_traffic_stats_dir();
|
||||
let file_path = storage_dir.join(format!("{id}.json"));
|
||||
let mut removed = false;
|
||||
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path).is_ok()
|
||||
} else {
|
||||
false
|
||||
for name in [
|
||||
format!("{id}.json"),
|
||||
format!("{id}.json.tmp"),
|
||||
format!("{id}.session.json"),
|
||||
format!("{id}.session.json.tmp"),
|
||||
] {
|
||||
let path = storage_dir.join(name);
|
||||
if path.exists() && secure_remove_file(&path).is_ok() {
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Clear all traffic stats (used when clearing cache)
|
||||
/// Best-effort secure erase: overwrite the file's bytes with zeros and flush
|
||||
/// before unlinking, so the traffic history isn't trivially recoverable from
|
||||
/// the freed blocks. On copy-on-write / SSD storage the OS may still retain
|
||||
/// old blocks — this is a best-effort mitigation, not a guarantee.
|
||||
fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let len = meta.len();
|
||||
if len > 0 {
|
||||
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
|
||||
let zeros = vec![0u8; 8192];
|
||||
let mut remaining = len;
|
||||
// The overwrite is best-effort and must never gate the unlink: a write
|
||||
// failure part-way (ENOSPC on a copy-on-write volume, EIO) would
|
||||
// otherwise leave the file both un-wiped and un-deleted, which is
|
||||
// strictly worse than the plain remove this replaced — and the caller
|
||||
// reports success either way, so the history would silently survive a
|
||||
// clear.
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(zeros.len() as u64) as usize;
|
||||
if f.write_all(&zeros[..chunk]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= chunk as u64;
|
||||
}
|
||||
let _ = f.flush();
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
|
||||
/// Clear all traffic stats (used when clearing cache), securely erasing each
|
||||
/// file first.
|
||||
pub fn clear_all_traffic_stats() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let storage_dir = get_traffic_stats_dir();
|
||||
|
||||
if storage_dir.exists() {
|
||||
for entry in fs::read_dir(&storage_dir)?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "json") {
|
||||
let _ = fs::remove_file(&path);
|
||||
// Wipe the live stats files and any temp orphan left by an interrupted
|
||||
// atomic save, which still holds a copy of the history. This directory
|
||||
// holds nothing but traffic stats, so matching every `.json`/`.tmp` also
|
||||
// catches orphans from older temp-naming schemes.
|
||||
let is_stats = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext == "json" || ext == "tmp");
|
||||
if is_stats {
|
||||
let _ = secure_remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,8 +656,10 @@ impl LiveTrafficTracker {
|
||||
fs::create_dir_all(&storage_dir)?;
|
||||
let session_file = storage_dir.join(format!("{}.session.json", storage_key));
|
||||
|
||||
// Write atomically using a temp file
|
||||
let temp_file = session_file.with_extension("tmp");
|
||||
// Write atomically using a temp file. Named by suffix rather than
|
||||
// `with_extension`, which would replace `.json` and yield
|
||||
// `{key}.session.tmp` — a name the cleanup sweeps did not recognise.
|
||||
let temp_file = storage_dir.join(format!("{}.session.json.tmp", storage_key));
|
||||
let content = serde_json::to_string(&snapshot)?;
|
||||
fs::write(&temp_file, content)?;
|
||||
fs::rename(&temp_file, &session_file)?;
|
||||
@@ -1035,14 +1109,14 @@ fn load_session_snapshot(profile_id: &str) -> Option<SessionSnapshot> {
|
||||
pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Start with disk-stored stats
|
||||
let mut snapshots: HashMap<String, TrafficSnapshot> = list_traffic_stats()
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let key = s.profile_id.clone().unwrap_or_else(|| s.proxy_id.clone());
|
||||
(key, s.to_snapshot())
|
||||
})
|
||||
.collect();
|
||||
// Start with disk-stored stats, keeping last_flush_timestamp per key so the
|
||||
// session-snapshot merge below doesn't need to re-read the files
|
||||
let mut snapshots: HashMap<String, TrafficSnapshot> = HashMap::new();
|
||||
let mut last_flush_by_key: HashMap<String, u64> = HashMap::new();
|
||||
for (key, s) in collect_traffic_stats() {
|
||||
last_flush_by_key.insert(key.clone(), s.last_flush_timestamp);
|
||||
snapshots.insert(key, s.to_snapshot());
|
||||
}
|
||||
|
||||
// Try to merge in real-time data from active tracker (if in same process)
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
@@ -1068,11 +1142,7 @@ pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
|
||||
// Only merge session data if it's newer than the last flush
|
||||
// Session snapshots written before the last flush contain bytes already
|
||||
// included in disk totals, so merging them would cause double-counting
|
||||
let disk_stats = load_traffic_stats(profile_id);
|
||||
let last_flush = disk_stats
|
||||
.as_ref()
|
||||
.map(|s| s.last_flush_timestamp)
|
||||
.unwrap_or(0);
|
||||
let last_flush = last_flush_by_key.get(profile_id).copied().unwrap_or(0);
|
||||
|
||||
if session.timestamp > last_flush {
|
||||
// Session data contains in-memory counters not yet flushed to disk
|
||||
|
||||
@@ -284,7 +284,11 @@ impl WayfernManager {
|
||||
vpn_id: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
match geoip {
|
||||
// The "v2:" prefix invalidates every signature stamped before geolocation
|
||||
// failures stopped being stamped: those may describe fingerprints that
|
||||
// silently carry the host's location, so each pre-v2 profile gets one
|
||||
// launch-time refresh and is re-stamped in the current format.
|
||||
let base = match geoip {
|
||||
Some(serde_json::Value::Bool(false)) => "off".to_string(),
|
||||
Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"),
|
||||
_ => {
|
||||
@@ -302,7 +306,8 @@ impl WayfernManager {
|
||||
"direct".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
format!("v2:{base}")
|
||||
}
|
||||
|
||||
/// Apply timezone/geolocation fields to a fingerprint object from the proxy's
|
||||
@@ -397,12 +402,44 @@ impl WayfernManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `url` is a socks proxy on a remote (non-loopback) host — the
|
||||
/// case where reqwest's SOCKS connector can't be trusted with the
|
||||
/// geolocation fetch. Loopback socks URLs are the app's own donut-proxy
|
||||
/// workers, whose single-segment replies don't trigger the connector bug.
|
||||
fn is_remote_socks_url(url: &str) -> bool {
|
||||
url.starts_with("socks")
|
||||
&& url::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| match u.host() {
|
||||
Some(url::Host::Ipv4(ip)) => Some(!ip.is_loopback()),
|
||||
Some(url::Host::Ipv6(ip)) => Some(!ip.is_loopback()),
|
||||
// socks is a non-special scheme, so the url crate keeps even
|
||||
// IP-literal hosts as Domain — parse them before comparing.
|
||||
Some(url::Host::Domain(domain)) => Some(
|
||||
domain != "localhost"
|
||||
&& domain
|
||||
.parse::<std::net::IpAddr>()
|
||||
.map(|ip| !ip.is_loopback())
|
||||
.unwrap_or(true),
|
||||
),
|
||||
None => None,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate a fingerprint for `config`, returning the fingerprint JSON and
|
||||
/// whether fresh geolocation was applied to it. Callers must only stamp
|
||||
/// `geo_proxy_signature` when geolocation succeeded: the base fingerprint
|
||||
/// comes from a headless Wayfern launched without a proxy, so on failure it
|
||||
/// silently carries the HOST timezone/locale — stamping the signature then
|
||||
/// would tell the launch-time refresh the location is already correct for
|
||||
/// this proxy and permanently disable the one path that can repair it.
|
||||
pub async fn generate_fingerprint_config(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
config: &WayfernConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
) -> Result<(String, bool), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
||||
@@ -549,7 +586,7 @@ impl WayfernManager {
|
||||
.send_cdp_command(&ws_url, "Wayfern.getFingerprint", json!({}))
|
||||
.await;
|
||||
|
||||
let fingerprint = match get_result {
|
||||
let (fingerprint, geolocation_applied) = match get_result {
|
||||
Ok(result) => {
|
||||
// Wayfern.getFingerprint returns { fingerprint: {...} }
|
||||
// We need to extract just the fingerprint object
|
||||
@@ -557,16 +594,57 @@ impl WayfernManager {
|
||||
// Normalize the fingerprint: convert JSON string fields to proper types
|
||||
let mut normalized = Self::normalize_fingerprint(fp);
|
||||
|
||||
// reqwest's SOCKS connector (hyper-util) corrupts its parse buffer
|
||||
// when a proxy splits a handshake reply across TCP segments, so a
|
||||
// socks upstream here can fail even though the proxy is healthy.
|
||||
// Route the geolocation lookup through a temporary local donut-proxy
|
||||
// worker — the same path the browser itself uses — and fall back to
|
||||
// the upstream URL only if the worker can't start. Two exclusions:
|
||||
// no worker when geolocation won't fetch through the proxy at all
|
||||
// (disabled, or a fixed geoip IP), and none for loopback socks URLs —
|
||||
// launch-time callers pass the already-running local worker's
|
||||
// socks5://127.0.0.1 URL, whose single-segment replies don't trigger
|
||||
// the bug, so chaining a second worker would only add latency.
|
||||
let needs_proxied_geo_fetch = !matches!(
|
||||
config.geoip.as_ref(),
|
||||
Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::String(_))
|
||||
);
|
||||
let remote_socks_upstream = config
|
||||
.proxy
|
||||
.as_deref()
|
||||
.filter(|url| Self::is_remote_socks_url(url));
|
||||
let (geo_proxy, temp_worker_id) = match remote_socks_upstream {
|
||||
Some(url) if needs_proxied_geo_fetch => {
|
||||
match crate::proxy_runner::start_proxy_process(Some(url.to_string()), None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
{
|
||||
Ok(worker) => {
|
||||
let local_url = format!("http://127.0.0.1:{}", worker.local_port.unwrap_or(0));
|
||||
(Some(local_url), Some(worker.id))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Could not start local proxy worker for geolocation ({e}); using the socks upstream directly"
|
||||
);
|
||||
(config.proxy.clone(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (config.proxy.clone(), None),
|
||||
};
|
||||
|
||||
// Apply timezone/geolocation for the proxy this fingerprint is being
|
||||
// generated against. Shared with the launch-time location refresh.
|
||||
Self::apply_geolocation(
|
||||
&mut normalized,
|
||||
config.proxy.as_deref(),
|
||||
config.geoip.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let geolocation_applied =
|
||||
Self::apply_geolocation(&mut normalized, geo_proxy.as_deref(), config.geoip.as_ref())
|
||||
.await;
|
||||
|
||||
normalized
|
||||
if let Some(worker_id) = temp_worker_id {
|
||||
let _ = crate::proxy_runner::stop_proxy_process(&worker_id).await;
|
||||
}
|
||||
|
||||
(normalized, geolocation_applied)
|
||||
}
|
||||
Err(e) => {
|
||||
cleanup().await;
|
||||
@@ -599,7 +677,7 @@ impl WayfernManager {
|
||||
);
|
||||
}
|
||||
|
||||
Ok(fingerprint_json)
|
||||
Ok((fingerprint_json, geolocation_applied))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1418,6 +1496,37 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_socks_url_detection() {
|
||||
// Remote socks upstreams (the hyper-util-affected case) are detected...
|
||||
assert!(WayfernManager::is_remote_socks_url(
|
||||
"socks5://user:pass@gw.dataimpulse.com:10000"
|
||||
));
|
||||
assert!(WayfernManager::is_remote_socks_url("socks5://1.2.3.4:1080"));
|
||||
assert!(WayfernManager::is_remote_socks_url("socks4://1.2.3.4:1080"));
|
||||
|
||||
// ...but the app's own loopback workers are not. socks is a non-special
|
||||
// URL scheme, so the IP literal parses as Host::Domain — the launch-time
|
||||
// randomize path depends on this returning false.
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"socks5://127.0.0.1:24001"
|
||||
));
|
||||
assert!(!WayfernManager::is_remote_socks_url("socks5://[::1]:24001"));
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"socks5://localhost:24001"
|
||||
));
|
||||
|
||||
// Non-socks schemes and unparsable URLs never need the workaround.
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"http://gw.dataimpulse.com:10000"
|
||||
));
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"https://gw.dataimpulse.com:10000"
|
||||
));
|
||||
assert!(!WayfernManager::is_remote_socks_url("socks5://"));
|
||||
assert!(!WayfernManager::is_remote_socks_url("not a url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_prefers_outer_window_dimensions() {
|
||||
// Field names + values mirror a real Wayfern fingerprint (camelCase).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.28.1",
|
||||
"version": "0.28.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+84
-5
@@ -3,14 +3,21 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { motion } from "motion/react";
|
||||
import { useOnborda } from "onborda";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AboutDialog } from "@/components/about-dialog";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import {
|
||||
type ConsistencyResult,
|
||||
ConsistencyWarningDialog,
|
||||
isConsistencyWarningSuppressed,
|
||||
} from "@/components/consistency-warning-dialog";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
@@ -60,6 +67,7 @@ import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import {
|
||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||
setOnboardingActive,
|
||||
@@ -324,6 +332,11 @@ export default function Home() {
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
|
||||
const [consistencyWarning, setConsistencyWarning] = useState<{
|
||||
profile: BrowserProfile;
|
||||
result: ConsistencyResult;
|
||||
} | null>(null);
|
||||
// Owned by page.tsx so the command palette can request opening the profile
|
||||
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||
const [profileInfoDialog, setProfileInfoDialog] =
|
||||
@@ -917,6 +930,24 @@ export default function Home() {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
|
||||
// Non-blocking: after a successful launch, check that the proxy exit
|
||||
// node's timezone/country agrees with the fingerprint. A mismatch is a
|
||||
// strong anti-bot tell even though the real device never leaks.
|
||||
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
|
||||
void invoke<ConsistencyResult>(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{ profileId: profile.id },
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.checked && !res.consistent) {
|
||||
setConsistencyWarning({ profile, result: res });
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn("Consistency check failed:", e);
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -1580,12 +1611,24 @@ export default function Home() {
|
||||
pageTitle={subPageTitle}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
|
||||
<RailNav
|
||||
currentPage={currentPage}
|
||||
onNavigate={handleRailNavigate}
|
||||
onOpenAbout={() => {
|
||||
setAboutDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{currentPage === "profiles" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2.5">
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
|
||||
className="flex min-h-0 flex-1 flex-col px-3 pt-2.5"
|
||||
>
|
||||
<ProfilesDataTable
|
||||
isLoading={isLoading && profiles.length === 0}
|
||||
showOnboardingEmptyState={profiles.length === 0}
|
||||
profiles={filteredProfiles}
|
||||
infoDialogProfile={profileInfoDialog}
|
||||
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||
@@ -1626,12 +1669,25 @@ export default function Home() {
|
||||
onLaunchWithSync={(profile) => {
|
||||
setSyncLeaderProfile(profile);
|
||||
}}
|
||||
onCreateProfile={() => {
|
||||
setCreateProfileDialogOpen(true);
|
||||
}}
|
||||
onImportProfiles={() => {
|
||||
handleRailNavigate("import");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{currentPage === "shortcuts" && (
|
||||
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{settingsDialogOpen && (
|
||||
@@ -1760,6 +1816,29 @@ export default function Home() {
|
||||
handleRailNavigate("profiles");
|
||||
setProfileInfoDialog(profile);
|
||||
}}
|
||||
onCreateProfile={() => {
|
||||
setCreateProfileDialogOpen(true);
|
||||
}}
|
||||
onOpenAbout={() => {
|
||||
setAboutDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AboutDialog
|
||||
isOpen={aboutDialogOpen}
|
||||
onClose={() => {
|
||||
setAboutDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConsistencyWarningDialog
|
||||
isOpen={consistencyWarning !== null}
|
||||
onClose={() => {
|
||||
setConsistencyWarning(null);
|
||||
}}
|
||||
profileName={consistencyWarning?.profile.name ?? ""}
|
||||
profileId={consistencyWarning?.profile.id ?? ""}
|
||||
result={consistencyWarning?.result ?? null}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { launchDonutClone } from "@/lib/donut-physics";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
app_version: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
portable: boolean;
|
||||
}
|
||||
|
||||
// Flywheel: each click adds spin; past this speed the donut escapes the
|
||||
// dialog and bounces around the window (shared physics with the rail egg).
|
||||
const SPIN_PER_CLICK = 540; // deg/s
|
||||
const ESCAPE_VELOCITY = 2200; // deg/s
|
||||
const SPIN_FRICTION = 1.1; // fraction of velocity lost per second
|
||||
|
||||
export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||
const [logoFlown, setLogoFlown] = useState(false);
|
||||
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const rotationRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const lastTimeRef = useRef(0);
|
||||
const cancelLaunchRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
invoke<SystemInfo>("get_system_info")
|
||||
.then(setSystemInfo)
|
||||
.catch(() => {
|
||||
setSystemInfo(null);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const stopSpin = useCallback(() => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
velocityRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const spinFrame = useCallback(
|
||||
(time: number) => {
|
||||
const el = logoRef.current;
|
||||
if (!el) {
|
||||
rafRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05);
|
||||
lastTimeRef.current = time;
|
||||
|
||||
rotationRef.current += velocityRef.current * dt;
|
||||
velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt);
|
||||
el.style.transform = `rotate(${rotationRef.current}deg)`;
|
||||
|
||||
if (velocityRef.current >= ESCAPE_VELOCITY) {
|
||||
// The flywheel wins: the donut tears loose and joins the bounce sim,
|
||||
// keeping its spin.
|
||||
stopSpin();
|
||||
setLogoFlown(true);
|
||||
cancelLaunchRef.current = launchDonutClone(el, {
|
||||
initialVX: Math.random() > 0.5 ? 420 : -420,
|
||||
initialVY: -750,
|
||||
spinSpeed: ESCAPE_VELOCITY,
|
||||
onExit: () => {
|
||||
cancelLaunchRef.current = null;
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (velocityRef.current > 5) {
|
||||
rafRef.current = requestAnimationFrame(spinFrame);
|
||||
} else {
|
||||
rafRef.current = 0;
|
||||
velocityRef.current = 0;
|
||||
}
|
||||
},
|
||||
[stopSpin],
|
||||
);
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
if (reducedMotion || logoFlown) return;
|
||||
velocityRef.current += SPIN_PER_CLICK;
|
||||
if (!rafRef.current) {
|
||||
lastTimeRef.current = performance.now();
|
||||
rafRef.current = requestAnimationFrame(spinFrame);
|
||||
}
|
||||
}, [reducedMotion, logoFlown, spinFrame]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
stopSpin();
|
||||
cancelLaunchRef.current?.();
|
||||
cancelLaunchRef.current = null;
|
||||
rotationRef.current = 0;
|
||||
if (logoRef.current) {
|
||||
logoRef.current.style.transform = "";
|
||||
logoRef.current.style.visibility = "";
|
||||
}
|
||||
setLogoFlown(false);
|
||||
onClose();
|
||||
}, [stopSpin, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
cancelLaunchRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("about.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<button
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
aria-label={t("header.donutLogo")}
|
||||
onClick={handleLogoClick}
|
||||
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
|
||||
style={logoFlown ? { visibility: "hidden" } : undefined}
|
||||
>
|
||||
<Logo className="size-14" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold">Donut Browser</p>
|
||||
{systemInfo && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("about.version", { version: systemInfo.app_version })}
|
||||
{systemInfo.portable && (
|
||||
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
|
||||
{t("about.portableBadge")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{systemInfo.os} {systemInfo.arch}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("about.licenseNotice")}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openUrl("https://donutbrowser.com")}
|
||||
>
|
||||
{t("about.website")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void openUrl("https://github.com/zhom/donutbrowser")
|
||||
}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+273
-271
@@ -198,315 +198,317 @@ export function AccountPage({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-4rem)] max-w-2xl flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
{t("account.tabs.account")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
{t("account.tabs.account")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
{user.subscriptionStatus ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-server-url" className="text-xs">
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-server-url"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider } from "@/components/i18n-provider";
|
||||
import { OnboardingProvider } from "@/components/onboarding-provider";
|
||||
@@ -17,11 +18,17 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
{/* reducedMotion="user" makes every motion/react animation honor the
|
||||
OS prefers-reduced-motion setting: transforms are skipped, opacity
|
||||
cross-fades are kept. The CSS-side media query in globals.css only
|
||||
covers CSS transitions — this covers the JS-driven ones. */}
|
||||
<MotionConfig reducedMotion="user">
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</MotionConfig>
|
||||
</CustomThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,14 @@ import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear } from "react-icons/go";
|
||||
import {
|
||||
LuBadgeInfo,
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
@@ -53,6 +55,8 @@ interface CommandPaletteProps {
|
||||
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||
onKillProfile: (profile: BrowserProfile) => void;
|
||||
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||
onCreateProfile: () => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -122,6 +126,8 @@ export function CommandPalette({
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onShowProfileInfo,
|
||||
onCreateProfile,
|
||||
onOpenAbout,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -251,6 +257,14 @@ export function CommandPalette({
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
dispatch(onCreateProfile);
|
||||
}}
|
||||
>
|
||||
<LuPlus />
|
||||
<span>{t("commandPalette.actions.createProfile")}</span>
|
||||
</CommandItem>
|
||||
{byGroup("actions").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
@@ -268,6 +282,14 @@ export function CommandPalette({
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
dispatch(onOpenAbout);
|
||||
}}
|
||||
>
|
||||
<LuBadgeInfo />
|
||||
<span>{t("commandPalette.actions.about")}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface ConsistencyResult {
|
||||
consistent: boolean;
|
||||
checked: boolean;
|
||||
exit_ip: string | null;
|
||||
exit_country_code: string | null;
|
||||
exit_timezone: string | null;
|
||||
fingerprint_timezone: string | null;
|
||||
fingerprint_language: string | null;
|
||||
mismatches: string[];
|
||||
}
|
||||
|
||||
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
|
||||
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
|
||||
|
||||
export function isConsistencyWarningEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConsistencyWarningSuppressed(profileId: string): boolean {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
|
||||
localStorage.getItem(perProfileKey(profileId)) === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsistencyWarningDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
result: ConsistencyResult | null;
|
||||
}
|
||||
|
||||
export function ConsistencyWarningDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileName,
|
||||
profileId,
|
||||
result,
|
||||
}: ConsistencyWarningDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dontWarnAgain, setDontWarnAgain] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontWarnAgain) {
|
||||
try {
|
||||
localStorage.setItem(perProfileKey(profileId), "1");
|
||||
} catch {
|
||||
// localStorage unavailable — nothing to persist
|
||||
}
|
||||
}
|
||||
setDontWarnAgain(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mismatches = result?.mismatches ?? [];
|
||||
const exitIp = result?.exit_ip ?? null;
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
setIsMatching(true);
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning" />
|
||||
{t("consistencyWarning.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("consistencyWarning.intro", { name: profileName })}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
|
||||
{mismatches.includes("timezone") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.timezoneTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.timezoneDetail", {
|
||||
exit: result?.exit_timezone ?? "?",
|
||||
fingerprint: result?.fingerprint_timezone ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mismatches.includes("language") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.languageTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.languageDetail", {
|
||||
country: result?.exit_country_code ?? "?",
|
||||
fingerprint: result?.fingerprint_language ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.explainer")}
|
||||
</p>
|
||||
|
||||
<label
|
||||
htmlFor="consistency-dont-warn"
|
||||
className="flex cursor-pointer items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id="consistency-dont-warn"
|
||||
checked={dontWarnAgain}
|
||||
onCheckedChange={(v) => setDontWarnAgain(v === true)}
|
||||
/>
|
||||
{t("consistencyWarning.dontWarnAgain")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isMatching}
|
||||
>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
{exitIp && (
|
||||
<RippleButton onClick={handleMatch} disabled={isMatching}>
|
||||
{isMatching
|
||||
? t("consistencyWarning.matching")
|
||||
: t("consistencyWarning.matchToProxy")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuCookie,
|
||||
LuSearch,
|
||||
} from "react-icons/lu";
|
||||
import { LuChevronRight, LuCookie, LuSearch } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
AnimatedDisclosureChevron,
|
||||
AnimatedDisclosureContent,
|
||||
AnimatedDisclosureItem,
|
||||
} from "@/components/ui/animated-disclosure";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -546,7 +546,7 @@ function DomainRow({
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AnimatedDisclosureItem>
|
||||
<div className="flex items-center gap-2 rounded p-2 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
@@ -557,48 +557,47 @@ function DomainRow({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left"
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="size-4" />
|
||||
) : (
|
||||
<AnimatedDisclosureChevron open={isExpanded}>
|
||||
<LuChevronRight className="size-4" />
|
||||
)}
|
||||
</AnimatedDisclosureChevron>
|
||||
<span className="truncate font-medium">{domain.domain}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-8 space-y-1 border-l pl-2">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatedDisclosureContent
|
||||
open={isExpanded}
|
||||
className="ml-8 space-y-1 border-l pl-2"
|
||||
>
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected = domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</AnimatedDisclosureContent>
|
||||
</AnimatedDisclosureItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
|
||||
import { LuChevronRight, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
AnimatedDisclosureChevron,
|
||||
AnimatedDisclosureContent,
|
||||
AnimatedDisclosureItem,
|
||||
} from "@/components/ui/animated-disclosure";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -628,7 +633,7 @@ function ExportDomainRow({
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AnimatedDisclosureItem>
|
||||
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
@@ -639,48 +644,47 @@ function ExportDomainRow({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left text-sm"
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<AnimatedDisclosureChevron open={isExpanded}>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
)}
|
||||
</AnimatedDisclosureChevron>
|
||||
<span className="truncate font-medium">{domain.domain}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-7 space-y-0.5 border-l pl-2">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatedDisclosureContent
|
||||
open={isExpanded}
|
||||
className="ml-7 space-y-0.5 border-l pl-2"
|
||||
>
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected = domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</AnimatedDisclosureContent>
|
||||
</AnimatedDisclosureItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||
|
||||
@@ -1183,21 +1184,14 @@ export function CreateProfileDialog({
|
||||
<SelectItem value="none">
|
||||
{t("dnsBlocklist.none")}
|
||||
</SelectItem>
|
||||
<SelectItem value="light">
|
||||
{t("dnsBlocklist.light")}
|
||||
</SelectItem>
|
||||
<SelectItem value="normal">
|
||||
{t("dnsBlocklist.normal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro">
|
||||
{t("dnsBlocklist.pro")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro_plus">
|
||||
{t("dnsBlocklist.proPlus")}
|
||||
</SelectItem>
|
||||
<SelectItem value="ultimate">
|
||||
{t("dnsBlocklist.ultimate")}
|
||||
</SelectItem>
|
||||
{DNS_BLOCKLIST_LEVELS.map((level) => (
|
||||
<SelectItem
|
||||
key={level.value}
|
||||
value={level.value}
|
||||
>
|
||||
{t(level.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
+220
-135
@@ -26,14 +26,14 @@
|
||||
*
|
||||
* Auto-update toast:
|
||||
* ```
|
||||
* showAutoUpdateToast("Firefox", "125.0.1");
|
||||
* showAutoUpdateToast("Wayfern", "149.0.7827.116");
|
||||
* ```
|
||||
*
|
||||
* Download progress toast:
|
||||
* ```
|
||||
* showToast({
|
||||
* type: "download",
|
||||
* title: "Downloading Firefox 123.0",
|
||||
* title: "Downloading Wayfern 149.0.7827.116",
|
||||
* progress: { percentage: 45, speed: "2.5", eta: "30s" }
|
||||
* });
|
||||
* ```
|
||||
@@ -49,6 +49,7 @@
|
||||
*/
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuCheckCheck,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import type { ExternalToast } from "sonner";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface BaseToastProps {
|
||||
@@ -159,6 +161,23 @@ function formatEtaCompact(seconds: number): string {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
function ProgressBar({
|
||||
percentage,
|
||||
className = "w-full",
|
||||
}: {
|
||||
percentage: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`h-1.5 rounded-full bg-muted ${className}`}>
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-foreground"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
@@ -196,154 +215,220 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
|
||||
export function UnifiedToast(props: ToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const { title, description, type, action, onCancel } = props;
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
const stateKey = `${type}:${stage ?? "default"}`;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md items-start rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg">
|
||||
<div className="mt-0.5 mr-3">{getToastIcon(type, stage)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm/tight font-semibold text-foreground">{title}</p>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 mr-3">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={`icon-${stateKey}`}
|
||||
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.92 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: reduceMotion ? 1 : 0.92,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.12,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{getToastIcon(type, stage)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={stateKey}
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: reduceMotion ? 0 : -4,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.12,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm/tight font-semibold text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download progress */}
|
||||
{type === "download" &&
|
||||
progress &&
|
||||
"percentage" in progress &&
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta &&
|
||||
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version update progress */}
|
||||
{type === "version-update" &&
|
||||
progress &&
|
||||
"current_browser" in progress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.current_browser &&
|
||||
t("versionUpdater.toast.lookingForUpdates", {
|
||||
browser: progress.current_browser,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="h-1.5 min-w-0 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Download progress */}
|
||||
{type === "download" &&
|
||||
progress &&
|
||||
"percentage" in progress &&
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta &&
|
||||
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
<ProgressBar percentage={progress.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extraction / verification progress. Extraction reports a real
|
||||
percentage for most archive formats; when none is available yet
|
||||
(or the format can't measure progress) show an indeterminate bar. */}
|
||||
{type === "download" &&
|
||||
(stage === "extracting" || stage === "verifying") && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{stage === "extracting" &&
|
||||
progress &&
|
||||
"percentage" in progress &&
|
||||
progress.percentage > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
</p>
|
||||
<ProgressBar percentage={progress.percentage} />
|
||||
</>
|
||||
) : (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-1.5 w-1/3 animate-progress-indeterminate rounded-full bg-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version update progress */}
|
||||
{type === "version-update" &&
|
||||
progress &&
|
||||
"current_browser" in progress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.current_browser &&
|
||||
t("versionUpdater.toast.lookingForUpdates", {
|
||||
browser: progress.current_browser,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ProgressBar
|
||||
percentage={(progress.current / progress.total) * 100}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync progress */}
|
||||
{type === "sync-progress" &&
|
||||
progress &&
|
||||
"completed_files" in progress && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.phase === "uploading"
|
||||
? t("appUpdate.toast.uploading")
|
||||
: t("appUpdate.toast.downloading")}{" "}
|
||||
{t("toasts.progress.filesProgress", {
|
||||
completed: progress.completed_files,
|
||||
total: progress.total_files,
|
||||
})}
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
{formatBytesCompact(progress.total_bytes)}
|
||||
{progress.speed_bytes_per_sec > 0 && (
|
||||
<>
|
||||
{" \u2022 "}
|
||||
{formatSpeedCompact(progress.speed_bytes_per_sec)}
|
||||
</>
|
||||
)}
|
||||
{progress.eta_seconds > 0 &&
|
||||
progress.completed_files < progress.total_files &&
|
||||
` \u2022 ${t("toasts.progress.remaining", {
|
||||
time: `~${formatEtaCompact(progress.eta_seconds)}`,
|
||||
})}`}
|
||||
</p>
|
||||
{progress.failed_count > 0 && (
|
||||
<p className="mt-0.5 text-xs text-destructive">
|
||||
{t("toasts.progress.filesFailed", {
|
||||
count: progress.failed_count,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs/tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sync progress */}
|
||||
{type === "sync-progress" &&
|
||||
progress &&
|
||||
"completed_files" in progress && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.phase === "uploading"
|
||||
? t("appUpdate.toast.uploading")
|
||||
: t("appUpdate.toast.downloading")}{" "}
|
||||
{t("toasts.progress.filesProgress", {
|
||||
completed: progress.completed_files,
|
||||
total: progress.total_files,
|
||||
})}
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
{formatBytesCompact(progress.total_bytes)}
|
||||
{progress.speed_bytes_per_sec > 0 && (
|
||||
<>
|
||||
{" \u2022 "}
|
||||
{formatSpeedCompact(progress.speed_bytes_per_sec)}
|
||||
</>
|
||||
)}
|
||||
{progress.eta_seconds > 0 &&
|
||||
progress.completed_files < progress.total_files &&
|
||||
` \u2022 ${t("toasts.progress.remaining", {
|
||||
time: `~${formatEtaCompact(progress.eta_seconds)}`,
|
||||
})}`}
|
||||
</p>
|
||||
{progress.failed_count > 0 && (
|
||||
<p className="mt-0.5 text-xs text-destructive">
|
||||
{t("toasts.progress.filesFailed", {
|
||||
count: progress.failed_count,
|
||||
})}
|
||||
{/* Stage-specific descriptions for downloads */}
|
||||
{type === "download" && !description && (
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("browserDownload.toast.extracting")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("browserDownload.toast.verifying")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs/tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stage-specific descriptions for downloads */}
|
||||
{type === "download" && !description && (
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("browserDownload.toast.extracting")}
|
||||
</p>
|
||||
{action &&
|
||||
"onClick" in (action as { onClick?: () => void; label?: string }) &&
|
||||
"label" in (action as { onClick?: () => void; label?: string }) && (
|
||||
<div className="mt-2 w-full">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={
|
||||
(action as { onClick: () => void; label: string }).onClick
|
||||
}
|
||||
>
|
||||
{(action as { onClick: () => void; label: string }).label}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("browserDownload.toast.verifying")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{action &&
|
||||
"onClick" in (action as { onClick?: () => void; label?: string }) &&
|
||||
"label" in (action as { onClick?: () => void; label?: string }) && (
|
||||
<div className="mt-2 w-full">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={
|
||||
(action as { onClick: () => void; label: string }).onClick
|
||||
}
|
||||
>
|
||||
{(action as { onClick: () => void; label: string }).label}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
open as openDialog,
|
||||
save as saveDialog,
|
||||
} from "@tauri-apps/plugin-dialog";
|
||||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,6 +25,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { dnsBlocklistLabelKey } from "@/lib/dns-blocklist-levels";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
|
||||
interface BlocklistCacheStatus {
|
||||
level: string;
|
||||
@@ -23,11 +41,25 @@ interface BlocklistCacheStatus {
|
||||
is_cached: boolean;
|
||||
}
|
||||
|
||||
interface CustomDnsConfig {
|
||||
sources: string[];
|
||||
block_domains: string[];
|
||||
allow_domains: string[];
|
||||
allowlist_mode: boolean;
|
||||
updated_at: number | null;
|
||||
}
|
||||
|
||||
interface DnsBlocklistDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const linesToArray = (v: string) =>
|
||||
v
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function DnsBlocklistDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -36,6 +68,12 @@ export function DnsBlocklistDialog({
|
||||
const [statuses, setStatuses] = useState<BlocklistCacheStatus[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const [sources, setSources] = useState("");
|
||||
const [blockDomains, setBlockDomains] = useState("");
|
||||
const [allowDomains, setAllowDomains] = useState("");
|
||||
const [allowlistMode, setAllowlistMode] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const loadStatuses = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<BlocklistCacheStatus[]>(
|
||||
@@ -47,11 +85,24 @@ export function DnsBlocklistDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCustomConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await invoke<CustomDnsConfig>("get_custom_dns_config");
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
} catch (e) {
|
||||
console.error("Failed to load custom DNS config:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadStatuses();
|
||||
void loadCustomConfig();
|
||||
}
|
||||
}, [isOpen, loadStatuses]);
|
||||
}, [isOpen, loadStatuses, loadCustomConfig]);
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -65,6 +116,67 @@ export function DnsBlocklistDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustom = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const config = await invoke<CustomDnsConfig>("set_custom_dns_config", {
|
||||
sources: linesToArray(sources),
|
||||
blockDomains: linesToArray(blockDomains),
|
||||
allowDomains: linesToArray(allowDomains),
|
||||
allowlistMode,
|
||||
});
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
toast.success(t("dnsBlocklist.custom.saved"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "Rules", extensions: ["json", "txt"] }],
|
||||
});
|
||||
if (!selected || typeof selected !== "string") return;
|
||||
const content = await readTextFile(selected);
|
||||
const format = selected.toLowerCase().endsWith(".json") ? "json" : "txt";
|
||||
const config = await invoke<CustomDnsConfig>("import_custom_dns_rules", {
|
||||
content,
|
||||
format,
|
||||
});
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
toast.success(t("dnsBlocklist.custom.imported"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format: "json" | "txt") => {
|
||||
try {
|
||||
const content = await invoke<string>("export_custom_dns_rules", {
|
||||
format,
|
||||
});
|
||||
const path = await saveDialog({
|
||||
defaultPath: `donut-dns-rules.${format}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [format] }],
|
||||
});
|
||||
if (!path) return;
|
||||
await writeTextFile(path, content);
|
||||
toast.success(t("dnsBlocklist.custom.exported"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -78,69 +190,185 @@ export function DnsBlocklistDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-lg flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
<AnimatedTabs
|
||||
defaultValue="blocklists"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
<AnimatedTabsTrigger value="blocklists">
|
||||
{t("dnsBlocklist.tabBlocklists")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="custom">
|
||||
{t("dnsBlocklist.tabCustom")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<div className="max-h-[40vh] min-h-0 space-y-3 overflow-y-auto">
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
className="flex items-center justify-between rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{status.display_name}
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
<AnimatedTabsContent
|
||||
value="blocklists"
|
||||
className="min-h-0 flex-1 space-y-3 overflow-y-auto"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
className="flex items-center justify-between rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t(dnsBlocklistLabelKey(status.level))}
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 text-[10px]"
|
||||
>
|
||||
{t("dnsBlocklist.stale")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.stale")}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{status.is_cached && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.entry_count.toLocaleString()}{" "}
|
||||
{t("dnsBlocklist.domains")} ·{" "}
|
||||
{formatSize(status.file_size_bytes)} ·{" "}
|
||||
{formatDate(status.last_updated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{status.is_cached && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.entry_count.toLocaleString()}{" "}
|
||||
{t("dnsBlocklist.domains")} ·{" "}
|
||||
{formatSize(status.file_size_bytes)} ·{" "}
|
||||
{formatDate(status.last_updated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("dnsBlocklist.refreshAll")}
|
||||
</Button>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("dnsBlocklist.refreshAll")}
|
||||
</Button>
|
||||
<AnimatedTabsContent
|
||||
value="custom"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-y-auto"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.custom.description")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("dnsBlocklist.custom.allowlistModeLabel")}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowlistModeOn")
|
||||
: t("dnsBlocklist.custom.allowlistModeOff")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={allowlistMode}
|
||||
onCheckedChange={(v) => setAllowlistMode(v === true)}
|
||||
aria-label={t("dnsBlocklist.custom.allowlistModeLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!allowlistMode && (
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{t("dnsBlocklist.custom.sourcesLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={sources}
|
||||
onChange={(e) => setSources(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.sourcesPlaceholder")}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allowlistMode && (
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{t("dnsBlocklist.custom.blockLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={blockDomains}
|
||||
onChange={(e) => setBlockDomains(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.blockPlaceholder")}
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowedOnlyLabel")
|
||||
: t("dnsBlocklist.custom.allowLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={allowDomains}
|
||||
onChange={(e) => setAllowDomains(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.allowPlaceholder")}
|
||||
rows={allowlistMode ? 6 : 3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowedOnlyHint")
|
||||
: t("dnsBlocklist.custom.allowHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LoadingButton isLoading={isSaving} onClick={handleSaveCustom}>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
<Button variant="outline" onClick={handleImport}>
|
||||
{t("common.buttons.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleExport("txt")}
|
||||
>
|
||||
{t("dnsBlocklist.custom.exportTxt")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleExport("json")}
|
||||
>
|
||||
{t("dnsBlocklist.custom.exportJson")}
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export function ExtensionManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -557,7 +557,7 @@ export function GroupManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(60rem,calc(100%-4rem))] flex-col">
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("groups.management")}</DialogTitle>
|
||||
@@ -567,15 +567,13 @@ export function GroupManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("groups.pageTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("groups.pageDescription")}
|
||||
</p>
|
||||
<div className="@container flex min-h-0 w-full flex-1 flex-col">
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<div className="inline-flex h-7 items-center justify-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium whitespace-nowrap text-foreground">
|
||||
<span>{t("groups.pageTitle")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{groups.length}
|
||||
</span>
|
||||
</div>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -583,31 +581,34 @@ export function GroupManagementDialog({
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
aria-label={t("common.buttons.create")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("proxies.management.create")}
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("common.buttons.create")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="mt-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
{t("groups.noGroupsDescription")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
"mt-4 min-h-0 flex-1",
|
||||
selectedGroupsForBulk.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -315,167 +315,261 @@ export function IntegrationsDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="mcp">
|
||||
{t("integrations.tabMcp")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="mcp">
|
||||
{t("integrations.tabMcp")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleApiToggle(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) => void handleApiToggle(checked)}
|
||||
/>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting ||
|
||||
apiServerPort === settings.api_port
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting || apiServerPort === settings.api_port
|
||||
}
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
t("integrations.apiInvalidPort"),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(
|
||||
t("integrations.apiStartFailed"),
|
||||
{
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("integrations.apiUnknownError"),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
{t("integrations.apiExampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="mcp"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleMcpToggle(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -483,10 +577,10 @@ export function IntegrationsDialog({
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
{showMcpUrl ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
@@ -494,164 +588,80 @@ export function IntegrationsDialog({
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
text={mcpUrl}
|
||||
successMessage={t("integrations.mcp.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiExampleRequest")}
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="mcp"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={(checked) => void handleMcpToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
>
|
||||
{showMcpUrl ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={mcpUrl}
|
||||
successMessage={t("integrations.mcp.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{categoryLabel(t, agent.category)}
|
||||
</p>
|
||||
</div>
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void handleRemoveAgent(agent)}
|
||||
aria-label={t(
|
||||
"integrations.mcp.removeAriaLabel",
|
||||
{
|
||||
name: agent.display_name,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</Button>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{categoryLabel(t, agent.category)}
|
||||
</p>
|
||||
</div>
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void handleRemoveAgent(agent)
|
||||
}
|
||||
aria-label={t(
|
||||
"integrations.mcp.removeAriaLabel",
|
||||
{
|
||||
name: agent.display_name,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { LuLoaderCircle } from "react-icons/lu";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type RippleButtonProps as ButtonProps,
|
||||
@@ -10,17 +12,42 @@ type Props = ButtonProps & {
|
||||
"aria-label"?: string;
|
||||
};
|
||||
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<UIButton
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
{...props}
|
||||
disabled={props.disabled || isLoading}
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LuLoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
<span className="inline-grid items-center justify-items-center">
|
||||
<motion.span
|
||||
animate={{
|
||||
opacity: isLoading ? 0 : 1,
|
||||
scale: reduceMotion || !isLoading ? 1 : 0.98,
|
||||
}}
|
||||
transition={{ duration: 0.1, ease: MOTION_EASE_OUT }}
|
||||
className="col-start-1 row-start-1 inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
{props.children}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isLoading ? 1 : 0,
|
||||
scale: reduceMotion || isLoading ? 1 : 0.9,
|
||||
}}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="pointer-events-none col-start-1 row-start-1 inline-flex items-center justify-center"
|
||||
>
|
||||
<LuLoaderCircle className="size-4 animate-spin" />
|
||||
</motion.span>
|
||||
</span>
|
||||
</UIButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
LuSquare,
|
||||
LuTrash2,
|
||||
LuTriangleAlert,
|
||||
LuUserSearch,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -107,11 +110,13 @@ import {
|
||||
DataTableActionBarAction,
|
||||
DataTableActionBarSelection,
|
||||
} from "./data-table-action-bar";
|
||||
import { Logo } from "./icons/logo";
|
||||
import MultipleSelector, { type Option } from "./multiple-selector";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { TrafficDetailsDialog } from "./traffic-details-dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
@@ -424,15 +429,7 @@ function DnsCell({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const level = profile.dns_blocklist ?? null;
|
||||
// Backend levels are: light, normal, pro, pro_plus, ultimate (+ null).
|
||||
// Keep the list ordered from least to most restrictive.
|
||||
const LEVELS: { value: string; labelKey: string }[] = [
|
||||
{ value: "light", labelKey: "dnsBlocklist.light" },
|
||||
{ value: "normal", labelKey: "dnsBlocklist.normal" },
|
||||
{ value: "pro", labelKey: "dnsBlocklist.pro" },
|
||||
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
|
||||
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
|
||||
];
|
||||
const LEVELS = DNS_BLOCKLIST_LEVELS;
|
||||
const currentLabel =
|
||||
level === null
|
||||
? null
|
||||
@@ -1168,6 +1165,12 @@ interface ProfilesDataTableProps {
|
||||
*/
|
||||
infoDialogProfile?: BrowserProfile | null;
|
||||
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
|
||||
/** Initial data load in flight — renders skeleton rows instead of "empty". */
|
||||
isLoading?: boolean;
|
||||
/** True when the app has zero profiles overall (not just a filtered view). */
|
||||
showOnboardingEmptyState?: boolean;
|
||||
onCreateProfile?: () => void;
|
||||
onImportProfiles?: () => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -1205,6 +1208,10 @@ export function ProfilesDataTable({
|
||||
onRemovePassword,
|
||||
infoDialogProfile,
|
||||
onInfoDialogProfileChange,
|
||||
isLoading = false,
|
||||
showOnboardingEmptyState = false,
|
||||
onCreateProfile,
|
||||
onImportProfiles,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -1665,6 +1672,30 @@ export function ProfilesDataTable({
|
||||
};
|
||||
}, [browserState.isClient, loadAllTags]);
|
||||
|
||||
// A running browser keeps the name it launched with, so close any inline
|
||||
// rename that was open when the profile entered a runtime transition.
|
||||
React.useEffect(() => {
|
||||
if (!profileToRename) return;
|
||||
|
||||
const profileId = profileToRename.id;
|
||||
const isRuntimeLocked =
|
||||
(browserState.isClient && runningProfiles.has(profileId)) ||
|
||||
launchingProfiles.has(profileId) ||
|
||||
stoppingProfiles.has(profileId);
|
||||
|
||||
if (isRuntimeLocked) {
|
||||
setProfileToRename(null);
|
||||
setNewProfileName("");
|
||||
setRenameError(null);
|
||||
}
|
||||
}, [
|
||||
profileToRename,
|
||||
runningProfiles,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
browserState.isClient,
|
||||
]);
|
||||
|
||||
// Automatically deselect profiles that become running, updating, launching, or stopping
|
||||
React.useEffect(() => {
|
||||
const newSet = new Set(selectedProfiles);
|
||||
@@ -2367,13 +2398,42 @@ export function ProfilesDataTable({
|
||||
: void handleProfileLaunch(profile)
|
||||
}
|
||||
>
|
||||
{isLaunching || isStopping ? (
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : isRunning ? (
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
) : (
|
||||
<LuPlay className="size-3.5 fill-current" />
|
||||
)}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isLaunching || isStopping ? (
|
||||
<motion.span
|
||||
key="spinner"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
</motion.span>
|
||||
) : isRunning ? (
|
||||
<motion.span
|
||||
key="stop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="play"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<LuPlay className="size-3.5 fill-current" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</RippleButton>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -2479,7 +2539,15 @@ export function ProfilesDataTable({
|
||||
const profile = row.original as BrowserProfile;
|
||||
const rawName: string = row.getValue("name");
|
||||
const name = getBrowserDisplayName(rawName);
|
||||
const isEditing = meta.profileToRename?.id === profile.id;
|
||||
const isRuntimeLocked =
|
||||
(meta.isClient && meta.runningProfiles.has(profile.id)) ||
|
||||
meta.launchingProfiles.has(profile.id) ||
|
||||
meta.stoppingProfiles.has(profile.id);
|
||||
const isCrossOsBlocked = isCrossOsProfile(profile);
|
||||
const isEditing =
|
||||
meta.profileToRename?.id === profile.id &&
|
||||
!isRuntimeLocked &&
|
||||
!isCrossOsBlocked;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -2530,45 +2598,44 @@ export function ProfilesDataTable({
|
||||
/>
|
||||
);
|
||||
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isCrossOsBlocked;
|
||||
const lockedEmail = meta.getProfileLockEmail(profile.id);
|
||||
const isLocked = meta.isProfileLockedByAnother(profile.id);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"mr-auto h-6 max-w-full min-w-0 overflow-hidden rounded border-none bg-transparent px-2 py-1 text-left",
|
||||
isDisabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
const nameControl = isRuntimeLocked ? (
|
||||
<div className="mr-auto h-6 max-w-full min-w-0 cursor-text overflow-hidden rounded px-2 py-1 text-left select-text">
|
||||
{display}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"mr-auto h-6 max-w-full min-w-0 overflow-hidden rounded border-none bg-transparent px-2 py-1 text-left",
|
||||
isCrossOsBlocked
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isCrossOsBlocked) return;
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isCrossOsBlocked) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
{nameControl}
|
||||
{isLocked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2595,14 +2662,7 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isCrossOsBlocked;
|
||||
const isDisabled = isCrossOsProfile(profile);
|
||||
|
||||
return (
|
||||
<TagsCell
|
||||
@@ -2628,14 +2688,7 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isCrossOsBlocked;
|
||||
const isDisabled = isCrossOsProfile(profile);
|
||||
|
||||
return (
|
||||
<NoteCell
|
||||
@@ -3155,14 +3208,97 @@ export function ProfilesDataTable({
|
||||
</TableHeader>
|
||||
<TableBody className="overflow-visible">
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
isLoading ? (
|
||||
Array.from({ length: 8 }, (_, i) => (
|
||||
<TableRow
|
||||
key={`skeleton-${i}`}
|
||||
className="border-0!"
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-7 shrink-0 rounded-md" />
|
||||
<Skeleton
|
||||
className="h-3"
|
||||
style={{ width: `${30 + ((i * 17) % 40)}%` }}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : showOnboardingEmptyState ? (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-16"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Logo className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("profiles.table.emptyTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profiles.table.emptyHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{onCreateProfile && (
|
||||
<RippleButton size="sm" onClick={onCreateProfile}>
|
||||
{t("profiles.table.emptyCreate")}
|
||||
</RippleButton>
|
||||
)}
|
||||
{onImportProfiles && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onImportProfiles}
|
||||
>
|
||||
{t("profiles.table.emptyImport")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-16"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="grid size-12 place-items-center rounded-full bg-muted/60">
|
||||
<LuUserSearch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("profiles.table.emptyFilteredTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profiles.table.emptyFilteredHint")}
|
||||
</p>
|
||||
</div>
|
||||
{onCreateProfile && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1"
|
||||
onClick={onCreateProfile}
|
||||
>
|
||||
{t("profiles.table.emptyCreate")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LuCookie,
|
||||
LuCopy,
|
||||
LuDownload,
|
||||
LuEraser,
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { SharedFingerprintConfigForm } from "@/components/shared-fingerprint-config-form";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ColorPicker,
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getProfileIcon } from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -126,6 +129,56 @@ function _OSIcon({ os }: { os: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function ClearOnCloseToggle({
|
||||
profile,
|
||||
isDisabled,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = React.useState(profile.clear_on_close === true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEnabled(profile.clear_on_close === true);
|
||||
}, [profile.clear_on_close]);
|
||||
|
||||
const toggle = async (next: boolean) => {
|
||||
setEnabled(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
await invoke("update_profile_clear_on_close", {
|
||||
profileId: profile.id,
|
||||
clearOnClose: next,
|
||||
});
|
||||
} catch (error) {
|
||||
setEnabled(!next);
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<LuEraser className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{t("clearOnClose.label")}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("clearOnClose.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={enabled}
|
||||
disabled={saving || isDisabled}
|
||||
onCheckedChange={(v) => void toggle(v === true)}
|
||||
aria-label={t("clearOnClose.label")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/50 px-3 py-2.5">
|
||||
@@ -976,6 +1029,10 @@ function ProfileInfoLayout({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!profile.ephemeral && !profile.password_protected && (
|
||||
<ClearOnCloseToggle profile={profile} isDisabled={isDisabled} />
|
||||
)}
|
||||
|
||||
{profile.created_by_email && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
@@ -2320,11 +2377,10 @@ export function ProfileDnsBlocklistDialog({
|
||||
|
||||
const options = [
|
||||
{ value: "", label: t("dnsBlocklist.none") },
|
||||
{ value: "light", label: t("dnsBlocklist.light") },
|
||||
{ value: "normal", label: t("dnsBlocklist.normal") },
|
||||
{ value: "pro", label: t("dnsBlocklist.pro") },
|
||||
{ value: "pro_plus", label: t("dnsBlocklist.proPlus") },
|
||||
{ value: "ultimate", label: t("dnsBlocklist.ultimate") },
|
||||
...DNS_BLOCKLIST_LEVELS.map((l) => ({
|
||||
value: l.value as string,
|
||||
label: t(l.labelKey),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import type { ProxyCheckResult, StoredProxy } from "@/types";
|
||||
|
||||
interface ProxyCheckButtonProps {
|
||||
@@ -37,6 +39,7 @@ export function ProxyCheckButton({
|
||||
setCheckingProfileId,
|
||||
}: ProxyCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [localResult, setLocalResult] = React.useState<
|
||||
ProxyCheckResult | undefined
|
||||
>(cachedResult);
|
||||
@@ -111,6 +114,13 @@ export function ProxyCheckButton({
|
||||
|
||||
const isCurrentlyChecking = checkingProfileId === profileId;
|
||||
const result = localResult;
|
||||
const statusKey = isCurrentlyChecking
|
||||
? "checking"
|
||||
: result?.is_valid && result.country_code
|
||||
? "valid-location"
|
||||
: result && !result.is_valid
|
||||
? "invalid"
|
||||
: "idle";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -122,18 +132,45 @@ export function ProxyCheckButton({
|
||||
onClick={handleCheck}
|
||||
disabled={isCurrentlyChecking || disabled}
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : result?.is_valid && result.country_code ? (
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<FlagIcon countryCode={result.country_code} className="h-2.5" />
|
||||
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
|
||||
</span>
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-sm text-destructive">✕</span>
|
||||
) : (
|
||||
<FiCheck className="size-3" />
|
||||
)}
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={statusKey}
|
||||
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.9 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: reduceMotion ? 1 : 0.9,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.1,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
className="inline-flex size-3 items-center justify-center"
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<span className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : result?.is_valid && result.country_code ? (
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<FlagIcon
|
||||
countryCode={result.country_code}
|
||||
className="h-2.5"
|
||||
/>
|
||||
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
|
||||
</span>
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-sm text-destructive">✕</span>
|
||||
) : (
|
||||
<FiCheck className="size-3" />
|
||||
)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { StepTransition } from "@/components/ui/step-transition";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import type {
|
||||
ParsedProxyLine,
|
||||
@@ -33,6 +34,25 @@ interface ProxyImportDialogProps {
|
||||
|
||||
type ImportStep = "dropzone" | "preview" | "ambiguous" | "result";
|
||||
|
||||
const STEP_ORDER: Record<ImportStep, number> = {
|
||||
dropzone: 0,
|
||||
ambiguous: 1,
|
||||
preview: 2,
|
||||
result: 3,
|
||||
};
|
||||
|
||||
interface StepState {
|
||||
step: ImportStep;
|
||||
direction: 1 | -1;
|
||||
}
|
||||
|
||||
function transitionStep(current: StepState, next: ImportStep): StepState {
|
||||
return {
|
||||
step: next,
|
||||
direction: STEP_ORDER[next] >= STEP_ORDER[current.step] ? 1 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
interface AmbiguousProxy {
|
||||
line: string;
|
||||
possible_formats: string[];
|
||||
@@ -41,7 +61,10 @@ interface AmbiguousProxy {
|
||||
|
||||
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [{ step, direction: stepDirection }, setStep] = useReducer(
|
||||
transitionStep,
|
||||
{ step: "dropzone", direction: 1 },
|
||||
);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
|
||||
const [ambiguousProxies, setAmbiguousProxies] = useState<AmbiguousProxy[]>(
|
||||
@@ -57,7 +80,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
const [namePrefix, setNamePrefix] = useState(
|
||||
t("proxies.importDialog.namePrefixDefault"),
|
||||
);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
|
||||
@@ -283,263 +305,279 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" && t("proxies.importDialog.descDropzone")}
|
||||
{step === "preview" && t("proxies.importDialog.descPreview")}
|
||||
{step === "ambiguous" && t("proxies.importDialog.descAmbiguous")}
|
||||
{step === "result" && t("proxies.importDialog.descResult")}
|
||||
</DialogDescription>
|
||||
<StepTransition
|
||||
transitionKey={`description-${step}`}
|
||||
direction={stepDirection}
|
||||
>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" && t("proxies.importDialog.descDropzone")}
|
||||
{step === "preview" && t("proxies.importDialog.descPreview")}
|
||||
{step === "ambiguous" && t("proxies.importDialog.descAmbiguous")}
|
||||
{step === "result" && t("proxies.importDialog.descResult")}
|
||||
</DialogDescription>
|
||||
</StepTransition>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "dropzone" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
<StepTransition
|
||||
transitionKey={step}
|
||||
direction={stepDirection}
|
||||
className="grid gap-4"
|
||||
>
|
||||
{step === "dropzone" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
flex flex-col items-center justify-center
|
||||
border-2 border-dashed rounded-lg p-8
|
||||
transition-colors cursor-pointer
|
||||
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
|
||||
cursor-pointer transition-[color,background-color,border-color,transform]
|
||||
duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none
|
||||
${isDragOver ? "scale-[1.005] border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
|
||||
`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() =>
|
||||
document.getElementById("proxy-file-input")?.click()
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("proxy-file-input")?.click();
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() =>
|
||||
document.getElementById("proxy-file-input")?.click()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="mb-4 size-10 text-muted-foreground" />
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.dropzonePrompt")}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
{t("proxies.importDialog.dropzoneFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
type="file"
|
||||
accept=".json,.txt"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("proxy-file-input")?.click();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("proxies.importDialog.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "preview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name-prefix">
|
||||
{t("proxies.importDialog.namePrefix")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name-prefix"
|
||||
placeholder={t("proxies.importDialog.namePrefixDefault")}
|
||||
value={namePrefix}
|
||||
onChange={(e) => {
|
||||
setNamePrefix(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxies.importDialog.namePrefixHint", {
|
||||
prefix:
|
||||
namePrefix || t("proxies.importDialog.namePrefixDefault"),
|
||||
})}
|
||||
>
|
||||
<LuUpload
|
||||
className={`mb-4 size-10 text-muted-foreground transition-transform duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none ${
|
||||
isDragOver ? "-translate-y-0.5 scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.dropzonePrompt")}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
{t("proxies.importDialog.dropzoneFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
type="file"
|
||||
accept=".json,.txt"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("proxies.importDialog.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("proxies.importDialog.proxiesToImport", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
{invalidProxies.length > 0 && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t("proxies.importDialog.invalidCount", {
|
||||
count: invalidProxies.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{parsedProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.original_line}-${i}`}
|
||||
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
|
||||
>
|
||||
<span className="text-primary">
|
||||
{proxy.proxy_type}://
|
||||
</span>
|
||||
{proxy.username && (
|
||||
<span className="text-muted-foreground">
|
||||
{proxy.username}:***@
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{proxy.host}:{proxy.port}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "ambiguous" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
|
||||
<div className="space-y-4 p-3">
|
||||
{ambiguousProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.line}-${i}`}
|
||||
className="space-y-2 border-b pb-3 last:border-0"
|
||||
>
|
||||
<code className="block rounded bg-muted px-2 py-1 text-xs break-all">
|
||||
{proxy.line}
|
||||
</code>
|
||||
<div className="flex flex-col gap-2">
|
||||
{proxy.possible_formats.map((format) => (
|
||||
<label
|
||||
key={format}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`format-${i}`}
|
||||
checked={proxy.selectedFormat === format}
|
||||
onChange={() => {
|
||||
handleAmbiguousFormatSelect(i, format);
|
||||
}}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs">{format}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "result" && importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 rounded-lg bg-muted/30 p-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.imported")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-success">
|
||||
{importResult.imported_count}
|
||||
</span>
|
||||
</div>
|
||||
{importResult.skipped_count > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.skippedDuplicates")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{importResult.skipped_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.errors")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
{importResult.errors.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
{step === "preview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxies.importDialog.errors")}</Label>
|
||||
<ScrollArea className="h-[100px] rounded-md border">
|
||||
<Label htmlFor="name-prefix">
|
||||
{t("proxies.importDialog.namePrefix")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name-prefix"
|
||||
placeholder={t("proxies.importDialog.namePrefixDefault")}
|
||||
value={namePrefix}
|
||||
onChange={(e) => {
|
||||
setNamePrefix(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxies.importDialog.namePrefixHint", {
|
||||
prefix:
|
||||
namePrefix || t("proxies.importDialog.namePrefixDefault"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("proxies.importDialog.proxiesToImport", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
{invalidProxies.length > 0 && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t("proxies.importDialog.invalidCount", {
|
||||
count: invalidProxies.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{importResult.errors.map((error, i) => (
|
||||
{parsedProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`error-${i}`}
|
||||
className="text-xs text-destructive"
|
||||
key={`${proxy.original_line}-${i}`}
|
||||
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
|
||||
>
|
||||
{error}
|
||||
<span className="text-primary">
|
||||
{proxy.proxy_type}://
|
||||
</span>
|
||||
{proxy.username && (
|
||||
<span className="text-muted-foreground">
|
||||
{proxy.username}:***@
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{proxy.host}:{proxy.port}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={parsedProxies.length === 0}
|
||||
>
|
||||
{t("proxies.importDialog.importButton", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "ambiguous" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleResolveAmbiguous}
|
||||
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
|
||||
>
|
||||
{t("proxies.importDialog.continueButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
|
||||
<div className="space-y-4 p-3">
|
||||
{ambiguousProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.line}-${i}`}
|
||||
className="space-y-2 border-b pb-3 last:border-0"
|
||||
>
|
||||
<code className="block rounded bg-muted px-2 py-1 text-xs break-all">
|
||||
{proxy.line}
|
||||
</code>
|
||||
<div className="flex flex-col gap-2">
|
||||
{proxy.possible_formats.map((format) => (
|
||||
<label
|
||||
key={format}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`format-${i}`}
|
||||
checked={proxy.selectedFormat === format}
|
||||
onChange={() => {
|
||||
handleAmbiguousFormatSelect(i, format);
|
||||
}}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs">{format}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "result" && (
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("proxies.importDialog.doneButton")}
|
||||
</RippleButton>
|
||||
{step === "result" && importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 rounded-lg bg-muted/30 p-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.imported")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-success">
|
||||
{importResult.imported_count}
|
||||
</span>
|
||||
</div>
|
||||
{importResult.skipped_count > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.skippedDuplicates")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{importResult.skipped_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.errors")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
{importResult.errors.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxies.importDialog.errors")}</Label>
|
||||
<ScrollArea className="h-[100px] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{importResult.errors.map((error, i) => (
|
||||
<div
|
||||
key={`error-${i}`}
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={parsedProxies.length === 0}
|
||||
>
|
||||
{t("proxies.importDialog.importButton", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "ambiguous" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleResolveAmbiguous}
|
||||
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
|
||||
>
|
||||
{t("proxies.importDialog.continueButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "result" && (
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("proxies.importDialog.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</StepTransition>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+51
-87
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
@@ -7,12 +8,15 @@ import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import {
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { launchDonutClone } from "@/lib/donut-physics";
|
||||
import { MOTION_SPRING_POSITION } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -31,11 +35,6 @@ export type AppPage =
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const INITIAL_HORIZONTAL_SPEED = 350;
|
||||
const SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
|
||||
|
||||
function useLogoEasterEgg({
|
||||
@@ -64,74 +63,15 @@ function useLogoEasterEgg({
|
||||
}
|
||||
});
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const cancelFallRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const triggerFall = useCallback(() => {
|
||||
const el = logoRef.current;
|
||||
if (!el || isFalling) return;
|
||||
setIsFalling(true);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.pointerEvents = "none";
|
||||
clone.style.margin = "0";
|
||||
document.body.appendChild(clone);
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = -500;
|
||||
// Roll right first, bounce off the right wall, then escape the left.
|
||||
let vx = INITIAL_HORIZONTAL_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const animate = (time: number) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
// Read live so a mid-animation window resize moves the floor/wall.
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
vy =
|
||||
Math.abs(vy) > MIN_BOUNCE_VELOCITY
|
||||
? -Math.abs(vy) * BOUNCE_DAMPING
|
||||
: -MIN_BOUNCE_VELOCITY * 3;
|
||||
}
|
||||
|
||||
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
|
||||
// damping), and keep rolling. Left wall has no bounce — the donut
|
||||
// exits the window off the left edge.
|
||||
const currentRight = startX + x + rect.width;
|
||||
if (currentRight >= rightWall && vx > 0) {
|
||||
x = rightWall - startX - rect.width;
|
||||
vx = -Math.abs(vx) * 0.9;
|
||||
}
|
||||
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
|
||||
const offScreenLeft = startX + x + rect.width < -200;
|
||||
const offScreenBottom = startY + y > floorY + 100;
|
||||
const offScreenTop = startY + y + rect.height < -200;
|
||||
|
||||
if (offScreenLeft || offScreenBottom || offScreenTop) {
|
||||
clone.remove();
|
||||
cancelFallRef.current = launchDonutClone(el, {
|
||||
onExit: () => {
|
||||
try {
|
||||
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
@@ -139,16 +79,13 @@ function useLogoEasterEgg({
|
||||
}
|
||||
setIsHidden(true);
|
||||
setIsFalling(false);
|
||||
return;
|
||||
}
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
},
|
||||
});
|
||||
}, [isFalling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
cancelFallRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -236,6 +173,19 @@ function useLogoEasterEgg({
|
||||
interface RailNavProps {
|
||||
currentPage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
/** Shared-element indicator that slides between the active rail items. */
|
||||
function ActiveIndicator() {
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
layoutId="rail-indicator"
|
||||
transition={MOTION_SPRING_POSITION}
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RailItem {
|
||||
@@ -275,7 +225,11 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
export function RailNav({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onOpenAbout,
|
||||
}: RailNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const {
|
||||
@@ -358,12 +312,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
{active && <ActiveIndicator />}
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -413,12 +362,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{currentPage === "settings" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
{currentPage === "settings" && <ActiveIndicator />}
|
||||
<GoGear className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -435,7 +379,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border bg-card p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
<div className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
||||
<button
|
||||
key={page}
|
||||
@@ -459,6 +403,26 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
onOpenAbout();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
|
||||
<LuInfo className="size-3" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t("rail.more.about")}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{t("rail.more.aboutHint")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
+753
-611
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
interface UnsyncedEntityCounts {
|
||||
@@ -33,6 +35,7 @@ interface SyncAllDialogProps {
|
||||
|
||||
export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [counts, setCounts] = useState<UnsyncedEntityCounts | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
@@ -127,33 +130,61 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
||||
<DialogDescription>{t("syncAll.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 py-2">
|
||||
{items.map(({ key, count, label, Icon }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{label}
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-2 tabular-nums"
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.15 : 0.12,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="flex justify-center py-8"
|
||||
>
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="results"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="grid grid-cols-2 gap-2 py-2"
|
||||
>
|
||||
{items.map(({ key, count, label, Icon }, index) => (
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
delay: reduceMotion ? 0 : Math.min(index * 0.03, 0.12),
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{label}
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-2 tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isEnabling}>
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
|
||||
import {
|
||||
applyThemeColors,
|
||||
clearThemeColors,
|
||||
withThemeTransition,
|
||||
} from "@/lib/themes";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
@@ -54,11 +58,13 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
|
||||
const setTheme = useCallback((newTheme: string) => {
|
||||
setThemeState(newTheme);
|
||||
if (newTheme === "custom") {
|
||||
applyClassToHtml("dark");
|
||||
} else {
|
||||
applyClassToHtml(newTheme);
|
||||
}
|
||||
withThemeTransition(() => {
|
||||
if (newTheme === "custom") {
|
||||
applyClassToHtml("dark");
|
||||
} else {
|
||||
applyClassToHtml(newTheme);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load initial theme from Tauri settings
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSearch, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -17,6 +18,13 @@ import type {
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
@@ -37,7 +46,10 @@ import {
|
||||
TooltipTrigger,
|
||||
Tooltip as UITooltip,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { FilteredTrafficStats } from "@/types";
|
||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type TimePeriod =
|
||||
| "1m"
|
||||
@@ -157,24 +169,28 @@ export function TrafficDetailsDialog({
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
|
||||
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
|
||||
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
|
||||
const [isClearing, setIsClearing] = React.useState(false);
|
||||
const [domainSearch, setDomainSearch] = React.useState("");
|
||||
|
||||
const fetchStats = React.useCallback(async () => {
|
||||
if (!profileId) return;
|
||||
try {
|
||||
const seconds = getSecondsForPeriod(timePeriod);
|
||||
const filteredStats = await invoke<FilteredTrafficStats | null>(
|
||||
"get_traffic_stats_for_period",
|
||||
{ profileId, seconds },
|
||||
);
|
||||
setStats(filteredStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch traffic stats:", error);
|
||||
}
|
||||
}, [profileId, timePeriod]);
|
||||
|
||||
// Fetch stats periodically - now uses filtered API
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profileId) return;
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const seconds = getSecondsForPeriod(timePeriod);
|
||||
const filteredStats = await invoke<FilteredTrafficStats | null>(
|
||||
"get_traffic_stats_for_period",
|
||||
{ profileId, seconds },
|
||||
);
|
||||
setStats(filteredStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch traffic stats:", error);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchStats();
|
||||
const interval = setInterval(() => {
|
||||
void fetchStats();
|
||||
@@ -185,7 +201,22 @@ export function TrafficDetailsDialog({
|
||||
// Clear stats from memory when dialog closes to free up memory
|
||||
setStats(null);
|
||||
};
|
||||
}, [isOpen, profileId, timePeriod]);
|
||||
}, [isOpen, profileId, fetchStats]);
|
||||
|
||||
const handleClearHistory = React.useCallback(async () => {
|
||||
if (!profileId) return;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await invoke("clear_profile_traffic_stats", { profileId });
|
||||
setStats(null);
|
||||
setShowClearConfirm(false);
|
||||
await fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [profileId, fetchStats, t]);
|
||||
|
||||
// Transform data for chart (already filtered by backend)
|
||||
const chartData = React.useMemo(() => {
|
||||
@@ -231,24 +262,31 @@ export function TrafficDetailsDialog({
|
||||
[t],
|
||||
);
|
||||
|
||||
// Top domains sorted by total traffic
|
||||
const topDomainsByTraffic = React.useMemo(() => {
|
||||
// Domains matching the search query (empty query = all).
|
||||
const filteredDomains = React.useMemo(() => {
|
||||
if (!stats?.domains) return [];
|
||||
return Object.values(stats.domains)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
|
||||
)
|
||||
.slice(0, 10);
|
||||
}, [stats]);
|
||||
const q = domainSearch.trim().toLowerCase();
|
||||
const all = Object.values(stats.domains);
|
||||
return q ? all.filter((d) => d.domain.toLowerCase().includes(q)) : all;
|
||||
}, [stats, domainSearch]);
|
||||
|
||||
// Top domains sorted by total traffic. When searching, show every match
|
||||
// (not just the top 10) so the queried domain is never hidden below the cut.
|
||||
const topDomainsByTraffic = React.useMemo(() => {
|
||||
const sorted = [...filteredDomains].sort(
|
||||
(a, b) =>
|
||||
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
|
||||
);
|
||||
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
|
||||
}, [filteredDomains, domainSearch]);
|
||||
|
||||
// Top domains sorted by request count
|
||||
const topDomainsByRequests = React.useMemo(() => {
|
||||
if (!stats?.domains) return [];
|
||||
return Object.values(stats.domains)
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
.slice(0, 10);
|
||||
}, [stats]);
|
||||
const sorted = [...filteredDomains].sort(
|
||||
(a, b) => b.request_count - a.request_count,
|
||||
);
|
||||
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
|
||||
}, [filteredDomains, domainSearch]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -257,364 +295,461 @@ export function TrafficDetailsDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[min(56rem,calc(100%-4rem))]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
— {profileName}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[min(56rem,calc(100%-4rem))] flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
— {profileName}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!stats}
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
<LuTrash2 className="mr-1.5 size-3.5" />
|
||||
{t("traffic.clearHistory")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">{t("traffic.last1m")}</SelectItem>
|
||||
<SelectItem value="5m">{t("traffic.last5m")}</SelectItem>
|
||||
<SelectItem value="30m">{t("traffic.last30m")}</SelectItem>
|
||||
<SelectItem value="1h">{t("traffic.last1h")}</SelectItem>
|
||||
<SelectItem value="2h">{t("traffic.last2h")}</SelectItem>
|
||||
<SelectItem value="4h">{t("traffic.last4h")}</SelectItem>
|
||||
<SelectItem value="1d">{t("traffic.last1d")}</SelectItem>
|
||||
<SelectItem value="7d">{t("traffic.last7d")}</SelectItem>
|
||||
<SelectItem value="30d">{t("traffic.last30d")}</SelectItem>
|
||||
<SelectItem value="all">{t("traffic.allTime")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<AnimatedTabs
|
||||
defaultValue="overview"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
<AnimatedTabsTrigger value="overview">
|
||||
{t("traffic.tabOverview")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="domains">
|
||||
{t("traffic.tabTopDomains")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sentGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
<AnimatedTabsContent
|
||||
value="overview"
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="h-[56vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">
|
||||
{t("traffic.last1m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="5m">
|
||||
{t("traffic.last5m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30m">
|
||||
{t("traffic.last30m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1h">
|
||||
{t("traffic.last1h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="2h">
|
||||
{t("traffic.last2h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="4h">
|
||||
{t("traffic.last4h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1d">
|
||||
{t("traffic.last1d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
{t("traffic.last7d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
{t("traffic.last30d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("traffic.allTime")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sentGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="receivedGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={(t) =>
|
||||
new Date(t * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="receivedGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.5}
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytesPerSecond(v)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={60}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.1}
|
||||
<Tooltip content={renderTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sent"
|
||||
stackId="1"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#sentGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={(t) =>
|
||||
new Date(t * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytesPerSecond(v)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={renderTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sent"
|
||||
stackId="1"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#sentGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="received"
|
||||
stackId="1"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#receivedGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="received"
|
||||
stackId="1"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#receivedGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
<div className="mt-2 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
|
||||
{/* Period Stats - now uses backend-computed values */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period Stats - now uses backend-computed values */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* No data state (overview) */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right text-chart-1">
|
||||
{formatBytes(domain.bytes_sent)}
|
||||
</span>
|
||||
<span className="text-right text-chart-2">
|
||||
{formatBytes(domain.bytes_received)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AnimatedTabsContent
|
||||
value="domains"
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="h-[56vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={domainSearch}
|
||||
onChange={(e) => setDomainSearch(e.target.value)}
|
||||
placeholder={t("traffic.searchDomains")}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Domains by Requests */}
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
{domainSearch.trim() && topDomainsByTraffic.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("traffic.noDomainMatch")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatBytes(
|
||||
domain.bytes_sent + domain.bytes_received,
|
||||
)}
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right text-chart-1">
|
||||
{formatBytes(domain.bytes_sent)}
|
||||
</span>
|
||||
<span className="text-right text-chart-2">
|
||||
{formatBytes(domain.bytes_received)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Unique IPs */}
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
{/* Top Domains by Requests */}
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatBytes(
|
||||
domain.bytes_sent + domain.bytes_received,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* No data state */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
{/* Unique IPs */}
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.uniqueIps", {
|
||||
count: stats.unique_ips.length,
|
||||
})}
|
||||
</h3>
|
||||
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data state (domains) */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showClearConfirm}
|
||||
onClose={() => setShowClearConfirm(false)}
|
||||
onConfirm={handleClearHistory}
|
||||
title={t("traffic.clearHistoryTitle")}
|
||||
description={t("traffic.clearHistoryDescription", {
|
||||
name: profileName ?? "",
|
||||
})}
|
||||
confirmButtonText={t("traffic.clearHistory")}
|
||||
isLoading={isClearing}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedDisclosureItemProps
|
||||
extends ComponentProps<typeof motion.div> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnimatedDisclosureItem({
|
||||
children,
|
||||
...props
|
||||
}: AnimatedDisclosureItemProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout={reduceMotion ? false : "position"}
|
||||
transition={reduceMotion ? { duration: 0 } : MOTION_SPRING_POSITION}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedDisclosureChevron({
|
||||
open,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
animate={{ rotate: open ? 90 : 0 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className={cn("inline-flex shrink-0", className)}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedDisclosureContent({
|
||||
open,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ function AnimatedTabsList({
|
||||
<TabsPrimitive.List
|
||||
data-slot="animated-tabs-list"
|
||||
className={cn(
|
||||
"relative inline-flex max-w-full scrollbar-none items-center gap-1 overflow-x-auto rounded-md p-0",
|
||||
"relative isolate inline-flex max-w-full scrollbar-none items-center gap-1 overflow-x-auto rounded-md p-0",
|
||||
className,
|
||||
)}
|
||||
onMouseLeave={(event) => {
|
||||
@@ -120,7 +120,7 @@ function AnimatedTabsTrigger({
|
||||
onMouseEnter?.(event);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-colors duration-150",
|
||||
"relative inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-colors duration-150",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
isActive && "text-foreground",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
|
||||
@@ -132,7 +132,7 @@ function AnimatedTabsTrigger({
|
||||
{showIndicator && (
|
||||
<motion.span
|
||||
layoutId={`animated-tabs-indicator-${indicatorId}`}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,transform] duration-[120ms] ease-[var(--ease-out)] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -28,6 +28,12 @@ const buttonVariants = cva(
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: ["default", "destructive", "outline", "secondary", "ghost"],
|
||||
className: "active:scale-[0.98] motion-reduce:active:scale-100",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
|
||||
@@ -162,8 +162,12 @@ function SubPageContent({
|
||||
<motion.div
|
||||
data-slot="sub-page"
|
||||
data-sub-page="true"
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
// Sub-pages enter with a short rise+fade so rail navigation reads as a
|
||||
// transition instead of a hard cut. Same axis for every page (spatial
|
||||
// consistency); the outgoing page unmounts under the incoming fade.
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
@@ -177,7 +181,11 @@ function SubPageContent({
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
overflow: "auto",
|
||||
// The sub-page wrapper never scrolls itself — exactly one inner
|
||||
// element per page owns scrolling (full-width, so the wheel works
|
||||
// over side gutters too). "auto" here created a competing,
|
||||
// never-engaged scroll container.
|
||||
overflow: "hidden",
|
||||
background: "var(--background)",
|
||||
containerType: "inline-size",
|
||||
}}
|
||||
@@ -258,7 +266,7 @@ function DialogContent({
|
||||
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
|
||||
// gutter even when callers override max-w-*: tailwind-merge drops
|
||||
// a base max-w in favor of the caller's, but leaves width alone.
|
||||
"fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-lg",
|
||||
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -232,7 +232,7 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-lg 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,7 +32,7 @@ function PopoverContent({
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -61,7 +61,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { Key, ReactNode } from "react";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StepTransitionProps {
|
||||
transitionKey: Key;
|
||||
direction: 1 | -1;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StepTransition({
|
||||
transitionKey,
|
||||
direction,
|
||||
children,
|
||||
className,
|
||||
}: StepTransitionProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait" custom={direction}>
|
||||
<motion.div
|
||||
key={transitionKey}
|
||||
custom={direction}
|
||||
variants={{
|
||||
enter: (customDirection: 1 | -1) => ({
|
||||
opacity: 0,
|
||||
x: reduceMotion ? 0 : customDirection * 6,
|
||||
}),
|
||||
center: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.16 : 0.18,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
},
|
||||
exit: (customDirection: 1 | -1) => ({
|
||||
opacity: 0,
|
||||
x: reduceMotion ? 0 : customDirection * -6,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.16 : 0.12,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import type { ProxyCheckResult } from "@/types";
|
||||
|
||||
interface VpnCheckButtonProps {
|
||||
@@ -30,6 +32,7 @@ export function VpnCheckButton({
|
||||
disabled = false,
|
||||
}: VpnCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
|
||||
|
||||
const handleCheck = React.useCallback(async () => {
|
||||
@@ -63,6 +66,13 @@ export function VpnCheckButton({
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId, t]);
|
||||
|
||||
const isCurrentlyChecking = checkingVpnId === vpnId;
|
||||
const statusKey = isCurrentlyChecking
|
||||
? "checking"
|
||||
: result?.is_valid
|
||||
? "valid"
|
||||
: result && !result.is_valid
|
||||
? "invalid"
|
||||
: "idle";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -74,15 +84,39 @@ export function VpnCheckButton({
|
||||
onClick={handleCheck}
|
||||
disabled={isCurrentlyChecking || disabled}
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : result?.is_valid ? (
|
||||
<FiCheck className="size-3 text-success" />
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-sm text-destructive">✕</span>
|
||||
) : (
|
||||
<FiCheck className="size-3" />
|
||||
)}
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={statusKey}
|
||||
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.9 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: reduceMotion ? 1 : 0.9,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.15 : 0.1,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}}
|
||||
className="inline-flex size-3 items-center justify-center"
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<span className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : result?.is_valid ? (
|
||||
<FiCheck className="size-3 text-success" />
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-sm text-destructive">✕</span>
|
||||
) : (
|
||||
<FiCheck className="size-3" />
|
||||
)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuShield, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { StepTransition } from "@/components/ui/step-transition";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import type { VpnImportResult, VpnType } from "@/types";
|
||||
|
||||
@@ -29,6 +30,24 @@ interface VpnImportDialogProps {
|
||||
|
||||
type ImportStep = "dropzone" | "vpn-preview" | "vpn-result";
|
||||
|
||||
const STEP_ORDER: Record<ImportStep, number> = {
|
||||
dropzone: 0,
|
||||
"vpn-preview": 1,
|
||||
"vpn-result": 2,
|
||||
};
|
||||
|
||||
interface StepState {
|
||||
step: ImportStep;
|
||||
direction: 1 | -1;
|
||||
}
|
||||
|
||||
function transitionStep(current: StepState, next: ImportStep): StepState {
|
||||
return {
|
||||
step: next,
|
||||
direction: STEP_ORDER[next] >= STEP_ORDER[current.step] ? 1 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
interface VpnPreviewData {
|
||||
content: string;
|
||||
filename: string;
|
||||
@@ -58,14 +77,16 @@ const detectVpnType = (
|
||||
|
||||
export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [{ step, direction: stepDirection }, setStep] = useReducer(
|
||||
transitionStep,
|
||||
{ step: "dropzone", direction: 1 },
|
||||
);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
const [vpnName, setVpnName] = useState("");
|
||||
const [vpnImportResult, setVpnImportResult] =
|
||||
useState<VpnImportResult | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
|
||||
@@ -190,159 +211,179 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vpns.import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" && t("vpns.import.descDropzone")}
|
||||
{step === "vpn-preview" && t("vpns.import.descPreview")}
|
||||
{step === "vpn-result" && t("vpns.import.descResult")}
|
||||
</DialogDescription>
|
||||
<StepTransition
|
||||
transitionKey={`description-${step}`}
|
||||
direction={stepDirection}
|
||||
>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" && t("vpns.import.descDropzone")}
|
||||
{step === "vpn-preview" && t("vpns.import.descPreview")}
|
||||
{step === "vpn-result" && t("vpns.import.descResult")}
|
||||
</DialogDescription>
|
||||
</StepTransition>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "dropzone" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
flex flex-col items-center justify-center
|
||||
border-2 border-dashed rounded-lg p-8
|
||||
transition-colors cursor-pointer
|
||||
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
|
||||
`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => document.getElementById("vpn-file-input")?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("vpn-file-input")?.click();
|
||||
<StepTransition
|
||||
transitionKey={step}
|
||||
direction={stepDirection}
|
||||
className="grid gap-4"
|
||||
>
|
||||
{step === "dropzone" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
flex flex-col items-center justify-center
|
||||
border-2 border-dashed rounded-lg p-8
|
||||
cursor-pointer transition-[color,background-color,border-color,transform]
|
||||
duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none
|
||||
${isDragOver ? "scale-[1.005] border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
|
||||
`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() =>
|
||||
document.getElementById("vpn-file-input")?.click()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="mb-4 size-10 text-muted-foreground" />
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("vpns.import.dropzonePrompt")}
|
||||
</p>
|
||||
<input
|
||||
id="vpn-file-input"
|
||||
type="file"
|
||||
accept=".conf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("vpn-file-input")?.click();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<LuUpload
|
||||
className={`mb-4 size-10 text-muted-foreground transition-transform duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none ${
|
||||
isDragOver ? "-translate-y-0.5 scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("vpns.import.dropzonePrompt")}
|
||||
</p>
|
||||
<input
|
||||
id="vpn-file-input"
|
||||
type="file"
|
||||
accept=".conf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("vpns.import.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("vpns.import.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && vpnPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
|
||||
<LuShield className="size-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t("vpns.import.configurationLabel", {
|
||||
type: vpnPreview.detectedType,
|
||||
})}
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.import.endpointLabel", {
|
||||
endpoint: vpnPreview.endpoint,
|
||||
{step === "vpn-preview" && vpnPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
|
||||
<LuShield className="size-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t("vpns.import.configurationLabel", {
|
||||
type: vpnPreview.detectedType,
|
||||
})}
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.import.endpointLabel", {
|
||||
endpoint: vpnPreview.endpoint,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">
|
||||
{t("vpns.import.vpnNameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder={t("vpns.import.vpnNamePlaceholder")}
|
||||
value={vpnName}
|
||||
onChange={(e) => {
|
||||
setVpnName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("vpns.import.configPreview")}</Label>
|
||||
<ScrollArea className="h-[min(150px,25vh)] rounded-md border">
|
||||
<pre className="p-2 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
{vpnPreview.content.length > 1000 && "..."}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && vpnImportResult && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-success/10" : "bg-destructive/10"}`}
|
||||
>
|
||||
{vpnImportResult.success ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<LuShield className="size-8 text-success" />
|
||||
<div>
|
||||
<div className="font-medium text-success">
|
||||
{t("vpns.import.importedSuccess")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-destructive">
|
||||
{t("vpns.import.importFailed")}
|
||||
</div>
|
||||
<div className="text-sm text-destructive">
|
||||
{vpnImportResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">{t("vpns.import.vpnNameLabel")}</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder={t("vpns.import.vpnNamePlaceholder")}
|
||||
value={vpnName}
|
||||
onChange={(e) => {
|
||||
setVpnName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("vpns.import.configPreview")}</Label>
|
||||
<ScrollArea className="h-[min(150px,25vh)] rounded-md border">
|
||||
<pre className="p-2 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
{vpnPreview.content.length > 1000 && "..."}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && vpnImportResult && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-success/10" : "bg-destructive/10"}`}
|
||||
>
|
||||
{vpnImportResult.success ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<LuShield className="size-8 text-success" />
|
||||
<div>
|
||||
<div className="font-medium text-success">
|
||||
{t("vpns.import.importedSuccess")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-destructive">
|
||||
{t("vpns.import.importFailed")}
|
||||
</div>
|
||||
<div className="text-sm text-destructive">
|
||||
{vpnImportResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
{t("common.buttons.back")}
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t("vpns.import.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("vpns.import.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t("vpns.import.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("vpns.import.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</StepTransition>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AppUpdateToast } from "@/components/app-update-toast";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showToast } from "@/lib/toast-utils";
|
||||
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
||||
|
||||
@@ -82,11 +83,16 @@ export function useAppUpdateNotifications() {
|
||||
showToast({
|
||||
type: "error",
|
||||
title: t("appUpdate.toast.updateFailed"),
|
||||
description: String(error),
|
||||
description: translateBackendError(t, error),
|
||||
duration: 6000,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setUpdateProgress(null);
|
||||
// Deliberately NOT resetting autoDownloadedVersion here: the
|
||||
// auto-download effect re-runs as soon as isUpdating flips back to
|
||||
// false, so clearing the marker now would retry in a tight loop.
|
||||
// Retries are re-armed when the next backend check delivers a fresh
|
||||
// update event instead.
|
||||
}
|
||||
},
|
||||
[t],
|
||||
@@ -127,6 +133,12 @@ export function useAppUpdateNotifications() {
|
||||
"app-update-available",
|
||||
(event) => {
|
||||
console.log("App update available:", event.payload);
|
||||
// A fresh backend check re-arms auto-download, so a version whose
|
||||
// earlier attempt failed (e.g. a transient checksum-fetch error) is
|
||||
// retried once per periodic check instead of staying blocked until
|
||||
// restart. The effect's updateReady guard keeps an already-prepared
|
||||
// update from being downloaded again.
|
||||
autoDownloadedVersion.current = null;
|
||||
setUpdateInfo(event.payload);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -354,7 +354,14 @@ export function useBrowserDownload() {
|
||||
}
|
||||
} else if (progress.stage === "extracting") {
|
||||
if (!isOnboardingActive()) {
|
||||
showDownloadToast(browserName, progress.version, "extracting");
|
||||
showDownloadToast(
|
||||
browserName,
|
||||
progress.version,
|
||||
"extracting",
|
||||
progress.percentage > 0
|
||||
? { percentage: progress.percentage }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
} else if (progress.stage === "verifying") {
|
||||
if (!isOnboardingActive()) {
|
||||
|
||||
@@ -27,10 +27,11 @@ export interface SetupError {
|
||||
stage: SetupErrorStage;
|
||||
}
|
||||
|
||||
// The backend emits a real percentage only while downloading; extraction sends
|
||||
// a single "extracting" event with no incremental progress (it takes ~2 min).
|
||||
// So we estimate extraction progress from elapsed time vs. a learned average,
|
||||
// seeded at 2 minutes and refined with the real durations we record.
|
||||
// The backend reports real extraction percentages for most archive formats
|
||||
// (zip, tar.*, dmg). For formats that can't measure progress (e.g. MSI) the
|
||||
// "extracting" events carry percentage 0, so we fall back to estimating from
|
||||
// elapsed time vs. a learned average, seeded at 2 minutes and refined with
|
||||
// the real durations we record.
|
||||
const DEFAULT_EXTRACT_MS = 2 * 60 * 1000;
|
||||
const MAX_SAMPLES = 5; // the 2-min seed + up to 4 most recent real durations
|
||||
|
||||
@@ -89,8 +90,9 @@ function toErrorStage(stage: string): SetupErrorStage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks first-launch setup of a browser: real download progress plus an
|
||||
* estimated extraction progress (no countdown timer, percentages only).
|
||||
* Tracks first-launch setup of a browser: real download progress plus
|
||||
* extraction progress — real backend percentages when the archive format
|
||||
* supports them, otherwise a time-based estimate (percentages only).
|
||||
* `active` should be true while the owning dialog is open.
|
||||
*/
|
||||
export function useBrowserSetup(browser: string, active: boolean) {
|
||||
@@ -108,8 +110,12 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
|
||||
const extractStartRef = useRef<number | null>(null);
|
||||
const estimateRef = useRef(DEFAULT_EXTRACT_MS);
|
||||
// Fallback bookkeeping so a listener that mounts mid-flight (and therefore
|
||||
// misses the single "extracting" event) can still show extraction progress.
|
||||
// True once an "extracting" event carried a real percentage — from then on
|
||||
// the backend drives the bar and the time-based estimate stays out of it.
|
||||
const sawRealExtractionRef = useRef(false);
|
||||
// Fallback bookkeeping so a listener that mounts mid-flight, or that only
|
||||
// ever receives percentage-0 "extracting" events (formats that can't
|
||||
// measure progress), can still show extraction progress.
|
||||
const sawDownloadingRef = useRef(false);
|
||||
const lastProgressAtRef = useRef<number | null>(null);
|
||||
const lastDownloadPercentRef = useRef(0);
|
||||
@@ -133,6 +139,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
setExtractionOvertime(false);
|
||||
setError(null);
|
||||
extractStartRef.current = null;
|
||||
sawRealExtractionRef.current = false;
|
||||
sawDownloadingRef.current = false;
|
||||
lastProgressAtRef.current = null;
|
||||
lastDownloadPercentRef.current = 0;
|
||||
@@ -143,6 +150,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
let alive = true;
|
||||
estimateRef.current = average(readDurations(browser));
|
||||
extractStartRef.current = null;
|
||||
sawRealExtractionRef.current = false;
|
||||
sawDownloadingRef.current = false;
|
||||
lastProgressAtRef.current = null;
|
||||
lastDownloadPercentRef.current = 0;
|
||||
@@ -182,6 +190,11 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
}
|
||||
lastProgressAtRef.current = Date.now();
|
||||
setPhase("extracting");
|
||||
if (p.percentage > 0) {
|
||||
sawRealExtractionRef.current = true;
|
||||
setExtractionPercent(Math.min(99, Math.round(p.percentage)));
|
||||
setExtractionOvertime(false);
|
||||
}
|
||||
break;
|
||||
case "verifying":
|
||||
lastStageRef.current = "verifying";
|
||||
@@ -257,9 +270,9 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
// Drive the estimated extraction percentage while extracting.
|
||||
const tick = setInterval(() => {
|
||||
if (!alive || doneRef.current) return;
|
||||
// If the download visibly finished but we never saw the (single)
|
||||
// "extracting" event, start estimating extraction anyway — anchored to
|
||||
// the last download event, which is roughly when extraction began.
|
||||
// If the download visibly finished but we never saw any "extracting"
|
||||
// event, start estimating extraction anyway — anchored to the last
|
||||
// download event, which is roughly when extraction began.
|
||||
if (
|
||||
extractStartRef.current == null &&
|
||||
sawDownloadingRef.current &&
|
||||
@@ -272,6 +285,9 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
setPhase("extracting");
|
||||
}
|
||||
if (extractStartRef.current == null) return;
|
||||
// Real backend percentages drive the bar; the estimate would only
|
||||
// fight them (and flag bogus "overtime" on a healthy extraction).
|
||||
if (sawRealExtractionRef.current) return;
|
||||
const elapsed = Date.now() - extractStartRef.current;
|
||||
const est = estimateRef.current || DEFAULT_EXTRACT_MS;
|
||||
if (elapsed >= est) {
|
||||
@@ -310,6 +326,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
setExtractionOvertime(false);
|
||||
setError(null);
|
||||
extractStartRef.current = null;
|
||||
sawRealExtractionRef.current = false;
|
||||
sawDownloadingRef.current = false;
|
||||
lastProgressAtRef.current = null;
|
||||
lastDownloadPercentRef.current = 0;
|
||||
|
||||
@@ -8,6 +8,7 @@ import ja from "./locales/ja.json";
|
||||
import ko from "./locales/ko.json";
|
||||
import pt from "./locales/pt.json";
|
||||
import ru from "./locales/ru.json";
|
||||
import tr from "./locales/tr.json";
|
||||
import vi from "./locales/vi.json";
|
||||
import zh from "./locales/zh.json";
|
||||
|
||||
@@ -20,6 +21,7 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
||||
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
||||
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
||||
{ code: "tr", name: "Turkish", nativeName: "Türkçe" },
|
||||
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
|
||||
] as const;
|
||||
|
||||
@@ -68,6 +70,7 @@ const resources = {
|
||||
ja: { translation: ja },
|
||||
ko: { translation: ko },
|
||||
ru: { translation: ru },
|
||||
tr: { translation: tr },
|
||||
vi: { translation: vi },
|
||||
};
|
||||
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Disable App Auto Updates",
|
||||
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
|
||||
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless."
|
||||
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Fingerprint consistency warning",
|
||||
"consistencyWarningDescription": "Warn on launch when a profile's timezone or language doesn't match its proxy exit node.",
|
||||
"clearTraffic": "Clear all traffic history",
|
||||
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
|
||||
"clearTrafficSuccess": "Traffic history cleared"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Search profiles...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Can't modify running profile",
|
||||
"cantModifyLaunching": "Can't modify profile while launching",
|
||||
"cantModifyStopping": "Can't modify profile while stopping",
|
||||
"cantModifyUpdating": "Can't modify profile while browser is updating"
|
||||
"cantModifyUpdating": "Can't modify profile while browser is updating",
|
||||
"emptyTitle": "No profiles yet",
|
||||
"emptyHint": "Create your first profile or import existing ones from another browser.",
|
||||
"emptyCreate": "Create profile",
|
||||
"emptyImport": "Import profiles",
|
||||
"emptyFilteredTitle": "No profiles found",
|
||||
"emptyFilteredHint": "No profiles match this group or search. Try another filter or create a new one."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Launch",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "domains",
|
||||
"fresh": "Fresh",
|
||||
"stale": "Stale",
|
||||
"notCached": "Not cached"
|
||||
"notCached": "Not cached",
|
||||
"tabBlocklists": "Blocklists",
|
||||
"tabCustom": "Custom lists",
|
||||
"custom.description": "Build your own blocklist from source URLs and manual rules. Allowed domains always override blocked ones.",
|
||||
"custom.sourcesLabel": "Blocklist source URLs",
|
||||
"custom.sourcesPlaceholder": "One URL per line",
|
||||
"custom.blockLabel": "Blocked domains",
|
||||
"custom.blockPlaceholder": "One domain per line",
|
||||
"custom.allowLabel": "Allowed domains",
|
||||
"custom.allowPlaceholder": "One domain per line",
|
||||
"custom.allowHint": "Allowed domains are removed from the compiled blocklist, overriding any block rule.",
|
||||
"custom.saved": "Custom DNS rules saved",
|
||||
"custom.imported": "Custom DNS rules imported",
|
||||
"custom.exported": "Custom DNS rules exported",
|
||||
"custom.exportTxt": "Export TXT",
|
||||
"custom.exportJson": "Export JSON",
|
||||
"customLevel": "Custom",
|
||||
"custom.allowlistModeLabel": "Allowlist mode",
|
||||
"custom.allowlistModeOn": "Only the domains below are reachable; everything else is blocked.",
|
||||
"custom.allowlistModeOff": "Block listed domains; allow everything else.",
|
||||
"custom.allowedOnlyLabel": "Allowed domains (only these)",
|
||||
"custom.allowedOnlyHint": "Subdomains of a listed domain are allowed too. An empty list disables filtering."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "Scanning for browser profiles...",
|
||||
"noneFound": "No browser profiles found on your system.",
|
||||
"noneFoundHint": "Try the manual import option if you have profiles in custom locations.",
|
||||
"selectProfile": "Select Profile:",
|
||||
"selectProfilePlaceholder": "Choose a detected profile",
|
||||
"pathLabel": "Path:",
|
||||
"browserLabel": "Browser:",
|
||||
"newProfileName": "New Profile Name:",
|
||||
"newProfileNamePlaceholder": "Enter a name for the imported profile",
|
||||
"manualTitle": "Manual Profile Import",
|
||||
"browserType": "Browser Type:",
|
||||
"loadingBrowsers": "Loading browsers...",
|
||||
"selectBrowserType": "Select browser type",
|
||||
"profileFolderPath": "Profile Folder Path:",
|
||||
"profileFolderPlaceholder": "Enter the full path to the profile folder",
|
||||
"browseFolderTitle": "Browse for folder",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "Select Browser Profile Folder",
|
||||
"folderDialogFailed": "Failed to open folder dialog",
|
||||
"detectFailed": "Failed to detect existing browser profiles",
|
||||
"fillFields": "Please fill in all fields",
|
||||
"selectAndName": "Please select a profile and provide a name",
|
||||
"profileNotFound": "Selected profile not found",
|
||||
"importedSuccess": "Successfully imported profile \"{{name}}\"",
|
||||
"notInstalled": "{{browser}} is not installed. Please download {{browser}} first from the main window, then try importing again.",
|
||||
"importFailed": "Failed to import profile: {{error}}",
|
||||
"proxyOptional": "Proxy (Optional)",
|
||||
"noProxy": "No proxy",
|
||||
"nextButton": "Next",
|
||||
"importButton": "Import",
|
||||
"importedAs": "This profile will be imported as a {{browser}} profile."
|
||||
"importedAs": "This profile will be imported as a {{browser}} profile.",
|
||||
"selectAll": "Select all",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"scanButton": "Scan",
|
||||
"manualHint": "Choose a profile folder, a browser user-data folder, a folder of exported profiles, or a ZIP archive.",
|
||||
"selectArchiveTitle": "Select ZIP archive",
|
||||
"noProfilesInLocation": "No profiles found in this location.",
|
||||
"selectAtLeastOne": "Select at least one profile to import",
|
||||
"emptyNames": "Every selected profile needs a name",
|
||||
"profilesToImport": "Profiles to import",
|
||||
"groupOptional": "Group (Optional)",
|
||||
"noGroup": "No group",
|
||||
"createNewGroup": "Create new group…",
|
||||
"newGroupNamePlaceholder": "New group name",
|
||||
"duplicateStrategyLabel": "If a profile name already exists",
|
||||
"duplicateRename": "Rename automatically",
|
||||
"duplicateSkip": "Skip",
|
||||
"proxyRoundRobin": "Distribute stored proxies (round-robin)",
|
||||
"importingTitle": "Importing profiles…",
|
||||
"importProgress": "{{completed}} of {{total}} processed",
|
||||
"resultsSummary": "{{imported}} imported, {{skipped}} skipped, {{failed}} failed",
|
||||
"statusImported": "Imported",
|
||||
"statusSkipped": "Skipped",
|
||||
"statusFailed": "Failed",
|
||||
"importButtonCount": "Import ({{count}})",
|
||||
"vpnOptional": "VPN (Optional)",
|
||||
"noVpn": "No VPN",
|
||||
"advancedOptions": "Advanced options",
|
||||
"configureFingerprint": "Configure fingerprint (optional)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Syncing...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "Sent",
|
||||
"receivedLegend": "Received",
|
||||
"tooltipSent": "↑ Sent: ",
|
||||
"tooltipReceived": "↓ Received: "
|
||||
"tooltipReceived": "↓ Received: ",
|
||||
"clearHistory": "Clear history",
|
||||
"clearHistoryTitle": "Clear traffic history",
|
||||
"clearHistoryDescription": "Permanently and securely erase all recorded traffic history for \"{{name}}\". This can't be undone.",
|
||||
"tabOverview": "Overview",
|
||||
"tabTopDomains": "Top domains",
|
||||
"searchDomains": "Search domains…",
|
||||
"noDomainMatch": "No domains match your search."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Unknown",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
|
||||
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
|
||||
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created.",
|
||||
"camoufoxImportDeprecated": "Importing Firefox-based profiles is no longer supported. Please use Wayfern instead."
|
||||
"camoufoxImportDeprecated": "Importing this profile type is no longer supported. Please use Wayfern instead.",
|
||||
"updateChecksumsUnavailable": "The update {{version}} could not be verified because its checksum file could not be retrieved. The update was not installed; it will be retried later.",
|
||||
"updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again.",
|
||||
"nameCannotBeEmpty": "Name cannot be empty",
|
||||
"wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}.",
|
||||
"profileNameExists": "A profile named \"{{name}}\" already exists",
|
||||
"importSourceNotFound": "The source path does not exist",
|
||||
"importNoItems": "Nothing selected to import",
|
||||
"browserNotDownloaded": "No downloaded version of {{browser}} is available. Download it first, then retry the import.",
|
||||
"archiveExtractionFailed": "Failed to extract the archive: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Unsupported archive format. Only ZIP archives are supported.",
|
||||
"clearOnCloseUnavailable": "Clear-on-close isn't available for ephemeral or password-protected profiles.",
|
||||
"proxyAndVpnMutuallyExclusive": "A profile can use either a proxy or a VPN, not both.",
|
||||
"invalidDnsRulesJson": "The selected file isn't valid DNS rules JSON.",
|
||||
"unsupportedDnsRulesFormat": "Unsupported rules format: {{format}}",
|
||||
"dnsRulesSaveFailed": "Failed to save the DNS rules.",
|
||||
"dnsRulesExportFailed": "Failed to export the DNS rules.",
|
||||
"fingerprintMatchFailed": "Couldn't match the fingerprint to the proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "Import profile",
|
||||
"importProfileHint": "Bring profiles from another tool",
|
||||
"keyboardShortcuts": "Keyboard shortcuts",
|
||||
"keyboardShortcutsHint": "View all shortcuts"
|
||||
"keyboardShortcutsHint": "View all shortcuts",
|
||||
"about": "About Donut Browser",
|
||||
"aboutHint": "Version and app information"
|
||||
},
|
||||
"network": "Network",
|
||||
"integrations": "Integrations",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Launch {{name}}",
|
||||
"stopProfile": "Stop {{name}}",
|
||||
"profileInfo": "Info — {{name}}"
|
||||
"profileInfo": "Info — {{name}}",
|
||||
"createProfile": "Create profile",
|
||||
"about": "About Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "No other Wayfern profiles selected"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"version": "Version {{version}}",
|
||||
"portableBadge": "portable",
|
||||
"licenseNotice": "Open-source anti-detect browser, licensed under AGPL-3.0.",
|
||||
"website": "Website"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Clear data on close",
|
||||
"description": "Wipe cookies, history and cache when the browser closes. Extensions and bookmarks are kept."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Fingerprint mismatch",
|
||||
"intro": "Your proxy exit for \"{{name}}\" doesn't match this profile's fingerprint:",
|
||||
"timezoneTitle": "Timezone mismatch",
|
||||
"timezoneDetail": "Exit node is in {{exit}} but the fingerprint reports {{fingerprint}}.",
|
||||
"languageTitle": "Language mismatch",
|
||||
"languageDetail": "Exit country is {{country}} but the fingerprint language is {{fingerprint}}.",
|
||||
"explainer": "A timezone or language that disagrees with your exit IP is a strong anti-bot signal, even though your real device never leaks. Align the fingerprint with the proxy location to reduce hostile treatment.",
|
||||
"dontWarnAgain": "Don't warn again for this profile",
|
||||
"matchToProxy": "Match fingerprint to proxy",
|
||||
"matching": "Matching…",
|
||||
"matchSuccess": "Fingerprint updated to match the proxy. Relaunch the profile to apply."
|
||||
}
|
||||
}
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
|
||||
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.",
|
||||
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso."
|
||||
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Advertencia de consistencia de huella digital",
|
||||
"consistencyWarningDescription": "Advertir al iniciar cuando la zona horaria o el idioma de un perfil no coincidan con su nodo de salida del proxy.",
|
||||
"clearTraffic": "Borrar todo el historial de tráfico",
|
||||
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
|
||||
"clearTrafficSuccess": "Historial de tráfico borrado"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Buscar perfiles...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "No se puede modificar un perfil en ejecución",
|
||||
"cantModifyLaunching": "No se puede modificar el perfil mientras se inicia",
|
||||
"cantModifyStopping": "No se puede modificar el perfil mientras se detiene",
|
||||
"cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador"
|
||||
"cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador",
|
||||
"emptyTitle": "Aún no hay perfiles",
|
||||
"emptyHint": "Crea tu primer perfil o importa perfiles existentes desde otro navegador.",
|
||||
"emptyCreate": "Crear perfil",
|
||||
"emptyImport": "Importar perfiles",
|
||||
"emptyFilteredTitle": "No se encontraron perfiles",
|
||||
"emptyFilteredHint": "Ningún perfil coincide con este grupo o búsqueda. Prueba otro filtro o crea uno nuevo."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "dominios",
|
||||
"fresh": "Actualizado",
|
||||
"stale": "Desactualizado",
|
||||
"notCached": "Sin caché"
|
||||
"notCached": "Sin caché",
|
||||
"tabBlocklists": "Listas de bloqueo",
|
||||
"tabCustom": "Listas personalizadas",
|
||||
"custom.description": "Crea tu propia lista de bloqueo a partir de URLs de origen y reglas manuales. Los dominios permitidos siempre prevalecen sobre los bloqueados.",
|
||||
"custom.sourcesLabel": "URLs de origen de listas de bloqueo",
|
||||
"custom.sourcesPlaceholder": "Una URL por línea",
|
||||
"custom.blockLabel": "Dominios bloqueados",
|
||||
"custom.blockPlaceholder": "Un dominio por línea",
|
||||
"custom.allowLabel": "Dominios permitidos",
|
||||
"custom.allowPlaceholder": "Un dominio por línea",
|
||||
"custom.allowHint": "Los dominios permitidos se eliminan de la lista de bloqueo compilada, anulando cualquier regla de bloqueo.",
|
||||
"custom.saved": "Reglas DNS personalizadas guardadas",
|
||||
"custom.imported": "Reglas DNS personalizadas importadas",
|
||||
"custom.exported": "Reglas DNS personalizadas exportadas",
|
||||
"custom.exportTxt": "Exportar TXT",
|
||||
"custom.exportJson": "Exportar JSON",
|
||||
"customLevel": "Personalizada",
|
||||
"custom.allowlistModeLabel": "Modo lista de permitidos",
|
||||
"custom.allowlistModeOn": "Solo se puede acceder a los dominios de abajo; todo lo demás se bloquea.",
|
||||
"custom.allowlistModeOff": "Bloquear los dominios de la lista; permitir todo lo demás.",
|
||||
"custom.allowedOnlyLabel": "Dominios permitidos (solo estos)",
|
||||
"custom.allowedOnlyHint": "También se permiten los subdominios de un dominio de la lista. Una lista vacía desactiva el filtrado."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "Buscando perfiles de navegador...",
|
||||
"noneFound": "No se encontraron perfiles de navegador en tu sistema.",
|
||||
"noneFoundHint": "Prueba la importación manual si tienes perfiles en ubicaciones personalizadas.",
|
||||
"selectProfile": "Seleccionar perfil:",
|
||||
"selectProfilePlaceholder": "Elige un perfil detectado",
|
||||
"pathLabel": "Ruta:",
|
||||
"browserLabel": "Navegador:",
|
||||
"newProfileName": "Nombre del nuevo perfil:",
|
||||
"newProfileNamePlaceholder": "Introduce un nombre para el perfil importado",
|
||||
"manualTitle": "Importación manual de perfil",
|
||||
"browserType": "Tipo de navegador:",
|
||||
"loadingBrowsers": "Cargando navegadores...",
|
||||
"selectBrowserType": "Selecciona el tipo de navegador",
|
||||
"profileFolderPath": "Ruta de la carpeta del perfil:",
|
||||
"profileFolderPlaceholder": "Introduce la ruta completa a la carpeta del perfil",
|
||||
"browseFolderTitle": "Buscar carpeta",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "Seleccionar carpeta de perfil",
|
||||
"folderDialogFailed": "Error al abrir el diálogo de carpeta",
|
||||
"detectFailed": "Error al detectar los perfiles de navegador existentes",
|
||||
"fillFields": "Por favor, completa todos los campos",
|
||||
"selectAndName": "Selecciona un perfil y proporciona un nombre",
|
||||
"profileNotFound": "Perfil seleccionado no encontrado",
|
||||
"importedSuccess": "Perfil \"{{name}}\" importado correctamente",
|
||||
"notInstalled": "{{browser}} no está instalado. Por favor descarga {{browser}} primero desde la ventana principal y luego intenta importar de nuevo.",
|
||||
"importFailed": "Error al importar el perfil: {{error}}",
|
||||
"proxyOptional": "Proxy (Opcional)",
|
||||
"noProxy": "Sin proxy",
|
||||
"nextButton": "Siguiente",
|
||||
"importButton": "Importar",
|
||||
"importedAs": "Este perfil se importará como un perfil de {{browser}}."
|
||||
"importedAs": "Este perfil se importará como un perfil de {{browser}}.",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"selectedCount": "{{count}} seleccionados",
|
||||
"scanButton": "Escanear",
|
||||
"manualHint": "Elige una carpeta de perfil, una carpeta de datos de usuario del navegador, una carpeta con perfiles exportados o un archivo ZIP.",
|
||||
"selectArchiveTitle": "Seleccionar archivo ZIP",
|
||||
"noProfilesInLocation": "No se encontraron perfiles en esta ubicación.",
|
||||
"selectAtLeastOne": "Selecciona al menos un perfil para importar",
|
||||
"emptyNames": "Cada perfil seleccionado necesita un nombre",
|
||||
"profilesToImport": "Perfiles a importar",
|
||||
"groupOptional": "Grupo (opcional)",
|
||||
"noGroup": "Sin grupo",
|
||||
"createNewGroup": "Crear nuevo grupo…",
|
||||
"newGroupNamePlaceholder": "Nombre del nuevo grupo",
|
||||
"duplicateStrategyLabel": "Si el nombre del perfil ya existe",
|
||||
"duplicateRename": "Renombrar automáticamente",
|
||||
"duplicateSkip": "Omitir",
|
||||
"proxyRoundRobin": "Distribuir proxies guardados (round-robin)",
|
||||
"importingTitle": "Importando perfiles…",
|
||||
"importProgress": "{{completed}} de {{total}} procesados",
|
||||
"resultsSummary": "{{imported}} importados, {{skipped}} omitidos, {{failed}} fallidos",
|
||||
"statusImported": "Importado",
|
||||
"statusSkipped": "Omitido",
|
||||
"statusFailed": "Fallido",
|
||||
"importButtonCount": "Importar ({{count}})",
|
||||
"vpnOptional": "VPN (opcional)",
|
||||
"noVpn": "Sin VPN",
|
||||
"advancedOptions": "Opciones avanzadas",
|
||||
"configureFingerprint": "Configurar huella digital (opcional)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "Enviado",
|
||||
"receivedLegend": "Recibido",
|
||||
"tooltipSent": "↑ Enviado: ",
|
||||
"tooltipReceived": "↓ Recibido: "
|
||||
"tooltipReceived": "↓ Recibido: ",
|
||||
"clearHistory": "Borrar historial",
|
||||
"clearHistoryTitle": "Borrar historial de tráfico",
|
||||
"clearHistoryDescription": "Elimina de forma permanente y segura todo el historial de tráfico registrado de \"{{name}}\". Esta acción no se puede deshacer.",
|
||||
"tabOverview": "Resumen",
|
||||
"tabTopDomains": "Dominios principales",
|
||||
"searchDomains": "Buscar dominios…",
|
||||
"noDomainMatch": "Ningún dominio coincide con tu búsqueda."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Desconocido",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
|
||||
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
|
||||
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil.",
|
||||
"camoufoxImportDeprecated": "La importación de perfiles basados en Firefox ya no está soportada. Utilice Wayfern en su lugar."
|
||||
"camoufoxImportDeprecated": "La importación de este tipo de perfil ya no es compatible. Utiliza Wayfern en su lugar.",
|
||||
"updateChecksumsUnavailable": "No se pudo verificar la actualización {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La actualización no se instaló; se reintentará más tarde.",
|
||||
"updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.",
|
||||
"nameCannotBeEmpty": "El nombre no puede estar vacío",
|
||||
"wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}.",
|
||||
"profileNameExists": "Ya existe un perfil llamado \"{{name}}\"",
|
||||
"importSourceNotFound": "La ruta de origen no existe",
|
||||
"importNoItems": "No hay nada seleccionado para importar",
|
||||
"browserNotDownloaded": "No hay ninguna versión descargada de {{browser}}. Descárgala primero y vuelve a intentar la importación.",
|
||||
"archiveExtractionFailed": "No se pudo extraer el archivo: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Formato de archivo no compatible. Solo se admiten archivos ZIP.",
|
||||
"clearOnCloseUnavailable": "El borrado al cerrar no está disponible para perfiles efímeros o protegidos con contraseña.",
|
||||
"proxyAndVpnMutuallyExclusive": "Un perfil puede usar un proxy o una VPN, pero no ambos.",
|
||||
"invalidDnsRulesJson": "El archivo seleccionado no es un JSON de reglas DNS válido.",
|
||||
"unsupportedDnsRulesFormat": "Formato de reglas no compatible: {{format}}",
|
||||
"dnsRulesSaveFailed": "No se pudieron guardar las reglas DNS.",
|
||||
"dnsRulesExportFailed": "No se pudieron exportar las reglas DNS.",
|
||||
"fingerprintMatchFailed": "No se pudo ajustar la huella al proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trae perfiles de otra herramienta",
|
||||
"keyboardShortcuts": "Atajos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos los atajos"
|
||||
"keyboardShortcutsHint": "Ver todos los atajos",
|
||||
"about": "Acerca de Donut Browser",
|
||||
"aboutHint": "Versión e información de la aplicación"
|
||||
},
|
||||
"network": "Red",
|
||||
"integrations": "Integraciones",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Detener {{name}}",
|
||||
"profileInfo": "Información — {{name}}"
|
||||
"profileInfo": "Información — {{name}}",
|
||||
"createProfile": "Crear perfil",
|
||||
"about": "Acerca de Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "No hay otros perfiles Wayfern seleccionados"
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de",
|
||||
"version": "Versión {{version}}",
|
||||
"portableBadge": "portable",
|
||||
"licenseNotice": "Navegador anti-detección de código abierto, con licencia AGPL-3.0.",
|
||||
"website": "Sitio web"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Borrar datos al cerrar",
|
||||
"description": "Elimina cookies, historial y caché al cerrar el navegador. Las extensiones y los marcadores se conservan."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Discrepancia de huella digital",
|
||||
"intro": "La salida del proxy de \"{{name}}\" no coincide con la huella digital de este perfil:",
|
||||
"timezoneTitle": "Discrepancia de zona horaria",
|
||||
"timezoneDetail": "El nodo de salida está en {{exit}}, pero la huella digital indica {{fingerprint}}.",
|
||||
"languageTitle": "Discrepancia de idioma",
|
||||
"languageDetail": "El país de salida es {{country}}, pero el idioma de la huella digital es {{fingerprint}}.",
|
||||
"explainer": "Una zona horaria o un idioma que no coincide con tu IP de salida es una fuerte señal anti-bot, aunque tu dispositivo real nunca se filtre. Alinea la huella digital con la ubicación del proxy para reducir el trato hostil.",
|
||||
"dontWarnAgain": "No volver a advertir para este perfil",
|
||||
"matchToProxy": "Ajustar huella al proxy",
|
||||
"matching": "Ajustando…",
|
||||
"matchSuccess": "Huella actualizada para coincidir con el proxy. Reinicia el perfil para aplicar."
|
||||
}
|
||||
}
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
|
||||
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.",
|
||||
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas."
|
||||
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Avertissement de cohérence d'empreinte",
|
||||
"consistencyWarningDescription": "Avertir au lancement lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie proxy.",
|
||||
"clearTraffic": "Effacer tout l'historique de trafic",
|
||||
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
|
||||
"clearTrafficSuccess": "Historique de trafic effacé"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Rechercher des profils...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Impossible de modifier un profil en cours d'exécution",
|
||||
"cantModifyLaunching": "Impossible de modifier le profil pendant le lancement",
|
||||
"cantModifyStopping": "Impossible de modifier le profil pendant l'arrêt",
|
||||
"cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur"
|
||||
"cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur",
|
||||
"emptyTitle": "Aucun profil pour l'instant",
|
||||
"emptyHint": "Créez votre premier profil ou importez des profils existants depuis un autre navigateur.",
|
||||
"emptyCreate": "Créer un profil",
|
||||
"emptyImport": "Importer des profils",
|
||||
"emptyFilteredTitle": "Aucun profil trouvé",
|
||||
"emptyFilteredHint": "Aucun profil ne correspond à ce groupe ou à cette recherche. Essayez un autre filtre ou créez-en un."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Lancer",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "domaines",
|
||||
"fresh": "À jour",
|
||||
"stale": "Obsolète",
|
||||
"notCached": "Non mis en cache"
|
||||
"notCached": "Non mis en cache",
|
||||
"tabBlocklists": "Listes de blocage",
|
||||
"tabCustom": "Listes personnalisées",
|
||||
"custom.description": "Créez votre propre liste de blocage à partir d'URL sources et de règles manuelles. Les domaines autorisés priment toujours sur les domaines bloqués.",
|
||||
"custom.sourcesLabel": "URL sources de listes de blocage",
|
||||
"custom.sourcesPlaceholder": "Une URL par ligne",
|
||||
"custom.blockLabel": "Domaines bloqués",
|
||||
"custom.blockPlaceholder": "Un domaine par ligne",
|
||||
"custom.allowLabel": "Domaines autorisés",
|
||||
"custom.allowPlaceholder": "Un domaine par ligne",
|
||||
"custom.allowHint": "Les domaines autorisés sont retirés de la liste de blocage compilée et priment sur toute règle de blocage.",
|
||||
"custom.saved": "Règles DNS personnalisées enregistrées",
|
||||
"custom.imported": "Règles DNS personnalisées importées",
|
||||
"custom.exported": "Règles DNS personnalisées exportées",
|
||||
"custom.exportTxt": "Exporter en TXT",
|
||||
"custom.exportJson": "Exporter en JSON",
|
||||
"customLevel": "Personnalisée",
|
||||
"custom.allowlistModeLabel": "Mode liste d'autorisation",
|
||||
"custom.allowlistModeOn": "Seuls les domaines ci-dessous sont accessibles ; tout le reste est bloqué.",
|
||||
"custom.allowlistModeOff": "Bloquer les domaines listés ; autoriser tout le reste.",
|
||||
"custom.allowedOnlyLabel": "Domaines autorisés (uniquement ceux-ci)",
|
||||
"custom.allowedOnlyHint": "Les sous-domaines d'un domaine listé sont aussi autorisés. Une liste vide désactive le filtrage."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "Recherche de profils de navigateur...",
|
||||
"noneFound": "Aucun profil de navigateur trouvé sur votre système.",
|
||||
"noneFoundHint": "Essayez l'import manuel si vos profils sont dans des emplacements personnalisés.",
|
||||
"selectProfile": "Sélectionner un profil :",
|
||||
"selectProfilePlaceholder": "Choisissez un profil détecté",
|
||||
"pathLabel": "Chemin :",
|
||||
"browserLabel": "Navigateur :",
|
||||
"newProfileName": "Nom du nouveau profil :",
|
||||
"newProfileNamePlaceholder": "Entrez un nom pour le profil importé",
|
||||
"manualTitle": "Import manuel de profil",
|
||||
"browserType": "Type de navigateur :",
|
||||
"loadingBrowsers": "Chargement des navigateurs...",
|
||||
"selectBrowserType": "Sélectionnez le type de navigateur",
|
||||
"profileFolderPath": "Chemin du dossier du profil :",
|
||||
"profileFolderPlaceholder": "Entrez le chemin complet vers le dossier du profil",
|
||||
"browseFolderTitle": "Parcourir le dossier",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "Sélectionnez un dossier de profil de navigateur",
|
||||
"folderDialogFailed": "Échec de l'ouverture de la boîte de dialogue de dossier",
|
||||
"detectFailed": "Échec de la détection des profils de navigateur existants",
|
||||
"fillFields": "Veuillez remplir tous les champs",
|
||||
"selectAndName": "Sélectionnez un profil et fournissez un nom",
|
||||
"profileNotFound": "Profil sélectionné introuvable",
|
||||
"importedSuccess": "Profil « {{name}} » importé avec succès",
|
||||
"notInstalled": "{{browser}} n'est pas installé. Veuillez télécharger {{browser}} depuis la fenêtre principale puis réessayer.",
|
||||
"importFailed": "Échec de l'import du profil : {{error}}",
|
||||
"proxyOptional": "Proxy (optionnel)",
|
||||
"noProxy": "Aucun proxy",
|
||||
"nextButton": "Suivant",
|
||||
"importButton": "Importer",
|
||||
"importedAs": "Ce profil sera importé en tant que profil {{browser}}."
|
||||
"importedAs": "Ce profil sera importé en tant que profil {{browser}}.",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"selectedCount": "{{count}} sélectionné(s)",
|
||||
"scanButton": "Analyser",
|
||||
"manualHint": "Choisissez un dossier de profil, un dossier de données utilisateur du navigateur, un dossier de profils exportés ou une archive ZIP.",
|
||||
"selectArchiveTitle": "Sélectionner une archive ZIP",
|
||||
"noProfilesInLocation": "Aucun profil trouvé à cet emplacement.",
|
||||
"selectAtLeastOne": "Sélectionnez au moins un profil à importer",
|
||||
"emptyNames": "Chaque profil sélectionné doit avoir un nom",
|
||||
"profilesToImport": "Profils à importer",
|
||||
"groupOptional": "Groupe (facultatif)",
|
||||
"noGroup": "Aucun groupe",
|
||||
"createNewGroup": "Créer un nouveau groupe…",
|
||||
"newGroupNamePlaceholder": "Nom du nouveau groupe",
|
||||
"duplicateStrategyLabel": "Si le nom du profil existe déjà",
|
||||
"duplicateRename": "Renommer automatiquement",
|
||||
"duplicateSkip": "Ignorer",
|
||||
"proxyRoundRobin": "Répartir les proxys enregistrés (round-robin)",
|
||||
"importingTitle": "Importation des profils…",
|
||||
"importProgress": "{{completed}} sur {{total}} traités",
|
||||
"resultsSummary": "{{imported}} importés, {{skipped}} ignorés, {{failed}} échoués",
|
||||
"statusImported": "Importé",
|
||||
"statusSkipped": "Ignoré",
|
||||
"statusFailed": "Échec",
|
||||
"importButtonCount": "Importer ({{count}})",
|
||||
"vpnOptional": "VPN (facultatif)",
|
||||
"noVpn": "Sans VPN",
|
||||
"advancedOptions": "Options avancées",
|
||||
"configureFingerprint": "Configurer l'empreinte (facultatif)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Synchronisation...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "Envoyé",
|
||||
"receivedLegend": "Reçu",
|
||||
"tooltipSent": "↑ Envoyé : ",
|
||||
"tooltipReceived": "↓ Reçu : "
|
||||
"tooltipReceived": "↓ Reçu : ",
|
||||
"clearHistory": "Effacer l'historique",
|
||||
"clearHistoryTitle": "Effacer l'historique de trafic",
|
||||
"clearHistoryDescription": "Efface définitivement et en toute sécurité tout l'historique de trafic enregistré pour « {{name}} ». Cette action est irréversible.",
|
||||
"tabOverview": "Vue d'ensemble",
|
||||
"tabTopDomains": "Principaux domaines",
|
||||
"searchDomains": "Rechercher des domaines…",
|
||||
"noDomainMatch": "Aucun domaine ne correspond à votre recherche."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Inconnu",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.",
|
||||
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"camoufoxImportDeprecated": "L'importation de profils basés sur Firefox n'est plus prise en charge. Utilisez Wayfern à la place."
|
||||
"camoufoxImportDeprecated": "L'importation de ce type de profil n'est plus prise en charge. Veuillez utiliser Wayfern à la place.",
|
||||
"updateChecksumsUnavailable": "La mise à jour {{version}} n'a pas pu être vérifiée car son fichier de sommes de contrôle n'a pas pu être récupéré. La mise à jour n'a pas été installée ; une nouvelle tentative aura lieu plus tard.",
|
||||
"updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer.",
|
||||
"nameCannotBeEmpty": "Le nom ne peut pas être vide",
|
||||
"wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}.",
|
||||
"profileNameExists": "Un profil nommé « {{name}} » existe déjà",
|
||||
"importSourceNotFound": "Le chemin source n'existe pas",
|
||||
"importNoItems": "Rien n'est sélectionné pour l'importation",
|
||||
"browserNotDownloaded": "Aucune version téléchargée de {{browser}} n'est disponible. Téléchargez-la d'abord, puis réessayez l'importation.",
|
||||
"archiveExtractionFailed": "Échec de l'extraction de l'archive : {{detail}}",
|
||||
"unsupportedArchiveFormat": "Format d'archive non pris en charge. Seules les archives ZIP sont prises en charge.",
|
||||
"clearOnCloseUnavailable": "L'effacement à la fermeture n'est pas disponible pour les profils éphémères ou protégés par mot de passe.",
|
||||
"proxyAndVpnMutuallyExclusive": "Un profil peut utiliser un proxy ou un VPN, mais pas les deux.",
|
||||
"invalidDnsRulesJson": "Le fichier sélectionné n'est pas un JSON de règles DNS valide.",
|
||||
"unsupportedDnsRulesFormat": "Format de règles non pris en charge : {{format}}",
|
||||
"dnsRulesSaveFailed": "Échec de l'enregistrement des règles DNS.",
|
||||
"dnsRulesExportFailed": "Échec de l'exportation des règles DNS.",
|
||||
"fingerprintMatchFailed": "Impossible d'aligner l'empreinte sur le proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "Importer un profil",
|
||||
"importProfileHint": "Importer depuis un autre outil",
|
||||
"keyboardShortcuts": "Raccourcis clavier",
|
||||
"keyboardShortcutsHint": "Voir tous les raccourcis"
|
||||
"keyboardShortcutsHint": "Voir tous les raccourcis",
|
||||
"about": "À propos de Donut Browser",
|
||||
"aboutHint": "Version et informations sur l'application"
|
||||
},
|
||||
"network": "Réseau",
|
||||
"integrations": "Intégrations",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Lancer {{name}}",
|
||||
"stopProfile": "Arrêter {{name}}",
|
||||
"profileInfo": "Informations — {{name}}"
|
||||
"profileInfo": "Informations — {{name}}",
|
||||
"createProfile": "Créer un profil",
|
||||
"about": "À propos de Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Aucun autre profil Wayfern sélectionné"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"version": "Version {{version}}",
|
||||
"portableBadge": "portable",
|
||||
"licenseNotice": "Navigateur anti-détection open source, sous licence AGPL-3.0.",
|
||||
"website": "Site web"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Effacer les données à la fermeture",
|
||||
"description": "Supprime les cookies, l'historique et le cache à la fermeture du navigateur. Les extensions et les favoris sont conservés."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Incohérence d'empreinte",
|
||||
"intro": "La sortie du proxy de « {{name}} » ne correspond pas à l'empreinte de ce profil :",
|
||||
"timezoneTitle": "Incohérence de fuseau horaire",
|
||||
"timezoneDetail": "Le nœud de sortie est dans {{exit}}, mais l'empreinte indique {{fingerprint}}.",
|
||||
"languageTitle": "Incohérence de langue",
|
||||
"languageDetail": "Le pays de sortie est {{country}}, mais la langue de l'empreinte est {{fingerprint}}.",
|
||||
"explainer": "Un fuseau horaire ou une langue en désaccord avec votre IP de sortie est un signal anti-bot fort, même si votre appareil réel ne fuite jamais. Alignez l'empreinte sur l'emplacement du proxy pour réduire les traitements hostiles.",
|
||||
"dontWarnAgain": "Ne plus avertir pour ce profil",
|
||||
"matchToProxy": "Aligner l'empreinte sur le proxy",
|
||||
"matching": "Alignement…",
|
||||
"matchSuccess": "Empreinte mise à jour pour correspondre au proxy. Relancez le profil pour l'appliquer."
|
||||
}
|
||||
}
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "アプリの自動更新を無効にする",
|
||||
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
|
||||
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
|
||||
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。"
|
||||
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。",
|
||||
"privacy": {
|
||||
"consistencyWarning": "フィンガープリント整合性の警告",
|
||||
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、起動時に警告します。",
|
||||
"clearTraffic": "すべてのトラフィック履歴を消去",
|
||||
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
|
||||
"clearTrafficSuccess": "トラフィック履歴を消去しました"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "プロファイルを検索...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "実行中のプロファイルは変更できません",
|
||||
"cantModifyLaunching": "起動中はプロファイルを変更できません",
|
||||
"cantModifyStopping": "停止中はプロファイルを変更できません",
|
||||
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません"
|
||||
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません",
|
||||
"emptyTitle": "プロファイルはまだありません",
|
||||
"emptyHint": "最初のプロファイルを作成するか、他のブラウザから既存のプロファイルをインポートしてください。",
|
||||
"emptyCreate": "プロファイルを作成",
|
||||
"emptyImport": "プロファイルをインポート",
|
||||
"emptyFilteredTitle": "プロファイルが見つかりません",
|
||||
"emptyFilteredHint": "このグループまたは検索に一致するプロファイルはありません。別のフィルターを試すか、新規作成してください。"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "起動",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "ドメイン",
|
||||
"fresh": "最新",
|
||||
"stale": "期限切れ",
|
||||
"notCached": "キャッシュなし"
|
||||
"notCached": "キャッシュなし",
|
||||
"tabBlocklists": "ブロックリスト",
|
||||
"tabCustom": "カスタムリスト",
|
||||
"custom.description": "ソース URL と手動ルールから独自のブロックリストを作成します。許可ドメインは常にブロックより優先されます。",
|
||||
"custom.sourcesLabel": "ブロックリストのソース URL",
|
||||
"custom.sourcesPlaceholder": "1 行につき 1 つの URL",
|
||||
"custom.blockLabel": "ブロックするドメイン",
|
||||
"custom.blockPlaceholder": "1 行につき 1 つのドメイン",
|
||||
"custom.allowLabel": "許可するドメイン",
|
||||
"custom.allowPlaceholder": "1 行につき 1 つのドメイン",
|
||||
"custom.allowHint": "許可ドメインはコンパイル済みブロックリストから除外され、あらゆるブロックルールより優先されます。",
|
||||
"custom.saved": "カスタム DNS ルールを保存しました",
|
||||
"custom.imported": "カスタム DNS ルールをインポートしました",
|
||||
"custom.exported": "カスタム DNS ルールをエクスポートしました",
|
||||
"custom.exportTxt": "TXT をエクスポート",
|
||||
"custom.exportJson": "JSON をエクスポート",
|
||||
"customLevel": "カスタム",
|
||||
"custom.allowlistModeLabel": "許可リストモード",
|
||||
"custom.allowlistModeOn": "下記のドメインのみアクセス可能で、それ以外はすべてブロックされます。",
|
||||
"custom.allowlistModeOff": "リストのドメインをブロックし、それ以外はすべて許可します。",
|
||||
"custom.allowedOnlyLabel": "許可するドメイン(これらのみ)",
|
||||
"custom.allowedOnlyHint": "リスト内ドメインのサブドメインも許可されます。リストが空の場合はフィルタリングが無効になります。"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "ブラウザプロファイルをスキャン中...",
|
||||
"noneFound": "システムにブラウザプロファイルが見つかりませんでした。",
|
||||
"noneFoundHint": "カスタムの場所にプロファイルがある場合は手動インポートをお試しください。",
|
||||
"selectProfile": "プロファイルを選択:",
|
||||
"selectProfilePlaceholder": "検出されたプロファイルを選択",
|
||||
"pathLabel": "パス:",
|
||||
"browserLabel": "ブラウザ:",
|
||||
"newProfileName": "新しいプロファイル名:",
|
||||
"newProfileNamePlaceholder": "インポートするプロファイルの名前を入力",
|
||||
"manualTitle": "手動プロファイルインポート",
|
||||
"browserType": "ブラウザの種類:",
|
||||
"loadingBrowsers": "ブラウザを読み込み中...",
|
||||
"selectBrowserType": "ブラウザの種類を選択",
|
||||
"profileFolderPath": "プロファイルフォルダパス:",
|
||||
"profileFolderPlaceholder": "プロファイルフォルダのフルパスを入力",
|
||||
"browseFolderTitle": "フォルダを参照",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "ブラウザプロファイルフォルダを選択",
|
||||
"folderDialogFailed": "フォルダダイアログを開けませんでした",
|
||||
"detectFailed": "既存のブラウザプロファイルの検出に失敗しました",
|
||||
"fillFields": "すべてのフィールドを入力してください",
|
||||
"selectAndName": "プロファイルを選択し、名前を入力してください",
|
||||
"profileNotFound": "選択されたプロファイルが見つかりません",
|
||||
"importedSuccess": "プロファイル「{{name}}」をインポートしました",
|
||||
"notInstalled": "{{browser}} はインストールされていません。メインウィンドウから {{browser}} をダウンロードしてからもう一度インポートしてください。",
|
||||
"importFailed": "プロファイルのインポートに失敗しました: {{error}}",
|
||||
"proxyOptional": "プロキシ (任意)",
|
||||
"noProxy": "プロキシなし",
|
||||
"nextButton": "次へ",
|
||||
"importButton": "インポート",
|
||||
"importedAs": "このプロファイルは {{browser}} プロファイルとしてインポートされます。"
|
||||
"importedAs": "このプロファイルは {{browser}} プロファイルとしてインポートされます。",
|
||||
"selectAll": "すべて選択",
|
||||
"selectedCount": "{{count}}件選択中",
|
||||
"scanButton": "スキャン",
|
||||
"manualHint": "プロファイルフォルダ、ブラウザのユーザーデータフォルダ、エクスポートされたプロファイルのフォルダ、またはZIPアーカイブを選択してください。",
|
||||
"selectArchiveTitle": "ZIPアーカイブを選択",
|
||||
"noProfilesInLocation": "この場所にプロファイルが見つかりませんでした。",
|
||||
"selectAtLeastOne": "インポートするプロファイルを1つ以上選択してください",
|
||||
"emptyNames": "選択したすべてのプロファイルに名前が必要です",
|
||||
"profilesToImport": "インポートするプロファイル",
|
||||
"groupOptional": "グループ(任意)",
|
||||
"noGroup": "グループなし",
|
||||
"createNewGroup": "新しいグループを作成…",
|
||||
"newGroupNamePlaceholder": "新しいグループ名",
|
||||
"duplicateStrategyLabel": "プロファイル名が既に存在する場合",
|
||||
"duplicateRename": "自動的に名前を変更",
|
||||
"duplicateSkip": "スキップ",
|
||||
"proxyRoundRobin": "保存済みプロキシを順番に割り当て(ラウンドロビン)",
|
||||
"importingTitle": "プロファイルをインポート中…",
|
||||
"importProgress": "{{total}}件中{{completed}}件処理済み",
|
||||
"resultsSummary": "インポート{{imported}}件、スキップ{{skipped}}件、失敗{{failed}}件",
|
||||
"statusImported": "インポート済み",
|
||||
"statusSkipped": "スキップ",
|
||||
"statusFailed": "失敗",
|
||||
"importButtonCount": "インポート ({{count}})",
|
||||
"vpnOptional": "VPN(任意)",
|
||||
"noVpn": "VPNなし",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"configureFingerprint": "フィンガープリントを設定(任意)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同期中...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "送信",
|
||||
"receivedLegend": "受信",
|
||||
"tooltipSent": "↑ 送信: ",
|
||||
"tooltipReceived": "↓ 受信: "
|
||||
"tooltipReceived": "↓ 受信: ",
|
||||
"clearHistory": "履歴を消去",
|
||||
"clearHistoryTitle": "トラフィック履歴を消去",
|
||||
"clearHistoryDescription": "「{{name}}」の記録されたトラフィック履歴をすべて完全かつ安全に消去します。この操作は元に戻せません。",
|
||||
"tabOverview": "概要",
|
||||
"tabTopDomains": "上位ドメイン",
|
||||
"searchDomains": "ドメインを検索…",
|
||||
"noDomainMatch": "検索に一致するドメインはありません。"
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "不明",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
||||
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
|
||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
|
||||
"camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。"
|
||||
"camoufoxImportDeprecated": "このタイプのプロファイルのインポートはサポートされなくなりました。代わりにWayfernを使用してください。",
|
||||
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
|
||||
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
|
||||
"nameCannotBeEmpty": "名前を空にすることはできません",
|
||||
"wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。",
|
||||
"profileNameExists": "「{{name}}」という名前のプロファイルは既に存在します",
|
||||
"importSourceNotFound": "ソースパスが存在しません",
|
||||
"importNoItems": "インポートする項目が選択されていません",
|
||||
"browserNotDownloaded": "{{browser}}のダウンロード済みバージョンがありません。先にダウンロードしてから、再度インポートしてください。",
|
||||
"archiveExtractionFailed": "アーカイブの展開に失敗しました:{{detail}}",
|
||||
"unsupportedArchiveFormat": "サポートされていないアーカイブ形式です。ZIPアーカイブのみサポートされています。",
|
||||
"clearOnCloseUnavailable": "終了時消去は、一時プロファイルやパスワード保護されたプロファイルでは利用できません。",
|
||||
"proxyAndVpnMutuallyExclusive": "プロファイルにはプロキシまたは VPN のいずれかを設定できます(両方は設定できません)。",
|
||||
"invalidDnsRulesJson": "選択したファイルは有効な DNS ルール JSON ではありません。",
|
||||
"unsupportedDnsRulesFormat": "サポートされていないルール形式: {{format}}",
|
||||
"dnsRulesSaveFailed": "DNS ルールを保存できませんでした。",
|
||||
"dnsRulesExportFailed": "DNS ルールをエクスポートできませんでした。",
|
||||
"fingerprintMatchFailed": "フィンガープリントをプロキシに合わせられませんでした。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"importProfileHint": "別のツールから取り込む",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"keyboardShortcutsHint": "すべてのショートカットを表示"
|
||||
"keyboardShortcutsHint": "すべてのショートカットを表示",
|
||||
"about": "Donut Browser について",
|
||||
"aboutHint": "バージョンとアプリ情報"
|
||||
},
|
||||
"network": "ネットワーク",
|
||||
"integrations": "連携",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "{{name}} を起動",
|
||||
"stopProfile": "{{name}} を停止",
|
||||
"profileInfo": "情報 — {{name}}"
|
||||
"profileInfo": "情報 — {{name}}",
|
||||
"createProfile": "プロファイルを作成",
|
||||
"about": "Donut Browser について"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "他に Wayfern プロファイルが選択されていません"
|
||||
},
|
||||
"about": {
|
||||
"title": "このアプリについて",
|
||||
"version": "バージョン {{version}}",
|
||||
"portableBadge": "ポータブル",
|
||||
"licenseNotice": "AGPL-3.0 ライセンスのオープンソース・アンチディテクトブラウザです。",
|
||||
"website": "ウェブサイト"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "終了時にデータを消去",
|
||||
"description": "ブラウザを閉じるときに Cookie、履歴、キャッシュを消去します。拡張機能とブックマークは保持されます。"
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "フィンガープリントの不一致",
|
||||
"intro": "「{{name}}」のプロキシ出口がこのプロファイルのフィンガープリントと一致していません:",
|
||||
"timezoneTitle": "タイムゾーンの不一致",
|
||||
"timezoneDetail": "出口ノードは {{exit}} にありますが、フィンガープリントは {{fingerprint}} を示しています。",
|
||||
"languageTitle": "言語の不一致",
|
||||
"languageDetail": "出口の国は {{country}} ですが、フィンガープリントの言語は {{fingerprint}} です。",
|
||||
"explainer": "出口 IP と食い違うタイムゾーンや言語は、実際のデバイス情報が漏れていなくても強力なアンチボットシグナルになります。フィンガープリントをプロキシの場所に合わせて、警戒される扱いを減らしましょう。",
|
||||
"dontWarnAgain": "このプロファイルでは今後警告しない",
|
||||
"matchToProxy": "フィンガープリントをプロキシに合わせる",
|
||||
"matching": "調整中…",
|
||||
"matchSuccess": "フィンガープリントをプロキシに合わせて更新しました。反映するにはプロファイルを再起動してください。"
|
||||
}
|
||||
}
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
|
||||
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
|
||||
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
|
||||
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다."
|
||||
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "핑거프린트 일관성 경고",
|
||||
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 실행 시 경고합니다.",
|
||||
"clearTraffic": "모든 트래픽 기록 지우기",
|
||||
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
|
||||
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "프로필 검색...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "실행 중인 프로필은 수정할 수 없습니다",
|
||||
"cantModifyLaunching": "실행하는 동안 프로필을 수정할 수 없습니다",
|
||||
"cantModifyStopping": "중지하는 동안 프로필을 수정할 수 없습니다",
|
||||
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다"
|
||||
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다",
|
||||
"emptyTitle": "아직 프로필이 없습니다",
|
||||
"emptyHint": "첫 프로필을 만들거나 다른 브라우저에서 기존 프로필을 가져오세요.",
|
||||
"emptyCreate": "프로필 생성",
|
||||
"emptyImport": "프로필 가져오기",
|
||||
"emptyFilteredTitle": "프로필을 찾을 수 없습니다",
|
||||
"emptyFilteredHint": "이 그룹 또는 검색과 일치하는 프로필이 없습니다. 다른 필터를 사용하거나 새로 만드세요."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "실행",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "도메인",
|
||||
"fresh": "최신",
|
||||
"stale": "오래됨",
|
||||
"notCached": "캐시되지 않음"
|
||||
"notCached": "캐시되지 않음",
|
||||
"tabBlocklists": "차단 목록",
|
||||
"tabCustom": "사용자 지정 목록",
|
||||
"custom.description": "소스 URL과 수동 규칙으로 나만의 차단 목록을 만드세요. 허용 도메인은 항상 차단 도메인보다 우선합니다.",
|
||||
"custom.sourcesLabel": "차단 목록 소스 URL",
|
||||
"custom.sourcesPlaceholder": "한 줄에 URL 하나",
|
||||
"custom.blockLabel": "차단할 도메인",
|
||||
"custom.blockPlaceholder": "한 줄에 도메인 하나",
|
||||
"custom.allowLabel": "허용할 도메인",
|
||||
"custom.allowPlaceholder": "한 줄에 도메인 하나",
|
||||
"custom.allowHint": "허용 도메인은 컴파일된 차단 목록에서 제거되어 모든 차단 규칙보다 우선합니다.",
|
||||
"custom.saved": "사용자 지정 DNS 규칙이 저장되었습니다",
|
||||
"custom.imported": "사용자 지정 DNS 규칙을 가져왔습니다",
|
||||
"custom.exported": "사용자 지정 DNS 규칙을 내보냈습니다",
|
||||
"custom.exportTxt": "TXT 내보내기",
|
||||
"custom.exportJson": "JSON 내보내기",
|
||||
"customLevel": "사용자 지정",
|
||||
"custom.allowlistModeLabel": "허용 목록 모드",
|
||||
"custom.allowlistModeOn": "아래 도메인만 접근할 수 있으며, 그 외에는 모두 차단됩니다.",
|
||||
"custom.allowlistModeOff": "목록의 도메인을 차단하고, 그 외에는 모두 허용합니다.",
|
||||
"custom.allowedOnlyLabel": "허용 도메인 (이것만)",
|
||||
"custom.allowedOnlyHint": "목록에 있는 도메인의 하위 도메인도 허용됩니다. 목록이 비어 있으면 필터링이 비활성화됩니다."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "브라우저 프로필 검색 중...",
|
||||
"noneFound": "시스템에서 브라우저 프로필을 찾을 수 없습니다.",
|
||||
"noneFoundHint": "사용자 지정 위치에 프로필이 있는 경우 수동 가져오기 옵션을 시도해 보세요.",
|
||||
"selectProfile": "프로필 선택:",
|
||||
"selectProfilePlaceholder": "감지된 프로필 선택",
|
||||
"pathLabel": "경로:",
|
||||
"browserLabel": "브라우저:",
|
||||
"newProfileName": "새 프로필 이름:",
|
||||
"newProfileNamePlaceholder": "가져올 프로필의 이름을 입력하세요",
|
||||
"manualTitle": "수동 프로필 가져오기",
|
||||
"browserType": "브라우저 유형:",
|
||||
"loadingBrowsers": "브라우저 불러오는 중...",
|
||||
"selectBrowserType": "브라우저 유형 선택",
|
||||
"profileFolderPath": "프로필 폴더 경로:",
|
||||
"profileFolderPlaceholder": "프로필 폴더의 전체 경로 입력",
|
||||
"browseFolderTitle": "폴더 찾아보기",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "브라우저 프로필 폴더 선택",
|
||||
"folderDialogFailed": "폴더 대화상자 열기 실패",
|
||||
"detectFailed": "기존 브라우저 프로필 감지 실패",
|
||||
"fillFields": "모든 필드를 입력하세요",
|
||||
"selectAndName": "프로필을 선택하고 이름을 제공하세요",
|
||||
"profileNotFound": "선택한 프로필을 찾을 수 없습니다",
|
||||
"importedSuccess": "프로필 \"{{name}}\"을(를) 가져왔습니다",
|
||||
"notInstalled": "{{browser}}이(가) 설치되지 않았습니다. 메인 창에서 먼저 {{browser}}을(를) 다운로드한 후 다시 가져오기를 시도하세요.",
|
||||
"importFailed": "프로필 가져오기 실패: {{error}}",
|
||||
"proxyOptional": "프록시 (선택 사항)",
|
||||
"noProxy": "프록시 없음",
|
||||
"nextButton": "다음",
|
||||
"importButton": "가져오기",
|
||||
"importedAs": "이 프로필은 {{browser}} 프로필로 가져옵니다."
|
||||
"importedAs": "이 프로필은 {{browser}} 프로필로 가져옵니다.",
|
||||
"selectAll": "모두 선택",
|
||||
"selectedCount": "{{count}}개 선택됨",
|
||||
"scanButton": "스캔",
|
||||
"manualHint": "프로필 폴더, 브라우저 사용자 데이터 폴더, 내보낸 프로필 폴더 또는 ZIP 아카이브를 선택하세요.",
|
||||
"selectArchiveTitle": "ZIP 아카이브 선택",
|
||||
"noProfilesInLocation": "이 위치에서 프로필을 찾을 수 없습니다.",
|
||||
"selectAtLeastOne": "가져올 프로필을 하나 이상 선택하세요",
|
||||
"emptyNames": "선택한 모든 프로필에 이름이 필요합니다",
|
||||
"profilesToImport": "가져올 프로필",
|
||||
"groupOptional": "그룹 (선택 사항)",
|
||||
"noGroup": "그룹 없음",
|
||||
"createNewGroup": "새 그룹 만들기…",
|
||||
"newGroupNamePlaceholder": "새 그룹 이름",
|
||||
"duplicateStrategyLabel": "프로필 이름이 이미 있는 경우",
|
||||
"duplicateRename": "자동으로 이름 변경",
|
||||
"duplicateSkip": "건너뛰기",
|
||||
"proxyRoundRobin": "저장된 프록시 순환 할당 (라운드 로빈)",
|
||||
"importingTitle": "프로필 가져오는 중…",
|
||||
"importProgress": "{{total}}개 중 {{completed}}개 처리됨",
|
||||
"resultsSummary": "가져옴 {{imported}}, 건너뜀 {{skipped}}, 실패 {{failed}}",
|
||||
"statusImported": "가져옴",
|
||||
"statusSkipped": "건너뜀",
|
||||
"statusFailed": "실패",
|
||||
"importButtonCount": "가져오기 ({{count}})",
|
||||
"vpnOptional": "VPN (선택 사항)",
|
||||
"noVpn": "VPN 없음",
|
||||
"advancedOptions": "고급 옵션",
|
||||
"configureFingerprint": "핑거프린트 구성 (선택 사항)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "동기화 중...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "보냄",
|
||||
"receivedLegend": "받음",
|
||||
"tooltipSent": "↑ 보냄: ",
|
||||
"tooltipReceived": "↓ 받음: "
|
||||
"tooltipReceived": "↓ 받음: ",
|
||||
"clearHistory": "기록 지우기",
|
||||
"clearHistoryTitle": "트래픽 기록 지우기",
|
||||
"clearHistoryDescription": "\"{{name}}\"의 기록된 모든 트래픽 기록을 영구적이고 안전하게 지웁니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"tabOverview": "개요",
|
||||
"tabTopDomains": "상위 도메인",
|
||||
"searchDomains": "도메인 검색…",
|
||||
"noDomainMatch": "검색과 일치하는 도메인이 없습니다."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "알 수 없음",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
|
||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요."
|
||||
"camoufoxImportDeprecated": "이 유형의 프로필 가져오기는 더 이상 지원되지 않습니다. 대신 Wayfern을 사용하세요.",
|
||||
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
|
||||
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
|
||||
"nameCannotBeEmpty": "이름은 비워둘 수 없습니다",
|
||||
"wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다.",
|
||||
"profileNameExists": "\"{{name}}\" 이름의 프로필이 이미 있습니다",
|
||||
"importSourceNotFound": "원본 경로가 존재하지 않습니다",
|
||||
"importNoItems": "가져올 항목이 선택되지 않았습니다",
|
||||
"browserNotDownloaded": "{{browser}}의 다운로드된 버전이 없습니다. 먼저 다운로드한 후 가져오기를 다시 시도하세요.",
|
||||
"archiveExtractionFailed": "아카이브 추출에 실패했습니다: {{detail}}",
|
||||
"unsupportedArchiveFormat": "지원되지 않는 아카이브 형식입니다. ZIP 아카이브만 지원됩니다.",
|
||||
"clearOnCloseUnavailable": "닫을 때 지우기는 임시 프로필이나 비밀번호로 보호된 프로필에서는 사용할 수 없습니다.",
|
||||
"proxyAndVpnMutuallyExclusive": "프로필에는 프록시 또는 VPN 중 하나만 사용할 수 있습니다.",
|
||||
"invalidDnsRulesJson": "선택한 파일이 올바른 DNS 규칙 JSON이 아닙니다.",
|
||||
"unsupportedDnsRulesFormat": "지원되지 않는 규칙 형식: {{format}}",
|
||||
"dnsRulesSaveFailed": "DNS 규칙을 저장하지 못했습니다.",
|
||||
"dnsRulesExportFailed": "DNS 규칙을 내보내지 못했습니다.",
|
||||
"fingerprintMatchFailed": "지문을 프록시에 맞추지 못했습니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "프로필 가져오기",
|
||||
"importProfileHint": "다른 도구에서 프로필 가져오기",
|
||||
"keyboardShortcuts": "키보드 단축키",
|
||||
"keyboardShortcutsHint": "모든 단축키 보기"
|
||||
"keyboardShortcutsHint": "모든 단축키 보기",
|
||||
"about": "Donut Browser 정보",
|
||||
"aboutHint": "버전 및 앱 정보"
|
||||
},
|
||||
"network": "네트워크",
|
||||
"integrations": "통합",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "{{name}} 실행",
|
||||
"stopProfile": "{{name}} 중지",
|
||||
"profileInfo": "정보 — {{name}}"
|
||||
"profileInfo": "정보 — {{name}}",
|
||||
"createProfile": "프로필 생성",
|
||||
"about": "Donut Browser 정보"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "선택된 다른 Wayfern 프로필이 없습니다"
|
||||
},
|
||||
"about": {
|
||||
"title": "정보",
|
||||
"version": "버전 {{version}}",
|
||||
"portableBadge": "포터블",
|
||||
"licenseNotice": "AGPL-3.0 라이선스의 오픈 소스 안티 디텍트 브라우저입니다.",
|
||||
"website": "웹사이트"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "닫을 때 데이터 지우기",
|
||||
"description": "브라우저를 닫을 때 쿠키, 방문 기록, 캐시를 지웁니다. 확장 프로그램과 북마크는 유지됩니다."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "핑거프린트 불일치",
|
||||
"intro": "\"{{name}}\"의 프록시 출구가 이 프로필의 핑거프린트와 일치하지 않습니다:",
|
||||
"timezoneTitle": "시간대 불일치",
|
||||
"timezoneDetail": "출구 노드는 {{exit}}에 있지만 핑거프린트는 {{fingerprint}}로 보고합니다.",
|
||||
"languageTitle": "언어 불일치",
|
||||
"languageDetail": "출구 국가는 {{country}}이지만 핑거프린트 언어는 {{fingerprint}}입니다.",
|
||||
"explainer": "출구 IP와 어긋나는 시간대나 언어는 실제 기기 정보가 유출되지 않더라도 강력한 안티봇 신호가 됩니다. 핑거프린트를 프록시 위치에 맞춰 의심받는 상황을 줄이세요.",
|
||||
"dontWarnAgain": "이 프로필에 대해 다시 경고하지 않음",
|
||||
"matchToProxy": "지문을 프록시에 맞추기",
|
||||
"matching": "맞추는 중…",
|
||||
"matchSuccess": "지문이 프록시에 맞게 업데이트되었습니다. 적용하려면 프로필을 다시 실행하세요."
|
||||
}
|
||||
}
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
|
||||
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.",
|
||||
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso."
|
||||
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Aviso de consistência de impressão digital",
|
||||
"consistencyWarningDescription": "Avisar ao iniciar quando o fuso horário ou o idioma de um perfil não corresponder ao seu nó de saída do proxy.",
|
||||
"clearTraffic": "Limpar todo o histórico de tráfego",
|
||||
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
|
||||
"clearTrafficSuccess": "Histórico de tráfego limpo"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Pesquisar perfis...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Não é possível modificar um perfil em execução",
|
||||
"cantModifyLaunching": "Não é possível modificar o perfil durante a inicialização",
|
||||
"cantModifyStopping": "Não é possível modificar o perfil durante a parada",
|
||||
"cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado"
|
||||
"cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado",
|
||||
"emptyTitle": "Nenhum perfil ainda",
|
||||
"emptyHint": "Crie seu primeiro perfil ou importe perfis existentes de outro navegador.",
|
||||
"emptyCreate": "Criar perfil",
|
||||
"emptyImport": "Importar perfis",
|
||||
"emptyFilteredTitle": "Nenhum perfil encontrado",
|
||||
"emptyFilteredHint": "Nenhum perfil corresponde a este grupo ou pesquisa. Tente outro filtro ou crie um novo."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "domínios",
|
||||
"fresh": "Atualizado",
|
||||
"stale": "Desatualizado",
|
||||
"notCached": "Sem cache"
|
||||
"notCached": "Sem cache",
|
||||
"tabBlocklists": "Listas de bloqueio",
|
||||
"tabCustom": "Listas personalizadas",
|
||||
"custom.description": "Monte sua própria lista de bloqueio a partir de URLs de origem e regras manuais. Domínios permitidos sempre têm prioridade sobre os bloqueados.",
|
||||
"custom.sourcesLabel": "URLs de origem de listas de bloqueio",
|
||||
"custom.sourcesPlaceholder": "Uma URL por linha",
|
||||
"custom.blockLabel": "Domínios bloqueados",
|
||||
"custom.blockPlaceholder": "Um domínio por linha",
|
||||
"custom.allowLabel": "Domínios permitidos",
|
||||
"custom.allowPlaceholder": "Um domínio por linha",
|
||||
"custom.allowHint": "Os domínios permitidos são removidos da lista de bloqueio compilada, anulando qualquer regra de bloqueio.",
|
||||
"custom.saved": "Regras DNS personalizadas salvas",
|
||||
"custom.imported": "Regras DNS personalizadas importadas",
|
||||
"custom.exported": "Regras DNS personalizadas exportadas",
|
||||
"custom.exportTxt": "Exportar TXT",
|
||||
"custom.exportJson": "Exportar JSON",
|
||||
"customLevel": "Personalizada",
|
||||
"custom.allowlistModeLabel": "Modo lista de permitidos",
|
||||
"custom.allowlistModeOn": "Apenas os domínios abaixo são acessíveis; todo o resto é bloqueado.",
|
||||
"custom.allowlistModeOff": "Bloquear os domínios listados; permitir todo o resto.",
|
||||
"custom.allowedOnlyLabel": "Domínios permitidos (apenas estes)",
|
||||
"custom.allowedOnlyHint": "Subdomínios de um domínio listado também são permitidos. Uma lista vazia desativa a filtragem."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "Procurando perfis de navegador...",
|
||||
"noneFound": "Nenhum perfil de navegador encontrado no seu sistema.",
|
||||
"noneFoundHint": "Tente a importação manual se você tem perfis em locais personalizados.",
|
||||
"selectProfile": "Selecionar perfil:",
|
||||
"selectProfilePlaceholder": "Escolha um perfil detectado",
|
||||
"pathLabel": "Caminho:",
|
||||
"browserLabel": "Navegador:",
|
||||
"newProfileName": "Nome do novo perfil:",
|
||||
"newProfileNamePlaceholder": "Insira um nome para o perfil importado",
|
||||
"manualTitle": "Importação manual de perfil",
|
||||
"browserType": "Tipo de navegador:",
|
||||
"loadingBrowsers": "Carregando navegadores...",
|
||||
"selectBrowserType": "Selecione o tipo de navegador",
|
||||
"profileFolderPath": "Caminho da pasta do perfil:",
|
||||
"profileFolderPlaceholder": "Insira o caminho completo para a pasta do perfil",
|
||||
"browseFolderTitle": "Procurar pasta",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "Selecionar pasta de perfil do navegador",
|
||||
"folderDialogFailed": "Falha ao abrir o diálogo de pasta",
|
||||
"detectFailed": "Falha ao detectar perfis de navegador existentes",
|
||||
"fillFields": "Por favor, preencha todos os campos",
|
||||
"selectAndName": "Selecione um perfil e forneça um nome",
|
||||
"profileNotFound": "Perfil selecionado não encontrado",
|
||||
"importedSuccess": "Perfil \"{{name}}\" importado com sucesso",
|
||||
"notInstalled": "{{browser}} não está instalado. Baixe {{browser}} primeiro pela janela principal e tente importar novamente.",
|
||||
"importFailed": "Falha ao importar perfil: {{error}}",
|
||||
"proxyOptional": "Proxy (Opcional)",
|
||||
"noProxy": "Sem proxy",
|
||||
"nextButton": "Próximo",
|
||||
"importButton": "Importar",
|
||||
"importedAs": "Este perfil será importado como um perfil {{browser}}."
|
||||
"importedAs": "Este perfil será importado como um perfil {{browser}}.",
|
||||
"selectAll": "Selecionar tudo",
|
||||
"selectedCount": "{{count}} selecionados",
|
||||
"scanButton": "Verificar",
|
||||
"manualHint": "Escolha uma pasta de perfil, uma pasta de dados de usuário do navegador, uma pasta com perfis exportados ou um arquivo ZIP.",
|
||||
"selectArchiveTitle": "Selecionar arquivo ZIP",
|
||||
"noProfilesInLocation": "Nenhum perfil encontrado neste local.",
|
||||
"selectAtLeastOne": "Selecione pelo menos um perfil para importar",
|
||||
"emptyNames": "Cada perfil selecionado precisa de um nome",
|
||||
"profilesToImport": "Perfis a importar",
|
||||
"groupOptional": "Grupo (opcional)",
|
||||
"noGroup": "Sem grupo",
|
||||
"createNewGroup": "Criar novo grupo…",
|
||||
"newGroupNamePlaceholder": "Nome do novo grupo",
|
||||
"duplicateStrategyLabel": "Se o nome do perfil já existir",
|
||||
"duplicateRename": "Renomear automaticamente",
|
||||
"duplicateSkip": "Ignorar",
|
||||
"proxyRoundRobin": "Distribuir proxies salvos (round-robin)",
|
||||
"importingTitle": "Importando perfis…",
|
||||
"importProgress": "{{completed}} de {{total}} processados",
|
||||
"resultsSummary": "{{imported}} importados, {{skipped}} ignorados, {{failed}} com falha",
|
||||
"statusImported": "Importado",
|
||||
"statusSkipped": "Ignorado",
|
||||
"statusFailed": "Falhou",
|
||||
"importButtonCount": "Importar ({{count}})",
|
||||
"vpnOptional": "VPN (opcional)",
|
||||
"noVpn": "Sem VPN",
|
||||
"advancedOptions": "Opções avançadas",
|
||||
"configureFingerprint": "Configurar impressão digital (opcional)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "Enviado",
|
||||
"receivedLegend": "Recebido",
|
||||
"tooltipSent": "↑ Enviado: ",
|
||||
"tooltipReceived": "↓ Recebido: "
|
||||
"tooltipReceived": "↓ Recebido: ",
|
||||
"clearHistory": "Limpar histórico",
|
||||
"clearHistoryTitle": "Limpar histórico de tráfego",
|
||||
"clearHistoryDescription": "Apaga de forma permanente e segura todo o histórico de tráfego registrado de \"{{name}}\". Isso não pode ser desfeito.",
|
||||
"tabOverview": "Visão geral",
|
||||
"tabTopDomains": "Principais domínios",
|
||||
"searchDomains": "Pesquisar domínios…",
|
||||
"noDomainMatch": "Nenhum domínio corresponde à sua pesquisa."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Desconhecido",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
|
||||
"proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.",
|
||||
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado.",
|
||||
"camoufoxImportDeprecated": "A importação de perfis baseados em Firefox não é mais suportada. Use o Wayfern."
|
||||
"camoufoxImportDeprecated": "A importação deste tipo de perfil não é mais suportada. Use o Wayfern em vez disso.",
|
||||
"updateChecksumsUnavailable": "Não foi possível verificar a atualização {{version}} porque o arquivo de somas de verificação não pôde ser obtido. A atualização não foi instalada; será tentada novamente mais tarde.",
|
||||
"updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.",
|
||||
"nameCannotBeEmpty": "O nome não pode estar vazio",
|
||||
"wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}.",
|
||||
"profileNameExists": "Já existe um perfil chamado \"{{name}}\"",
|
||||
"importSourceNotFound": "O caminho de origem não existe",
|
||||
"importNoItems": "Nada selecionado para importar",
|
||||
"browserNotDownloaded": "Nenhuma versão baixada de {{browser}} está disponível. Baixe-a primeiro e tente importar novamente.",
|
||||
"archiveExtractionFailed": "Falha ao extrair o arquivo: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Formato de arquivo não suportado. Apenas arquivos ZIP são suportados.",
|
||||
"clearOnCloseUnavailable": "A limpeza ao fechar não está disponível para perfis efêmeros ou protegidos por senha.",
|
||||
"proxyAndVpnMutuallyExclusive": "Um perfil pode usar um proxy ou uma VPN, mas não ambos.",
|
||||
"invalidDnsRulesJson": "O arquivo selecionado não é um JSON de regras DNS válido.",
|
||||
"unsupportedDnsRulesFormat": "Formato de regras não suportado: {{format}}",
|
||||
"dnsRulesSaveFailed": "Falha ao salvar as regras DNS.",
|
||||
"dnsRulesExportFailed": "Falha ao exportar as regras DNS.",
|
||||
"fingerprintMatchFailed": "Não foi possível ajustar a impressão digital ao proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trazer perfis de outra ferramenta",
|
||||
"keyboardShortcuts": "Atalhos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos os atalhos"
|
||||
"keyboardShortcutsHint": "Ver todos os atalhos",
|
||||
"about": "Sobre o Donut Browser",
|
||||
"aboutHint": "Versão e informações do aplicativo"
|
||||
},
|
||||
"network": "Rede",
|
||||
"integrations": "Integrações",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Parar {{name}}",
|
||||
"profileInfo": "Informações — {{name}}"
|
||||
"profileInfo": "Informações — {{name}}",
|
||||
"createProfile": "Criar perfil",
|
||||
"about": "Sobre o Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Nenhum outro perfil Wayfern selecionado"
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre",
|
||||
"version": "Versão {{version}}",
|
||||
"portableBadge": "portátil",
|
||||
"licenseNotice": "Navegador anti-detecção de código aberto, licenciado sob AGPL-3.0.",
|
||||
"website": "Site"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Limpar dados ao fechar",
|
||||
"description": "Apaga cookies, histórico e cache quando o navegador é fechado. Extensões e favoritos são mantidos."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Divergência de impressão digital",
|
||||
"intro": "A saída do proxy de \"{{name}}\" não corresponde à impressão digital deste perfil:",
|
||||
"timezoneTitle": "Divergência de fuso horário",
|
||||
"timezoneDetail": "O nó de saída está em {{exit}}, mas a impressão digital indica {{fingerprint}}.",
|
||||
"languageTitle": "Divergência de idioma",
|
||||
"languageDetail": "O país de saída é {{country}}, mas o idioma da impressão digital é {{fingerprint}}.",
|
||||
"explainer": "Um fuso horário ou idioma que não combina com seu IP de saída é um forte sinal anti-bot, mesmo que seu dispositivo real nunca vaze. Alinhe a impressão digital com a localização do proxy para reduzir tratamentos hostis.",
|
||||
"dontWarnAgain": "Não avisar novamente para este perfil",
|
||||
"matchToProxy": "Ajustar impressão ao proxy",
|
||||
"matching": "Ajustando…",
|
||||
"matchSuccess": "Impressão digital atualizada para corresponder ao proxy. Reinicie o perfil para aplicar."
|
||||
}
|
||||
}
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Отключить автообновление приложения",
|
||||
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
|
||||
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
|
||||
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной."
|
||||
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Предупреждение о согласованности отпечатка",
|
||||
"consistencyWarningDescription": "Предупреждать при запуске, если часовой пояс или язык профиля не совпадает с выходным узлом прокси.",
|
||||
"clearTraffic": "Очистить всю историю трафика",
|
||||
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
|
||||
"clearTrafficSuccess": "История трафика очищена"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Поиск профилей...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Нельзя изменить запущенный профиль",
|
||||
"cantModifyLaunching": "Нельзя изменить профиль во время запуска",
|
||||
"cantModifyStopping": "Нельзя изменить профиль во время остановки",
|
||||
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера"
|
||||
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера",
|
||||
"emptyTitle": "Профилей пока нет",
|
||||
"emptyHint": "Создайте свой первый профиль или импортируйте существующие из другого браузера.",
|
||||
"emptyCreate": "Создать профиль",
|
||||
"emptyImport": "Импортировать профили",
|
||||
"emptyFilteredTitle": "Профили не найдены",
|
||||
"emptyFilteredHint": "Нет профилей для этой группы или запроса. Попробуйте другой фильтр или создайте профиль."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Запустить",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "доменов",
|
||||
"fresh": "Актуальный",
|
||||
"stale": "Устаревший",
|
||||
"notCached": "Не кэшировано"
|
||||
"notCached": "Не кэшировано",
|
||||
"tabBlocklists": "Списки блокировки",
|
||||
"tabCustom": "Пользовательские списки",
|
||||
"custom.description": "Составьте собственный список блокировки из URL-источников и ручных правил. Разрешённые домены всегда имеют приоритет над заблокированными.",
|
||||
"custom.sourcesLabel": "URL-источники списков блокировки",
|
||||
"custom.sourcesPlaceholder": "Один URL на строку",
|
||||
"custom.blockLabel": "Заблокированные домены",
|
||||
"custom.blockPlaceholder": "Один домен на строку",
|
||||
"custom.allowLabel": "Разрешённые домены",
|
||||
"custom.allowPlaceholder": "Один домен на строку",
|
||||
"custom.allowHint": "Разрешённые домены исключаются из итогового списка блокировки и имеют приоритет над любым правилом блокировки.",
|
||||
"custom.saved": "Пользовательские правила DNS сохранены",
|
||||
"custom.imported": "Пользовательские правила DNS импортированы",
|
||||
"custom.exported": "Пользовательские правила DNS экспортированы",
|
||||
"custom.exportTxt": "Экспорт TXT",
|
||||
"custom.exportJson": "Экспорт JSON",
|
||||
"customLevel": "Пользовательский",
|
||||
"custom.allowlistModeLabel": "Режим разрешённого списка",
|
||||
"custom.allowlistModeOn": "Доступны только домены ниже; всё остальное блокируется.",
|
||||
"custom.allowlistModeOff": "Блокировать домены из списка; всё остальное разрешено.",
|
||||
"custom.allowedOnlyLabel": "Разрешённые домены (только эти)",
|
||||
"custom.allowedOnlyHint": "Поддомены указанных доменов также разрешены. Пустой список отключает фильтрацию."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "Поиск профилей браузера...",
|
||||
"noneFound": "На вашей системе профили браузера не найдены.",
|
||||
"noneFoundHint": "Попробуйте ручной импорт, если профили находятся в нестандартных местах.",
|
||||
"selectProfile": "Выбрать профиль:",
|
||||
"selectProfilePlaceholder": "Выберите обнаруженный профиль",
|
||||
"pathLabel": "Путь:",
|
||||
"browserLabel": "Браузер:",
|
||||
"newProfileName": "Имя нового профиля:",
|
||||
"newProfileNamePlaceholder": "Введите имя для импортированного профиля",
|
||||
"manualTitle": "Ручной импорт профиля",
|
||||
"browserType": "Тип браузера:",
|
||||
"loadingBrowsers": "Загрузка браузеров...",
|
||||
"selectBrowserType": "Выберите тип браузера",
|
||||
"profileFolderPath": "Путь к папке профиля:",
|
||||
"profileFolderPlaceholder": "Введите полный путь к папке профиля",
|
||||
"browseFolderTitle": "Выбрать папку",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "Выберите папку профиля браузера",
|
||||
"folderDialogFailed": "Не удалось открыть диалог выбора папки",
|
||||
"detectFailed": "Не удалось обнаружить существующие профили браузера",
|
||||
"fillFields": "Пожалуйста, заполните все поля",
|
||||
"selectAndName": "Выберите профиль и укажите имя",
|
||||
"profileNotFound": "Выбранный профиль не найден",
|
||||
"importedSuccess": "Профиль «{{name}}» успешно импортирован",
|
||||
"notInstalled": "{{browser}} не установлен. Сначала загрузите {{browser}} из главного окна, затем попробуйте импортировать снова.",
|
||||
"importFailed": "Не удалось импортировать профиль: {{error}}",
|
||||
"proxyOptional": "Прокси (необязательно)",
|
||||
"noProxy": "Без прокси",
|
||||
"nextButton": "Далее",
|
||||
"importButton": "Импорт",
|
||||
"importedAs": "Этот профиль будет импортирован как профиль {{browser}}."
|
||||
"importedAs": "Этот профиль будет импортирован как профиль {{browser}}.",
|
||||
"selectAll": "Выбрать все",
|
||||
"selectedCount": "Выбрано: {{count}}",
|
||||
"scanButton": "Сканировать",
|
||||
"manualHint": "Выберите папку профиля, папку данных браузера, папку с экспортированными профилями или ZIP-архив.",
|
||||
"selectArchiveTitle": "Выбрать ZIP-архив",
|
||||
"noProfilesInLocation": "В этом расположении профили не найдены.",
|
||||
"selectAtLeastOne": "Выберите хотя бы один профиль для импорта",
|
||||
"emptyNames": "Каждому выбранному профилю нужно имя",
|
||||
"profilesToImport": "Профили для импорта",
|
||||
"groupOptional": "Группа (необязательно)",
|
||||
"noGroup": "Без группы",
|
||||
"createNewGroup": "Создать новую группу…",
|
||||
"newGroupNamePlaceholder": "Название новой группы",
|
||||
"duplicateStrategyLabel": "Если имя профиля уже существует",
|
||||
"duplicateRename": "Переименовать автоматически",
|
||||
"duplicateSkip": "Пропустить",
|
||||
"proxyRoundRobin": "Распределить сохранённые прокси (по кругу)",
|
||||
"importingTitle": "Импорт профилей…",
|
||||
"importProgress": "Обработано {{completed}} из {{total}}",
|
||||
"resultsSummary": "Импортировано: {{imported}}, пропущено: {{skipped}}, с ошибкой: {{failed}}",
|
||||
"statusImported": "Импортирован",
|
||||
"statusSkipped": "Пропущен",
|
||||
"statusFailed": "Ошибка",
|
||||
"importButtonCount": "Импортировать ({{count}})",
|
||||
"vpnOptional": "VPN (необязательно)",
|
||||
"noVpn": "Без VPN",
|
||||
"advancedOptions": "Дополнительные параметры",
|
||||
"configureFingerprint": "Настроить отпечаток (необязательно)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Синхронизация...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "Отправлено",
|
||||
"receivedLegend": "Получено",
|
||||
"tooltipSent": "↑ Отправлено: ",
|
||||
"tooltipReceived": "↓ Получено: "
|
||||
"tooltipReceived": "↓ Получено: ",
|
||||
"clearHistory": "Очистить историю",
|
||||
"clearHistoryTitle": "Очистить историю трафика",
|
||||
"clearHistoryDescription": "Навсегда и безопасно удаляет всю записанную историю трафика для «{{name}}». Это действие нельзя отменить.",
|
||||
"tabOverview": "Обзор",
|
||||
"tabTopDomains": "Топ доменов",
|
||||
"searchDomains": "Поиск доменов…",
|
||||
"noDomainMatch": "Нет доменов, соответствующих запросу."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Неизвестно",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
||||
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
|
||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
|
||||
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern."
|
||||
"camoufoxImportDeprecated": "Импорт профилей этого типа больше не поддерживается. Используйте Wayfern.",
|
||||
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
|
||||
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
|
||||
"nameCannotBeEmpty": "Имя не может быть пустым",
|
||||
"wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}.",
|
||||
"profileNameExists": "Профиль с именем «{{name}}» уже существует",
|
||||
"importSourceNotFound": "Исходный путь не существует",
|
||||
"importNoItems": "Ничего не выбрано для импорта",
|
||||
"browserNotDownloaded": "Нет загруженной версии {{browser}}. Сначала загрузите её, затем повторите импорт.",
|
||||
"archiveExtractionFailed": "Не удалось распаковать архив: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Неподдерживаемый формат архива. Поддерживаются только ZIP-архивы.",
|
||||
"clearOnCloseUnavailable": "Очистка при закрытии недоступна для эфемерных и защищённых паролем профилей.",
|
||||
"proxyAndVpnMutuallyExclusive": "Профиль может использовать либо прокси, либо VPN, но не оба сразу.",
|
||||
"invalidDnsRulesJson": "Выбранный файл не является корректным JSON с правилами DNS.",
|
||||
"unsupportedDnsRulesFormat": "Неподдерживаемый формат правил: {{format}}",
|
||||
"dnsRulesSaveFailed": "Не удалось сохранить правила DNS.",
|
||||
"dnsRulesExportFailed": "Не удалось экспортировать правила DNS.",
|
||||
"fingerprintMatchFailed": "Не удалось подогнать отпечаток под прокси."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "Импорт профиля",
|
||||
"importProfileHint": "Перенести профили из другого инструмента",
|
||||
"keyboardShortcuts": "Сочетания клавиш",
|
||||
"keyboardShortcutsHint": "Показать все сочетания"
|
||||
"keyboardShortcutsHint": "Показать все сочетания",
|
||||
"about": "О Donut Browser",
|
||||
"aboutHint": "Версия и сведения о приложении"
|
||||
},
|
||||
"network": "Сеть",
|
||||
"integrations": "Интеграции",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Запустить {{name}}",
|
||||
"stopProfile": "Остановить {{name}}",
|
||||
"profileInfo": "Информация — {{name}}"
|
||||
"profileInfo": "Информация — {{name}}",
|
||||
"createProfile": "Создать профиль",
|
||||
"about": "О Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Других выбранных Wayfern профилей нет"
|
||||
},
|
||||
"about": {
|
||||
"title": "О приложении",
|
||||
"version": "Версия {{version}}",
|
||||
"portableBadge": "портативная",
|
||||
"licenseNotice": "Антидетект-браузер с открытым исходным кодом, распространяется по лицензии AGPL-3.0.",
|
||||
"website": "Веб-сайт"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Очищать данные при закрытии",
|
||||
"description": "Удаляет cookie, историю и кэш при закрытии браузера. Расширения и закладки сохраняются."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Несовпадение отпечатка",
|
||||
"intro": "Выходной узел прокси для «{{name}}» не соответствует отпечатку этого профиля:",
|
||||
"timezoneTitle": "Несовпадение часового пояса",
|
||||
"timezoneDetail": "Выходной узел находится в {{exit}}, но отпечаток сообщает {{fingerprint}}.",
|
||||
"languageTitle": "Несовпадение языка",
|
||||
"languageDetail": "Страна выхода — {{country}}, но язык отпечатка — {{fingerprint}}.",
|
||||
"explainer": "Часовой пояс или язык, не совпадающий с выходным IP, — сильный антибот-сигнал, даже если данные вашего реального устройства никогда не утекают. Приведите отпечаток в соответствие с расположением прокси, чтобы снизить враждебное отношение.",
|
||||
"dontWarnAgain": "Больше не предупреждать для этого профиля",
|
||||
"matchToProxy": "Подогнать отпечаток под прокси",
|
||||
"matching": "Подгонка…",
|
||||
"matchSuccess": "Отпечаток обновлён под прокси. Перезапустите профиль, чтобы применить."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Tắt tự động cập nhật ứng dụng",
|
||||
"disableAutoUpdatesDescription": "Ngăn ứng dụng tự động kiểm tra và cài đặt bản cập nhật Donut Browser. Cập nhật trình duyệt không bị ảnh hưởng.",
|
||||
"keepDecryptedProfilesInRam": "Giữ hồ sơ đã giải mã trong RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa."
|
||||
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Cảnh báo nhất quán vân tay",
|
||||
"consistencyWarningDescription": "Cảnh báo khi khởi chạy nếu múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát proxy.",
|
||||
"clearTraffic": "Xóa toàn bộ lịch sử lưu lượng",
|
||||
"clearTrafficDescription": "Xóa an toàn số liệu thống kê lưu lượng đã ghi của mọi hồ sơ.",
|
||||
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Tìm kiếm hồ sơ...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Không thể chỉnh sửa profile đang chạy",
|
||||
"cantModifyLaunching": "Không thể chỉnh sửa profile khi đang khởi chạy",
|
||||
"cantModifyStopping": "Không thể chỉnh sửa profile khi đang dừng",
|
||||
"cantModifyUpdating": "Không thể chỉnh sửa profile khi trình duyệt đang cập nhật"
|
||||
"cantModifyUpdating": "Không thể chỉnh sửa profile khi trình duyệt đang cập nhật",
|
||||
"emptyTitle": "Chưa có hồ sơ nào",
|
||||
"emptyHint": "Tạo hồ sơ đầu tiên của bạn hoặc nhập hồ sơ hiện có từ trình duyệt khác.",
|
||||
"emptyCreate": "Tạo hồ sơ",
|
||||
"emptyImport": "Nhập hồ sơ",
|
||||
"emptyFilteredTitle": "Không tìm thấy hồ sơ",
|
||||
"emptyFilteredHint": "Không có hồ sơ nào khớp với nhóm hoặc tìm kiếm này. Hãy thử bộ lọc khác hoặc tạo mới."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Khởi chạy",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "tên miền",
|
||||
"fresh": "Mới",
|
||||
"stale": "Cũ",
|
||||
"notCached": "Chưa lưu bộ nhớ đệm"
|
||||
"notCached": "Chưa lưu bộ nhớ đệm",
|
||||
"tabBlocklists": "Danh sách chặn",
|
||||
"tabCustom": "Danh sách tùy chỉnh",
|
||||
"custom.description": "Tự tạo danh sách chặn của riêng bạn từ các URL nguồn và quy tắc thủ công. Tên miền được phép luôn được ưu tiên hơn tên miền bị chặn.",
|
||||
"custom.sourcesLabel": "URL nguồn danh sách chặn",
|
||||
"custom.sourcesPlaceholder": "Mỗi dòng một URL",
|
||||
"custom.blockLabel": "Tên miền bị chặn",
|
||||
"custom.blockPlaceholder": "Mỗi dòng một tên miền",
|
||||
"custom.allowLabel": "Tên miền được phép",
|
||||
"custom.allowPlaceholder": "Mỗi dòng một tên miền",
|
||||
"custom.allowHint": "Tên miền được phép sẽ bị loại khỏi danh sách chặn đã biên dịch, ghi đè mọi quy tắc chặn.",
|
||||
"custom.saved": "Đã lưu quy tắc DNS tùy chỉnh",
|
||||
"custom.imported": "Đã nhập quy tắc DNS tùy chỉnh",
|
||||
"custom.exported": "Đã xuất quy tắc DNS tùy chỉnh",
|
||||
"custom.exportTxt": "Xuất TXT",
|
||||
"custom.exportJson": "Xuất JSON",
|
||||
"customLevel": "Tùy chỉnh",
|
||||
"custom.allowlistModeLabel": "Chế độ danh sách cho phép",
|
||||
"custom.allowlistModeOn": "Chỉ các tên miền bên dưới có thể truy cập; mọi thứ khác đều bị chặn.",
|
||||
"custom.allowlistModeOff": "Chặn các tên miền trong danh sách; cho phép mọi thứ khác.",
|
||||
"custom.allowedOnlyLabel": "Tên miền được phép (chỉ những tên này)",
|
||||
"custom.allowedOnlyHint": "Tên miền phụ của tên miền trong danh sách cũng được phép. Danh sách trống sẽ tắt bộ lọc."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "Đang quét profile trình duyệt...",
|
||||
"noneFound": "Không tìm thấy profile trình duyệt nào trên hệ thống của bạn.",
|
||||
"noneFoundHint": "Thử tùy chọn nhập thủ công nếu bạn có profile ở vị trí tùy chỉnh.",
|
||||
"selectProfile": "Chọn profile:",
|
||||
"selectProfilePlaceholder": "Chọn profile đã phát hiện",
|
||||
"pathLabel": "Đường dẫn:",
|
||||
"browserLabel": "Trình duyệt:",
|
||||
"newProfileName": "Tên profile mới:",
|
||||
"newProfileNamePlaceholder": "Nhập tên cho profile được nhập",
|
||||
"manualTitle": "Nhập profile thủ công",
|
||||
"browserType": "Loại trình duyệt:",
|
||||
"loadingBrowsers": "Đang tải trình duyệt...",
|
||||
"selectBrowserType": "Chọn loại trình duyệt",
|
||||
"profileFolderPath": "Đường dẫn thư mục profile:",
|
||||
"profileFolderPlaceholder": "Nhập đường dẫn đầy đủ đến thư mục profile",
|
||||
"browseFolderTitle": "Duyệt thư mục",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "Chọn thư mục profile trình duyệt",
|
||||
"folderDialogFailed": "Mở hộp thoại thư mục thất bại",
|
||||
"detectFailed": "Phát hiện profile trình duyệt hiện có thất bại",
|
||||
"fillFields": "Vui lòng điền đầy đủ các trường",
|
||||
"selectAndName": "Vui lòng chọn profile và cung cấp tên",
|
||||
"profileNotFound": "Không tìm thấy profile đã chọn",
|
||||
"importedSuccess": "Đã nhập thành công profile \"{{name}}\"",
|
||||
"notInstalled": "{{browser}} chưa được cài đặt. Vui lòng tải xuống {{browser}} trước từ cửa sổ chính, sau đó thử nhập lại.",
|
||||
"importFailed": "Nhập profile thất bại: {{error}}",
|
||||
"proxyOptional": "Proxy (Tùy chọn)",
|
||||
"noProxy": "Không có proxy",
|
||||
"nextButton": "Tiếp theo",
|
||||
"importButton": "Nhập",
|
||||
"importedAs": "Profile này sẽ được nhập dưới dạng profile {{browser}}."
|
||||
"importedAs": "Profile này sẽ được nhập dưới dạng profile {{browser}}.",
|
||||
"selectAll": "Chọn tất cả",
|
||||
"selectedCount": "Đã chọn {{count}}",
|
||||
"scanButton": "Quét",
|
||||
"manualHint": "Chọn thư mục hồ sơ, thư mục dữ liệu người dùng của trình duyệt, thư mục chứa các hồ sơ đã xuất hoặc tệp ZIP.",
|
||||
"selectArchiveTitle": "Chọn tệp ZIP",
|
||||
"noProfilesInLocation": "Không tìm thấy hồ sơ nào ở vị trí này.",
|
||||
"selectAtLeastOne": "Chọn ít nhất một hồ sơ để nhập",
|
||||
"emptyNames": "Mỗi hồ sơ đã chọn cần có tên",
|
||||
"profilesToImport": "Hồ sơ sẽ nhập",
|
||||
"groupOptional": "Nhóm (tùy chọn)",
|
||||
"noGroup": "Không có nhóm",
|
||||
"createNewGroup": "Tạo nhóm mới…",
|
||||
"newGroupNamePlaceholder": "Tên nhóm mới",
|
||||
"duplicateStrategyLabel": "Nếu tên hồ sơ đã tồn tại",
|
||||
"duplicateRename": "Tự động đổi tên",
|
||||
"duplicateSkip": "Bỏ qua",
|
||||
"proxyRoundRobin": "Phân bổ proxy đã lưu (xoay vòng)",
|
||||
"importingTitle": "Đang nhập hồ sơ…",
|
||||
"importProgress": "Đã xử lý {{completed}} / {{total}}",
|
||||
"resultsSummary": "Đã nhập {{imported}}, bỏ qua {{skipped}}, thất bại {{failed}}",
|
||||
"statusImported": "Đã nhập",
|
||||
"statusSkipped": "Bỏ qua",
|
||||
"statusFailed": "Thất bại",
|
||||
"importButtonCount": "Nhập ({{count}})",
|
||||
"vpnOptional": "VPN (tùy chọn)",
|
||||
"noVpn": "Không dùng VPN",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
"configureFingerprint": "Cấu hình vân tay (tùy chọn)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Đang đồng bộ...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "Đã gửi",
|
||||
"receivedLegend": "Đã nhận",
|
||||
"tooltipSent": "↑ Đã gửi: ",
|
||||
"tooltipReceived": "↓ Đã nhận: "
|
||||
"tooltipReceived": "↓ Đã nhận: ",
|
||||
"clearHistory": "Xóa lịch sử",
|
||||
"clearHistoryTitle": "Xóa lịch sử lưu lượng",
|
||||
"clearHistoryDescription": "Xóa vĩnh viễn và an toàn toàn bộ lịch sử lưu lượng đã ghi của \"{{name}}\". Hành động này không thể hoàn tác.",
|
||||
"tabOverview": "Tổng quan",
|
||||
"tabTopDomains": "Tên miền hàng đầu",
|
||||
"searchDomains": "Tìm kiếm tên miền…",
|
||||
"noDomainMatch": "Không có tên miền nào khớp với tìm kiếm."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Không xác định",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.",
|
||||
"proxyPaymentRequired": "Proxy đã chọn yêu cầu thanh toán (402) — gói đăng ký của nó có thể đã hết hạn — nên profile chưa được tạo.",
|
||||
"vpnNotWorking": "VPN đã chọn không hoạt động, nên profile chưa được tạo.",
|
||||
"camoufoxImportDeprecated": "Không còn hỗ trợ nhập profile dựa trên Firefox. Vui lòng dùng Wayfern."
|
||||
"camoufoxImportDeprecated": "Việc nhập loại hồ sơ này không còn được hỗ trợ. Vui lòng sử dụng Wayfern thay thế.",
|
||||
"updateChecksumsUnavailable": "Không thể xác minh bản cập nhật {{version}} vì không thể tải tệp checksum. Bản cập nhật chưa được cài đặt; sẽ thử lại sau.",
|
||||
"updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.",
|
||||
"nameCannotBeEmpty": "Tên không được để trống",
|
||||
"wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}.",
|
||||
"profileNameExists": "Hồ sơ có tên \"{{name}}\" đã tồn tại",
|
||||
"importSourceNotFound": "Đường dẫn nguồn không tồn tại",
|
||||
"importNoItems": "Chưa chọn mục nào để nhập",
|
||||
"browserNotDownloaded": "Không có phiên bản {{browser}} nào đã tải xuống. Hãy tải xuống trước, sau đó thử nhập lại.",
|
||||
"archiveExtractionFailed": "Không thể giải nén tệp: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Định dạng tệp nén không được hỗ trợ. Chỉ hỗ trợ tệp ZIP.",
|
||||
"clearOnCloseUnavailable": "Tính năng xóa khi đóng không khả dụng với hồ sơ tạm thời hoặc hồ sơ được bảo vệ bằng mật khẩu.",
|
||||
"proxyAndVpnMutuallyExclusive": "Một hồ sơ chỉ có thể dùng proxy hoặc VPN, không dùng cả hai.",
|
||||
"invalidDnsRulesJson": "Tệp đã chọn không phải là JSON quy tắc DNS hợp lệ.",
|
||||
"unsupportedDnsRulesFormat": "Định dạng quy tắc không được hỗ trợ: {{format}}",
|
||||
"dnsRulesSaveFailed": "Không thể lưu quy tắc DNS.",
|
||||
"dnsRulesExportFailed": "Không thể xuất quy tắc DNS.",
|
||||
"fingerprintMatchFailed": "Không thể khớp vân tay với proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "Nhập profile",
|
||||
"importProfileHint": "Đưa profile từ công cụ khác",
|
||||
"keyboardShortcuts": "Phím tắt",
|
||||
"keyboardShortcutsHint": "Xem tất cả phím tắt"
|
||||
"keyboardShortcutsHint": "Xem tất cả phím tắt",
|
||||
"about": "Giới thiệu về Donut Browser",
|
||||
"aboutHint": "Phiên bản và thông tin ứng dụng"
|
||||
},
|
||||
"network": "Mạng",
|
||||
"integrations": "Tích hợp",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Khởi chạy {{name}}",
|
||||
"stopProfile": "Dừng {{name}}",
|
||||
"profileInfo": "Thông tin — {{name}}"
|
||||
"profileInfo": "Thông tin — {{name}}",
|
||||
"createProfile": "Tạo hồ sơ",
|
||||
"about": "Giới thiệu về Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Chưa chọn profile Wayfern nào khác"
|
||||
},
|
||||
"about": {
|
||||
"title": "Giới thiệu",
|
||||
"version": "Phiên bản {{version}}",
|
||||
"portableBadge": "bản portable",
|
||||
"licenseNotice": "Trình duyệt chống phát hiện mã nguồn mở, được cấp phép theo AGPL-3.0.",
|
||||
"website": "Trang web"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Xóa dữ liệu khi đóng",
|
||||
"description": "Xóa cookie, lịch sử và bộ nhớ đệm khi trình duyệt đóng. Tiện ích mở rộng và dấu trang được giữ lại."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Vân tay không khớp",
|
||||
"intro": "Điểm thoát proxy của \"{{name}}\" không khớp với vân tay của hồ sơ này:",
|
||||
"timezoneTitle": "Múi giờ không khớp",
|
||||
"timezoneDetail": "Nút thoát nằm ở {{exit}} nhưng vân tay báo là {{fingerprint}}.",
|
||||
"languageTitle": "Ngôn ngữ không khớp",
|
||||
"languageDetail": "Quốc gia thoát là {{country}} nhưng ngôn ngữ của vân tay là {{fingerprint}}.",
|
||||
"explainer": "Múi giờ hoặc ngôn ngữ không khớp với IP thoát là một tín hiệu chống bot rất mạnh, dù thiết bị thật của bạn không bao giờ bị lộ. Hãy căn chỉnh vân tay theo vị trí proxy để giảm bị đối xử khắt khe.",
|
||||
"dontWarnAgain": "Không cảnh báo lại cho hồ sơ này",
|
||||
"matchToProxy": "Khớp vân tay với proxy",
|
||||
"matching": "Đang khớp…",
|
||||
"matchSuccess": "Đã cập nhật vân tay để khớp với proxy. Khởi động lại hồ sơ để áp dụng."
|
||||
}
|
||||
}
|
||||
|
||||
+122
-22
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "禁用应用自动更新",
|
||||
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
|
||||
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
|
||||
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。"
|
||||
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。",
|
||||
"privacy": {
|
||||
"consistencyWarning": "指纹一致性警告",
|
||||
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不匹配时,在启动时发出警告。",
|
||||
"clearTraffic": "清除所有流量历史",
|
||||
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
|
||||
"clearTrafficSuccess": "流量历史已清除"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "搜索配置文件...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "无法修改正在运行的配置文件",
|
||||
"cantModifyLaunching": "启动期间无法修改配置文件",
|
||||
"cantModifyStopping": "停止期间无法修改配置文件",
|
||||
"cantModifyUpdating": "浏览器更新期间无法修改配置文件"
|
||||
"cantModifyUpdating": "浏览器更新期间无法修改配置文件",
|
||||
"emptyTitle": "暂无配置文件",
|
||||
"emptyHint": "创建您的第一个配置文件,或从其他浏览器导入现有配置文件。",
|
||||
"emptyCreate": "创建配置文件",
|
||||
"emptyImport": "导入配置文件",
|
||||
"emptyFilteredTitle": "未找到配置文件",
|
||||
"emptyFilteredHint": "没有符合此分组或搜索的配置文件。请尝试其他筛选条件或新建一个。"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "启动",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "个域名",
|
||||
"fresh": "最新",
|
||||
"stale": "过期",
|
||||
"notCached": "未缓存"
|
||||
"notCached": "未缓存",
|
||||
"tabBlocklists": "拦截列表",
|
||||
"tabCustom": "自定义列表",
|
||||
"custom.description": "通过源 URL 和手动规则构建您自己的拦截列表。允许的域名始终优先于被拦截的域名。",
|
||||
"custom.sourcesLabel": "拦截列表源 URL",
|
||||
"custom.sourcesPlaceholder": "每行一个 URL",
|
||||
"custom.blockLabel": "拦截的域名",
|
||||
"custom.blockPlaceholder": "每行一个域名",
|
||||
"custom.allowLabel": "允许的域名",
|
||||
"custom.allowPlaceholder": "每行一个域名",
|
||||
"custom.allowHint": "允许的域名会从编译后的拦截列表中移除,并覆盖任何拦截规则。",
|
||||
"custom.saved": "自定义 DNS 规则已保存",
|
||||
"custom.imported": "自定义 DNS 规则已导入",
|
||||
"custom.exported": "自定义 DNS 规则已导出",
|
||||
"custom.exportTxt": "导出 TXT",
|
||||
"custom.exportJson": "导出 JSON",
|
||||
"customLevel": "自定义",
|
||||
"custom.allowlistModeLabel": "允许列表模式",
|
||||
"custom.allowlistModeOn": "仅可访问下方域名,其余全部拦截。",
|
||||
"custom.allowlistModeOff": "拦截列表中的域名,允许其余所有域名。",
|
||||
"custom.allowedOnlyLabel": "允许的域名(仅限这些)",
|
||||
"custom.allowedOnlyHint": "列表中域名的子域名也会被允许。列表为空时将禁用过滤。"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "正在扫描浏览器配置文件...",
|
||||
"noneFound": "在你的系统上未找到浏览器配置文件。",
|
||||
"noneFoundHint": "如果配置文件位于自定义位置,请尝试手动导入选项。",
|
||||
"selectProfile": "选择配置文件:",
|
||||
"selectProfilePlaceholder": "选择检测到的配置文件",
|
||||
"pathLabel": "路径:",
|
||||
"browserLabel": "浏览器:",
|
||||
"newProfileName": "新配置文件名称:",
|
||||
"newProfileNamePlaceholder": "输入要导入的配置文件名称",
|
||||
"manualTitle": "手动配置文件导入",
|
||||
"browserType": "浏览器类型:",
|
||||
"loadingBrowsers": "正在加载浏览器...",
|
||||
"selectBrowserType": "选择浏览器类型",
|
||||
"profileFolderPath": "配置文件夹路径:",
|
||||
"profileFolderPlaceholder": "输入配置文件夹的完整路径",
|
||||
"browseFolderTitle": "浏览文件夹",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "选择浏览器配置文件夹",
|
||||
"folderDialogFailed": "打开文件夹对话框失败",
|
||||
"detectFailed": "检测现有浏览器配置文件失败",
|
||||
"fillFields": "请填写所有字段",
|
||||
"selectAndName": "请选择配置文件并提供名称",
|
||||
"profileNotFound": "未找到所选配置文件",
|
||||
"importedSuccess": "已成功导入配置文件「{{name}}」",
|
||||
"notInstalled": "{{browser}} 未安装。请先从主窗口下载 {{browser}},然后再尝试导入。",
|
||||
"importFailed": "导入配置文件失败: {{error}}",
|
||||
"proxyOptional": "代理 (可选)",
|
||||
"noProxy": "无代理",
|
||||
"nextButton": "下一步",
|
||||
"importButton": "导入",
|
||||
"importedAs": "此配置文件将作为 {{browser}} 配置文件导入。"
|
||||
"importedAs": "此配置文件将作为 {{browser}} 配置文件导入。",
|
||||
"selectAll": "全选",
|
||||
"selectedCount": "已选择 {{count}} 个",
|
||||
"scanButton": "扫描",
|
||||
"manualHint": "选择配置文件夹、浏览器用户数据文件夹、包含导出配置文件的文件夹或 ZIP 压缩包。",
|
||||
"selectArchiveTitle": "选择 ZIP 压缩包",
|
||||
"noProfilesInLocation": "在此位置未找到配置文件。",
|
||||
"selectAtLeastOne": "请至少选择一个要导入的配置文件",
|
||||
"emptyNames": "每个所选配置文件都需要一个名称",
|
||||
"profilesToImport": "要导入的配置文件",
|
||||
"groupOptional": "分组(可选)",
|
||||
"noGroup": "无分组",
|
||||
"createNewGroup": "创建新分组…",
|
||||
"newGroupNamePlaceholder": "新分组名称",
|
||||
"duplicateStrategyLabel": "如果配置文件名称已存在",
|
||||
"duplicateRename": "自动重命名",
|
||||
"duplicateSkip": "跳过",
|
||||
"proxyRoundRobin": "轮流分配已保存的代理(轮询)",
|
||||
"importingTitle": "正在导入配置文件…",
|
||||
"importProgress": "已处理 {{completed}} / {{total}}",
|
||||
"resultsSummary": "已导入 {{imported}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个",
|
||||
"statusImported": "已导入",
|
||||
"statusSkipped": "已跳过",
|
||||
"statusFailed": "失败",
|
||||
"importButtonCount": "导入 ({{count}})",
|
||||
"vpnOptional": "VPN(可选)",
|
||||
"noVpn": "不使用 VPN",
|
||||
"advancedOptions": "高级选项",
|
||||
"configureFingerprint": "配置指纹(可选)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同步中...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "已发送",
|
||||
"receivedLegend": "已接收",
|
||||
"tooltipSent": "↑ 已发送: ",
|
||||
"tooltipReceived": "↓ 已接收: "
|
||||
"tooltipReceived": "↓ 已接收: ",
|
||||
"clearHistory": "清除历史",
|
||||
"clearHistoryTitle": "清除流量历史",
|
||||
"clearHistoryDescription": "永久且安全地清除「{{name}}」的所有已记录流量历史。此操作无法撤销。",
|
||||
"tabOverview": "概览",
|
||||
"tabTopDomains": "热门域名",
|
||||
"searchDomains": "搜索域名…",
|
||||
"noDomainMatch": "没有与搜索匹配的域名。"
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "未知",
|
||||
@@ -1798,7 +1853,24 @@
|
||||
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
||||
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
|
||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
|
||||
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。"
|
||||
"camoufoxImportDeprecated": "不再支持导入此类型的配置文件。请改用 Wayfern。",
|
||||
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
|
||||
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。",
|
||||
"nameCannotBeEmpty": "名称不能为空",
|
||||
"wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。",
|
||||
"profileNameExists": "名为“{{name}}”的配置文件已存在",
|
||||
"importSourceNotFound": "源路径不存在",
|
||||
"importNoItems": "未选择要导入的内容",
|
||||
"browserNotDownloaded": "没有已下载的 {{browser}} 版本。请先下载,然后重试导入。",
|
||||
"archiveExtractionFailed": "解压压缩包失败:{{detail}}",
|
||||
"unsupportedArchiveFormat": "不支持的压缩包格式。仅支持 ZIP 压缩包。",
|
||||
"clearOnCloseUnavailable": "关闭时清除功能不适用于临时配置文件或受密码保护的配置文件。",
|
||||
"proxyAndVpnMutuallyExclusive": "配置文件只能使用代理或 VPN,不能同时使用两者。",
|
||||
"invalidDnsRulesJson": "所选文件不是有效的 DNS 规则 JSON。",
|
||||
"unsupportedDnsRulesFormat": "不支持的规则格式:{{format}}",
|
||||
"dnsRulesSaveFailed": "保存 DNS 规则失败。",
|
||||
"dnsRulesExportFailed": "导出 DNS 规则失败。",
|
||||
"fingerprintMatchFailed": "无法将指纹匹配到代理。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -1811,7 +1883,9 @@
|
||||
"importProfile": "导入配置文件",
|
||||
"importProfileHint": "从其他工具导入",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"keyboardShortcutsHint": "查看所有快捷键"
|
||||
"keyboardShortcutsHint": "查看所有快捷键",
|
||||
"about": "关于 Donut Browser",
|
||||
"aboutHint": "版本和应用信息"
|
||||
},
|
||||
"network": "网络",
|
||||
"integrations": "集成",
|
||||
@@ -1900,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "启动 {{name}}",
|
||||
"stopProfile": "停止 {{name}}",
|
||||
"profileInfo": "信息 — {{name}}"
|
||||
"profileInfo": "信息 — {{name}}",
|
||||
"createProfile": "创建配置文件",
|
||||
"about": "关于 Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2016,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "未选择其他 Wayfern 配置文件"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"version": "版本 {{version}}",
|
||||
"portableBadge": "便携版",
|
||||
"licenseNotice": "开源反检测浏览器,基于 AGPL-3.0 许可证发布。",
|
||||
"website": "官网"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "关闭时清除数据",
|
||||
"description": "浏览器关闭时清除 Cookie、历史记录和缓存。扩展和书签将被保留。"
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "指纹不匹配",
|
||||
"intro": "「{{name}}」的代理出口与此配置文件的指纹不匹配:",
|
||||
"timezoneTitle": "时区不匹配",
|
||||
"timezoneDetail": "出口节点位于 {{exit}},但指纹报告为 {{fingerprint}}。",
|
||||
"languageTitle": "语言不匹配",
|
||||
"languageDetail": "出口国家/地区为 {{country}},但指纹语言为 {{fingerprint}}。",
|
||||
"explainer": "时区或语言与出口 IP 不一致是强烈的反机器人信号,即使您的真实设备信息从未泄露。请让指纹与代理位置保持一致,以减少被针对的风险。",
|
||||
"dontWarnAgain": "不再为此配置文件发出警告",
|
||||
"matchToProxy": "将指纹匹配到代理",
|
||||
"matching": "匹配中…",
|
||||
"matchSuccess": "指纹已更新以匹配代理。重新启动配置文件以生效。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export type BackendErrorCode =
|
||||
| "PROXY_NOT_FOUND"
|
||||
| "GROUP_NOT_FOUND"
|
||||
| "GROUP_ALREADY_EXISTS"
|
||||
| "NAME_CANNOT_BE_EMPTY"
|
||||
| "WAYFERN_VERSION_NOT_AVAILABLE"
|
||||
| "VPN_NOT_FOUND"
|
||||
| "EXTENSION_NOT_FOUND"
|
||||
| "EXTENSION_GROUP_NOT_FOUND"
|
||||
@@ -34,6 +36,21 @@ export type BackendErrorCode =
|
||||
| "PROXY_PAYMENT_REQUIRED"
|
||||
| "VPN_NOT_WORKING"
|
||||
| "CAMOUFOX_IMPORT_DEPRECATED"
|
||||
| "UPDATE_CHECKSUMS_UNAVAILABLE"
|
||||
| "UPDATE_CHECKSUM_MISMATCH"
|
||||
| "PROFILE_NAME_EXISTS"
|
||||
| "IMPORT_SOURCE_NOT_FOUND"
|
||||
| "IMPORT_NO_ITEMS"
|
||||
| "BROWSER_NOT_DOWNLOADED"
|
||||
| "ARCHIVE_EXTRACTION_FAILED"
|
||||
| "UNSUPPORTED_ARCHIVE_FORMAT"
|
||||
| "CLEAR_ON_CLOSE_UNAVAILABLE"
|
||||
| "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE"
|
||||
| "FINGERPRINT_MATCH_FAILED"
|
||||
| "INVALID_DNS_RULES_JSON"
|
||||
| "UNSUPPORTED_DNS_RULES_FORMAT"
|
||||
| "DNS_RULES_SAVE_FAILED"
|
||||
| "DNS_RULES_EXPORT_FAILED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -116,6 +133,13 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.groupNotFound");
|
||||
case "GROUP_ALREADY_EXISTS":
|
||||
return t("backendErrors.groupAlreadyExists");
|
||||
case "NAME_CANNOT_BE_EMPTY":
|
||||
return t("backendErrors.nameCannotBeEmpty");
|
||||
case "WAYFERN_VERSION_NOT_AVAILABLE":
|
||||
return t("backendErrors.wayfernVersionNotAvailable", {
|
||||
requested: parsed.params?.requested ?? "",
|
||||
current: parsed.params?.current ?? "",
|
||||
});
|
||||
case "VPN_NOT_FOUND":
|
||||
return t("backendErrors.vpnNotFound");
|
||||
case "EXTENSION_NOT_FOUND":
|
||||
@@ -138,6 +162,48 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.vpnNotWorking");
|
||||
case "CAMOUFOX_IMPORT_DEPRECATED":
|
||||
return t("backendErrors.camoufoxImportDeprecated");
|
||||
case "UPDATE_CHECKSUMS_UNAVAILABLE":
|
||||
return t("backendErrors.updateChecksumsUnavailable", {
|
||||
version: parsed.params?.version ?? "",
|
||||
});
|
||||
case "UPDATE_CHECKSUM_MISMATCH":
|
||||
return t("backendErrors.updateChecksumMismatch", {
|
||||
file: parsed.params?.file ?? "",
|
||||
});
|
||||
case "PROFILE_NAME_EXISTS":
|
||||
return t("backendErrors.profileNameExists", {
|
||||
name: parsed.params?.name ?? "",
|
||||
});
|
||||
case "IMPORT_SOURCE_NOT_FOUND":
|
||||
return t("backendErrors.importSourceNotFound");
|
||||
case "IMPORT_NO_ITEMS":
|
||||
return t("backendErrors.importNoItems");
|
||||
case "BROWSER_NOT_DOWNLOADED":
|
||||
return t("backendErrors.browserNotDownloaded", {
|
||||
browser: parsed.params?.browser ?? "",
|
||||
});
|
||||
case "ARCHIVE_EXTRACTION_FAILED":
|
||||
return t("backendErrors.archiveExtractionFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "UNSUPPORTED_ARCHIVE_FORMAT":
|
||||
return t("backendErrors.unsupportedArchiveFormat");
|
||||
case "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE":
|
||||
return t("backendErrors.proxyAndVpnMutuallyExclusive");
|
||||
case "FINGERPRINT_MATCH_FAILED":
|
||||
return t("backendErrors.fingerprintMatchFailed");
|
||||
case "INVALID_DNS_RULES_JSON":
|
||||
return t("backendErrors.invalidDnsRulesJson");
|
||||
case "UNSUPPORTED_DNS_RULES_FORMAT":
|
||||
return t("backendErrors.unsupportedDnsRulesFormat", {
|
||||
format: parsed.params?.format ?? "",
|
||||
});
|
||||
case "DNS_RULES_SAVE_FAILED":
|
||||
return t("backendErrors.dnsRulesSaveFailed");
|
||||
case "DNS_RULES_EXPORT_FAILED":
|
||||
return t("backendErrors.dnsRulesExportFailed");
|
||||
case "CLEAR_ON_CLOSE_UNAVAILABLE":
|
||||
return t("backendErrors.clearOnCloseUnavailable");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
/**
|
||||
* Donut-sprinkle confetti: small rounded bars tinted with the active theme's
|
||||
* chart colors. Used for celebration moments (e.g. a successful profile
|
||||
* import). Callers must skip it under prefers-reduced-motion.
|
||||
*/
|
||||
|
||||
// A 12×6 capsule — reads as a donut sprinkle at small scale.
|
||||
const SPRINKLE_PATH = "M3 0 h6 a3 3 0 0 1 0 6 h-6 a3 3 0 0 1 0 -6 z";
|
||||
|
||||
function themeChartColors(): string[] {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const colors = [1, 2, 3, 4, 5]
|
||||
.map((i) => styles.getPropertyValue(`--chart-${i}`).trim())
|
||||
.filter(Boolean);
|
||||
return colors.length > 0 ? colors : ["#888888"];
|
||||
}
|
||||
|
||||
export function fireSprinkleConfetti(): void {
|
||||
const sprinkle = confetti.shapeFromPath({ path: SPRINKLE_PATH });
|
||||
const colors = themeChartColors();
|
||||
|
||||
const fire = (particleCount: number, opts: confetti.Options = {}) => {
|
||||
void confetti({
|
||||
particleCount,
|
||||
spread: 75,
|
||||
startVelocity: 42,
|
||||
scalar: 0.9,
|
||||
ticks: 130,
|
||||
shapes: [sprinkle],
|
||||
colors,
|
||||
origin: { y: 0.65 },
|
||||
...opts,
|
||||
});
|
||||
};
|
||||
|
||||
fire(70);
|
||||
window.setTimeout(() => {
|
||||
fire(45, { angle: 60, origin: { x: 0.2, y: 0.7 } });
|
||||
}, 180);
|
||||
window.setTimeout(() => {
|
||||
fire(45, { angle: 120, origin: { x: 0.8, y: 0.7 } });
|
||||
}, 360);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The DNS blocklist levels the backend accepts, mirroring
|
||||
* `BlocklistLevel::as_str` in `src-tauri/src/dns_blocklist.rs`. Ordered from
|
||||
* least to most restrictive, with `custom` (the user's own sources/rules) last.
|
||||
*
|
||||
* Every level picker reads from this list. Keeping it in one place is what
|
||||
* stops a new level from reaching some surfaces and not others — `custom` was
|
||||
* previously missing from two pickers, so a profile already set to it rendered
|
||||
* with nothing selected and could not be restored.
|
||||
*
|
||||
* Labels are translation keys, never the backend's `display_name`: that field
|
||||
* is hardcoded English and renders untranslated to every locale.
|
||||
*/
|
||||
export const DNS_BLOCKLIST_LEVELS = [
|
||||
{ value: "light", labelKey: "dnsBlocklist.light" },
|
||||
{ value: "normal", labelKey: "dnsBlocklist.normal" },
|
||||
{ value: "pro", labelKey: "dnsBlocklist.pro" },
|
||||
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
|
||||
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
|
||||
{ value: "custom", labelKey: "dnsBlocklist.customLevel" },
|
||||
] as const;
|
||||
|
||||
export type DnsBlocklistLevel = (typeof DNS_BLOCKLIST_LEVELS)[number]["value"];
|
||||
|
||||
/**
|
||||
* Translation key for a level slug. A null/empty level means no filtering, and
|
||||
* an unrecognised one (a level added backend-first) falls back to the same,
|
||||
* which is the honest reading of "we don't know this level".
|
||||
*/
|
||||
export function dnsBlocklistLabelKey(level: string | null | undefined): string {
|
||||
if (!level) {
|
||||
return "dnsBlocklist.none";
|
||||
}
|
||||
return (
|
||||
DNS_BLOCKLIST_LEVELS.find((l) => l.value === level)?.labelKey ??
|
||||
"dnsBlocklist.none"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user