mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-04 01:25:12 +02:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 828c3bb984 | |||
| ffe35c1672 | |||
| 4a4cf81255 | |||
| 77be8cadaf | |||
| 3207e4fbd3 | |||
| c18e9625fd | |||
| d06ddccd78 | |||
| 04297fc27d | |||
| 1d404833ad | |||
| f61a3905fa | |||
| 79d8b83b57 | |||
| e700b47b4c | |||
| 57167b979f | |||
| 571bfcb213 | |||
| 6721444822 | |||
| ef1dc3407f | |||
| 1162f1e9f3 | |||
| 8d524e07f4 | |||
| f8ce56481f | |||
| 97d01e4b54 | |||
| 5980ce5e8d | |||
| 4cfbcde3de | |||
| c9ae34f225 | |||
| 0b30939b8f | |||
| 3e99bffe06 | |||
| 37da41da6c | |||
| b5a8a23b55 | |||
| 32888a90b3 | |||
| 50bf6a0ea1 | |||
| 3ea80830cf | |||
| d453dfb613 | |||
| bc2bf57908 | |||
| 18b28ce0cb | |||
| ce76c1381f | |||
| 91218e08f9 | |||
| 111b6819f0 | |||
| abc96e7424 | |||
| d6ef07e98d | |||
| 07cda5119f |
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 #v3.0.0
|
||||
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 #v3.1.0
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Enable auto-merge for minor and patch updates
|
||||
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@a35b8a95c27d28e979a3826e1289d7ee87f40251 #v1.4.11
|
||||
uses: anomalyco/opencode/github@557734bd130a68188454bc691e153f9f3731830e #v1.14.31
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 #v1.45.1
|
||||
uses: crate-ci/typos@bbaefadf97b0ec5fdc942684b647f1a6ab250274 #v1.46.0
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
Vendored
-1
@@ -191,7 +191,6 @@
|
||||
"osascript",
|
||||
"oscpu",
|
||||
"outpath",
|
||||
"OVPN",
|
||||
"pango",
|
||||
"passout",
|
||||
"patchelf",
|
||||
|
||||
@@ -26,7 +26,7 @@ donutbrowser/
|
||||
│ │ ├── api_server.rs # REST API (utoipa + axum)
|
||||
│ │ ├── mcp_server.rs # MCP protocol server
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard & OpenVPN tunnels
|
||||
│ │ ├── vpn/ # WireGuard tunnels
|
||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
||||
@@ -60,6 +60,16 @@ donutbrowser/
|
||||
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
|
||||
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
|
||||
|
||||
## Translations (mandatory)
|
||||
|
||||
- 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 seven locale files in `src/i18n/locales/` (en, es, fr, ja, pt, ru, zh) — not just `en.json`. The English version alone is incomplete work.
|
||||
- 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.
|
||||
|
||||
## Singletons
|
||||
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||
|
||||
@@ -1,6 +1,94 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.22.5 (2026-04-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- declare libxdo as runtime dependency
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: copy
|
||||
- chore: update flake.nix for v0.22.4 [skip ci] (#324)
|
||||
|
||||
|
||||
## v0.22.4 (2026-04-28)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: i18n
|
||||
- chore: update flake.nix for v0.22.3 [skip ci] (#321)
|
||||
|
||||
|
||||
## v0.22.3 (2026-04-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- correct browser port mapping
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.22.2 [skip ci] (#315)
|
||||
|
||||
|
||||
## v0.22.2 (2026-04-27)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cookie management
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.22.1 [skip ci] (#313)
|
||||
|
||||
|
||||
## v0.22.1 (2026-04-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- link proper wayfern tos
|
||||
|
||||
### Refactoring
|
||||
|
||||
- vpn refresh and remove openvpn support
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CHANGELOG.md and README.md for v0.22.0 [skip ci] (#306)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- chore: audit
|
||||
- chore: update flake.nix for v0.22.0 [skip ci] (#307)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(rust)(deps): bump the rust-dependencies group across 1 directory with 34 updates (#305)
|
||||
|
||||
|
||||
## v0.22.0 (2026-04-25)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- auth and wayfern
|
||||
- cdp gates cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: tests
|
||||
- chore:cargo audit
|
||||
- chore: version bump
|
||||
- chore: ignore .claude
|
||||
- chore: update flake.nix for v0.21.2 [skip ci] (#298)
|
||||
|
||||
|
||||
## v0.21.2 (2026-04-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support** — WireGuard and OpenVPN configs per profile
|
||||
- **VPN support** — WireGuard configs per profile
|
||||
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups** — organize profiles and apply bulk settings
|
||||
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -61,15 +61,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut-0.21.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut-0.21.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut-0.22.5-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut-0.22.5-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -160,6 +160,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<br />
|
||||
<sub><b>Jory Severijnse</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ThiagoMafra-Integrare">
|
||||
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
|
||||
<br />
|
||||
<sub><b>Thiago Mafra</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
|
||||
@@ -4,7 +4,6 @@ extend-exclude = [
|
||||
"src-tauri/src/camoufox/data/*.xml",
|
||||
"src/i18n/locales/*.json",
|
||||
"src-tauri/build.rs",
|
||||
"src-tauri/tests/fixtures/test.ovpn",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
@@ -94,17 +94,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.21.2";
|
||||
releaseVersion = "0.22.5";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.AppImage";
|
||||
hash = "sha256-wHaH4CVKp7OkBQfohqA8+hU7jdYpvYj1DaqD1ow5yCg=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.AppImage";
|
||||
hash = "sha256-709vcQ3SsFxsZEmDkuamlbHVsbFhGBAb3x59YvTehl4=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.AppImage";
|
||||
hash = "sha256-OX3NyTKBYxoH4j+rmfhlNHmiTaQbrKCiFxtqODF/NKM=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.AppImage";
|
||||
hash = "sha256-T7ZrRvo7gM5mnzmXfLQXVMekf28jVOgFlfAAi89huMY=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+8
-7
@@ -2,14 +2,13 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "pnpm test:rust:unit && pnpm test:openvpn-e2e && pnpm test:sync-e2e",
|
||||
"test:openvpn-e2e": "node scripts/openvpn-test-harness.mjs",
|
||||
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
|
||||
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
|
||||
@@ -46,7 +45,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "~2.10.1",
|
||||
"@tauri-apps/api": "~2.11.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "~2.5.0",
|
||||
@@ -76,7 +75,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@tauri-apps/cli": "~2.11.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
@@ -92,10 +91,12 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0"
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
|
||||
"postcss@<8.5.10": ">=8.5.12",
|
||||
"fast-xml-parser@<5.7.0": ">=5.7.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+91
-103
@@ -7,6 +7,8 @@ settings:
|
||||
overrides:
|
||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
postcss@<8.5.10: '>=8.5.12'
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -52,8 +54,8 @@ importers:
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tauri-apps/api':
|
||||
specifier: ~2.10.1
|
||||
version: 2.10.1
|
||||
specifier: ~2.11.0
|
||||
version: 2.11.0
|
||||
'@tauri-apps/plugin-deep-link':
|
||||
specifier: ^2.4.7
|
||||
version: 2.4.7
|
||||
@@ -137,8 +139,8 @@ importers:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
'@tauri-apps/cli':
|
||||
specifier: ~2.10.1
|
||||
version: 2.10.1
|
||||
specifier: ~2.11.0
|
||||
version: 2.11.0
|
||||
'@types/color':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -1425,6 +1427,9 @@ packages:
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodable/entities@2.1.0':
|
||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
|
||||
@@ -2673,82 +2678,82 @@ packages:
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
'@tauri-apps/api@2.11.0':
|
||||
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.0':
|
||||
resolution: {integrity: sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.10.1':
|
||||
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
|
||||
'@tauri-apps/cli-darwin-x64@2.11.0':
|
||||
resolution: {integrity: sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
|
||||
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
|
||||
resolution: {integrity: sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.0':
|
||||
resolution: {integrity: sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.0':
|
||||
resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
|
||||
resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.0':
|
||||
resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.0':
|
||||
resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.0':
|
||||
resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.0':
|
||||
resolution: {integrity: sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.0':
|
||||
resolution: {integrity: sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli@2.10.1':
|
||||
resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==}
|
||||
'@tauri-apps/cli@2.11.0':
|
||||
resolution: {integrity: sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3782,11 +3787,11 @@ packages:
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-xml-builder@1.1.4:
|
||||
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
|
||||
fast-xml-builder@1.1.5:
|
||||
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
|
||||
|
||||
fast-xml-parser@5.5.8:
|
||||
resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==}
|
||||
fast-xml-parser@5.7.2:
|
||||
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
|
||||
hasBin: true
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
@@ -4690,8 +4695,8 @@ packages:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-expression-matcher@1.2.1:
|
||||
resolution: {integrity: sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==}
|
||||
path-expression-matcher@1.5.0:
|
||||
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-is-absolute@1.0.1:
|
||||
@@ -4740,16 +4745,8 @@ packages:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.8:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.9:
|
||||
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
||||
postcss@8.5.12:
|
||||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
pretty-format@30.3.0:
|
||||
@@ -5131,8 +5128,8 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strnum@2.2.2:
|
||||
resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==}
|
||||
strnum@2.2.3:
|
||||
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
@@ -6075,7 +6072,7 @@ snapshots:
|
||||
'@aws-sdk/xml-builder@3.972.16':
|
||||
dependencies:
|
||||
'@smithy/types': 4.13.1
|
||||
fast-xml-parser: 5.5.8
|
||||
fast-xml-parser: 5.7.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4': {}
|
||||
@@ -7027,6 +7024,8 @@ snapshots:
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodable/entities@2.1.0': {}
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
@@ -8333,7 +8332,7 @@ snapshots:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 4.2.2
|
||||
postcss: 8.5.8
|
||||
postcss: 8.5.12
|
||||
tailwindcss: 4.2.2
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
@@ -8344,74 +8343,74 @@ snapshots:
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tauri-apps/api@2.10.1': {}
|
||||
'@tauri-apps/api@2.11.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.10.1':
|
||||
'@tauri-apps/cli-darwin-x64@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.0':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli@2.10.1':
|
||||
'@tauri-apps/cli@2.11.0':
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 2.10.1
|
||||
'@tauri-apps/cli-darwin-x64': 2.10.1
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.10.1
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.10.1
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.1
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.10.1
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.10.1
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
'@tauri-apps/cli-darwin-arm64': 2.11.0
|
||||
'@tauri-apps/cli-darwin-x64': 2.11.0
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.0
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.11.0
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.11.0
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.0
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.11.0
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.11.0
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.11.0
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.0
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-deep-link@2.4.7':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.7.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-log@2.8.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-opener@2.5.3':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
@@ -9425,15 +9424,16 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-xml-builder@1.1.4:
|
||||
fast-xml-builder@1.1.5:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.2.1
|
||||
path-expression-matcher: 1.5.0
|
||||
|
||||
fast-xml-parser@5.5.8:
|
||||
fast-xml-parser@5.7.2:
|
||||
dependencies:
|
||||
fast-xml-builder: 1.1.4
|
||||
path-expression-matcher: 1.2.1
|
||||
strnum: 2.2.2
|
||||
'@nodable/entities': 2.1.0
|
||||
fast-xml-builder: 1.1.5
|
||||
path-expression-matcher: 1.5.0
|
||||
strnum: 2.2.3
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
dependencies:
|
||||
@@ -10360,7 +10360,7 @@ snapshots:
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.10.17
|
||||
caniuse-lite: 1.0.30001787
|
||||
postcss: 8.4.31
|
||||
postcss: 8.5.12
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(react@19.2.4)
|
||||
@@ -10457,7 +10457,7 @@ snapshots:
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-expression-matcher@1.2.1: {}
|
||||
path-expression-matcher@1.5.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
@@ -10491,19 +10491,7 @@ snapshots:
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.9:
|
||||
postcss@8.5.12:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
@@ -10992,7 +10980,7 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strnum@2.2.2: {}
|
||||
strnum@2.2.3: {}
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
@@ -11049,7 +11037,7 @@ snapshots:
|
||||
|
||||
tauri-plugin-macos-permissions-api@2.3.0:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
terser-webpack-plugin@5.4.0(webpack@5.105.4):
|
||||
dependencies:
|
||||
@@ -11298,7 +11286,7 @@ snapshots:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.9
|
||||
postcss: 8.5.12
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OpenVPN E2E Test Harness
|
||||
*
|
||||
* This script:
|
||||
* 1. Skips unless explicitly enabled via DONUTBROWSER_RUN_OPENVPN_E2E=1
|
||||
* 2. Builds the Rust vpn_integration test binary without running it
|
||||
* 3. Runs the OpenVPN e2e test binary under sudo
|
||||
*
|
||||
* Usage: DONUTBROWSER_RUN_OPENVPN_E2E=1 node scripts/openvpn-test-harness.mjs
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||
const SRC_TAURI_DIR = path.join(ROOT_DIR, "src-tauri");
|
||||
const TEST_NAME = "test_openvpn_traffic_flows_through_donut_proxy";
|
||||
|
||||
function log(message) {
|
||||
console.log(`[openvpn-harness] ${message}`);
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
console.error(`[openvpn-harness] ERROR: ${message}`);
|
||||
}
|
||||
|
||||
function shouldRun() {
|
||||
if (process.env.DONUTBROWSER_RUN_OPENVPN_E2E !== "1") {
|
||||
log("Skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform !== "linux") {
|
||||
log(`Skipping OpenVPN e2e test on unsupported platform: ${process.platform}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function buildTestBinary() {
|
||||
log("Building OpenVPN e2e test binary...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let executablePath = "";
|
||||
let stdoutBuffer = "";
|
||||
|
||||
const proc = spawn(
|
||||
"cargo",
|
||||
[
|
||||
"test",
|
||||
"--test",
|
||||
"vpn_integration",
|
||||
TEST_NAME,
|
||||
"--no-run",
|
||||
"--message-format=json",
|
||||
],
|
||||
{
|
||||
cwd: SRC_TAURI_DIR,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
const parseBuffer = (flush = false) => {
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
const completeLines = flush ? lines : lines.slice(0, -1);
|
||||
stdoutBuffer = flush ? "" : lines.at(-1) ?? "";
|
||||
|
||||
for (const line of completeLines.filter(Boolean)) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.reason === "compiler-artifact" && message.executable) {
|
||||
executablePath = message.executable;
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdoutBuffer += data.toString();
|
||||
parseBuffer();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
parseBuffer(true);
|
||||
|
||||
if (code !== 0) {
|
||||
reject(new Error(`cargo test --no-run exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!executablePath) {
|
||||
reject(new Error("Could not determine the vpn_integration test binary path"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(path.isAbsolute(executablePath) ? executablePath : path.resolve(SRC_TAURI_DIR, executablePath));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runOpenVpnE2e(executablePath) {
|
||||
log("Running OpenVPN e2e test under sudo...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"sudo",
|
||||
[
|
||||
"--preserve-env=CI,GITHUB_ACTIONS,VPN_TEST_OVPN_HOST,VPN_TEST_OVPN_PORT,DONUTBROWSER_RUN_OPENVPN_E2E",
|
||||
executablePath,
|
||||
TEST_NAME,
|
||||
"--exact",
|
||||
"--nocapture",
|
||||
],
|
||||
{
|
||||
cwd: SRC_TAURI_DIR,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
}
|
||||
);
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!shouldRun()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const executablePath = await buildTestBinary();
|
||||
const exitCode = await runOpenVpnE2e(executablePath);
|
||||
process.exit(exitCode);
|
||||
} catch (err) {
|
||||
error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Generated
+234
-691
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.22.0"
|
||||
version = "0.22.6"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -73,7 +73,7 @@ once_cell = "1"
|
||||
urlencoding = "2.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
axum = { version = "0.8.8", features = ["ws"] }
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
rand = "0.10.1"
|
||||
@@ -102,7 +102,7 @@ serde_yaml = "0.9"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
maxminddb = "0.27"
|
||||
maxminddb = "0.28"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
@@ -110,8 +110,7 @@ boringtun = "0.7"
|
||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
|
||||
# Daemon dependencies (tray icon)
|
||||
tray-icon = "0.22"
|
||||
muda = "0.17"
|
||||
tray-icon = "0.23"
|
||||
tao = "0.35"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
|
||||
+334
-3
@@ -41,6 +41,7 @@ pub struct ApiProfile {
|
||||
pub tags: Vec<String>,
|
||||
pub is_running: bool,
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
@@ -60,6 +61,7 @@ pub struct CreateProfileRequest {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
@@ -76,6 +78,7 @@ pub struct UpdateProfileRequest {
|
||||
pub browser: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
@@ -130,6 +133,49 @@ struct UpdateProxyRequest {
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
struct ApiVpnResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Always "WireGuard"
|
||||
vpn_type: String,
|
||||
created_at: i64,
|
||||
last_used: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct ApiVpnExportResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Always "WireGuard"
|
||||
vpn_type: String,
|
||||
/// Raw `.conf` file content (decrypted)
|
||||
config_data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct ImportVpnRequest {
|
||||
/// Raw WireGuard `.conf` file content
|
||||
content: String,
|
||||
/// Original filename
|
||||
filename: String,
|
||||
/// Optional display name; defaults to filename-based name
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct CreateVpnRequest {
|
||||
name: String,
|
||||
/// Must be "WireGuard"
|
||||
vpn_type: String,
|
||||
config_data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct UpdateVpnRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct DownloadBrowserRequest {
|
||||
browser: String,
|
||||
@@ -191,6 +237,12 @@ struct OpenUrlRequest {
|
||||
create_proxy,
|
||||
update_proxy,
|
||||
delete_proxy,
|
||||
get_vpns,
|
||||
get_vpn,
|
||||
import_vpn,
|
||||
create_vpn,
|
||||
update_vpn,
|
||||
delete_vpn,
|
||||
download_browser_api,
|
||||
get_browser_versions,
|
||||
check_browser_downloaded,
|
||||
@@ -207,6 +259,10 @@ struct OpenUrlRequest {
|
||||
ApiProxyResponse,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
ApiVpnResponse,
|
||||
ImportVpnRequest,
|
||||
CreateVpnRequest,
|
||||
UpdateVpnRequest,
|
||||
DownloadBrowserRequest,
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
@@ -219,6 +275,7 @@ struct OpenUrlRequest {
|
||||
(name = "groups", description = "Group management endpoints"),
|
||||
(name = "tags", description = "Tag management endpoints"),
|
||||
(name = "proxies", description = "Proxy management endpoints"),
|
||||
(name = "vpns", description = "VPN management endpoints"),
|
||||
(name = "browsers", description = "Browser management endpoints"),
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
@@ -311,6 +368,10 @@ impl ApiServer {
|
||||
.routes(routes!(get_tags))
|
||||
.routes(routes!(get_proxies, create_proxy))
|
||||
.routes(routes!(get_proxy, update_proxy, delete_proxy))
|
||||
.routes(routes!(get_vpns, create_vpn))
|
||||
.routes(routes!(import_vpn))
|
||||
.routes(routes!(export_vpn))
|
||||
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
||||
.routes(routes!(get_extensions))
|
||||
.routes(routes!(delete_extension_api))
|
||||
.routes(routes!(get_extension_groups))
|
||||
@@ -495,6 +556,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -551,6 +613,7 @@ async fn get_profile(
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
@@ -605,7 +668,7 @@ async fn create_profile(
|
||||
&request.version,
|
||||
request.release_type.as_deref().unwrap_or("stable"),
|
||||
request.proxy_id.clone(),
|
||||
None, // vpn_id
|
||||
request.vpn_id.clone(),
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
request.group_id.clone(),
|
||||
@@ -653,6 +716,7 @@ async fn create_profile(
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules,
|
||||
vpn_id: profile.vpn_id,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -686,6 +750,12 @@ async fn update_profile(
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty())
|
||||
&& request.vpn_id.as_deref().is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update profile fields
|
||||
if let Some(new_name) = request.name {
|
||||
if profile_manager
|
||||
@@ -715,6 +785,21 @@ async fn update_profile(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(vpn_id) = request.vpn_id {
|
||||
let normalized = if vpn_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(vpn_id)
|
||||
};
|
||||
if profile_manager
|
||||
.update_profile_vpn(state.app_handle.clone(), &id, normalized)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(launch_hook) = request.launch_hook {
|
||||
let normalized = if launch_hook.trim().is_empty() {
|
||||
None
|
||||
@@ -1189,6 +1274,243 @@ async fn delete_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
// API Handlers - VPNs
|
||||
|
||||
fn vpn_to_api_response(c: &crate::vpn::VpnConfig) -> ApiVpnResponse {
|
||||
ApiVpnResponse {
|
||||
id: c.id.clone(),
|
||||
name: c.name.clone(),
|
||||
vpn_type: c.vpn_type.to_string(),
|
||||
created_at: c.created_at,
|
||||
last_used: c.last_used,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_vpn_type(s: &str) -> Option<crate::vpn::VpnType> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"wireguard" | "wg" => Some(crate::vpn::VpnType::WireGuard),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/vpns",
|
||||
responses(
|
||||
(status = 200, description = "List of all VPN configurations", body = Vec<ApiVpnResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn get_vpns(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<ApiVpnResponse>>, StatusCode> {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let configs = storage
|
||||
.list_configs()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(configs.iter().map(vpn_to_api_response).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/vpns/{id}",
|
||||
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration details", body = ApiVpnResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "VPN configuration not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn get_vpn(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let configs = storage
|
||||
.list_configs()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
configs
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| Json(vpn_to_api_response(c)))
|
||||
.ok_or(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/vpns/{id}/export",
|
||||
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||
responses(
|
||||
(status = 200, description = "Decrypted VPN configuration", body = ApiVpnExportResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "VPN configuration not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn export_vpn(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiVpnExportResponse>, StatusCode> {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match storage.load_config(&id) {
|
||||
Ok(config) => Ok(Json(ApiVpnExportResponse {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
vpn_type: config.vpn_type.to_string(),
|
||||
config_data: config.config_data,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/vpns/import",
|
||||
request_body = ImportVpnRequest,
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration imported successfully", body = ApiVpnResponse),
|
||||
(status = 400, description = "Invalid or unrecognized VPN config"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn import_vpn(
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<ImportVpnRequest>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.import_config(&request.content, &request.filename, request.name)
|
||||
};
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(Json(vpn_to_api_response(&config)))
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/vpns",
|
||||
request_body = CreateVpnRequest,
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration created successfully", body = ApiVpnResponse),
|
||||
(status = 400, description = "Invalid VPN config or unknown vpn_type"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn create_vpn(
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<CreateVpnRequest>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let vpn_type = parse_vpn_type(&request.vpn_type).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.create_config_manual(&request.name, vpn_type, &request.config_data)
|
||||
};
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(Json(vpn_to_api_response(&config)))
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v1/vpns/{id}",
|
||||
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||
request_body = UpdateVpnRequest,
|
||||
responses(
|
||||
(status = 200, description = "VPN configuration updated successfully", body = ApiVpnResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "VPN configuration not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn update_vpn(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateVpnRequest>,
|
||||
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.update_config_name(&id, &request.name)
|
||||
};
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(Json(vpn_to_api_response(&config)))
|
||||
}
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/v1/vpns/{id}",
|
||||
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||
responses(
|
||||
(status = 204, description = "VPN configuration deleted successfully"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "VPN configuration not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "vpns"
|
||||
)]
|
||||
async fn delete_vpn(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(&id).await;
|
||||
|
||||
let result = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
storage.delete_config(&id)
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
// Extension API endpoints
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1331,8 +1653,17 @@ async fn run_profile(
|
||||
.await
|
||||
.map_err(|_| StatusCode::CONFLICT)?;
|
||||
|
||||
// Generate a random port for remote debugging
|
||||
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
|
||||
let remote_debugging_port = {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.port();
|
||||
drop(listener);
|
||||
port
|
||||
};
|
||||
|
||||
// Use the same launch method as the main app, but with remote debugging enabled
|
||||
match crate::browser_runner::launch_browser_profile_with_debugging(
|
||||
|
||||
@@ -11,11 +11,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use muda::MenuEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tao::event::{Event, StartCause};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tokio::runtime::Runtime;
|
||||
use tray_icon::menu::MenuEvent;
|
||||
use tray_icon::TrayIcon;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use tray_icon::{MouseButton, TrayIconEvent};
|
||||
|
||||
@@ -490,23 +490,10 @@ async fn main() {
|
||||
|
||||
let server =
|
||||
donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port);
|
||||
if let Err(e) = server.run(id.clone()).await {
|
||||
log::error!("VPN worker failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
"openvpn" => {
|
||||
let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse OpenVPN config: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let server =
|
||||
donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port);
|
||||
if let Err(e) = server.run(id.clone()).await {
|
||||
if let Err(e) = server
|
||||
.run(id.clone(), config_path.map(std::path::PathBuf::from))
|
||||
.await
|
||||
{
|
||||
log::error!("VPN worker failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
@@ -50,20 +50,6 @@ pub mod chrome_decrypt {
|
||||
key
|
||||
}
|
||||
|
||||
/// Get the encryption key for Chrome cookies.
|
||||
///
|
||||
/// Wayfern stores `os_crypt_key` as a plain file inside the profile's
|
||||
/// user-data-dir on all platforms (see the wayfern patches for
|
||||
/// `os_crypt_mac.mm` and `os_crypt_linux.cc`). The file contains a
|
||||
/// base64-encoded 128-bit random value that is used as the PBKDF2
|
||||
/// password — not as the raw AES key — matching Chromium's
|
||||
/// `OSCryptImpl::DeriveKey` flow.
|
||||
///
|
||||
/// If the file is missing we return `None`. We must NEVER fall back to the
|
||||
/// real macOS Keychain or any other system credential store. Wayfern
|
||||
/// profiles are fully self-contained and reaching into another app's entry
|
||||
/// would trigger the macOS "confidential information stored in …" prompt
|
||||
/// and the "prevented from modifying other apps" warning.
|
||||
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
|
||||
let key_file = profile_data_path.join("os_crypt_key");
|
||||
// Read as raw bytes and do NOT trim — Chromium's `ReadFileToString`
|
||||
@@ -186,32 +172,34 @@ impl CookieManager {
|
||||
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
|
||||
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
|
||||
|
||||
/// Get the Chrome cookie encryption key for a Wayfern profile
|
||||
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
chrome_decrypt::get_encryption_key(&profile_data_path)
|
||||
}
|
||||
|
||||
fn wayfern_cookie_path(profile_data_path: &Path) -> PathBuf {
|
||||
let default_dir = profile_data_path.join("Default");
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
default_dir.join("Network").join("Cookies")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
default_dir.join("Cookies")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the cookie database path for a profile (read-side: errors if missing).
|
||||
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
|
||||
match profile.browser.as_str() {
|
||||
"wayfern" => {
|
||||
let network_path = profile_data_path
|
||||
.join("Default")
|
||||
.join("Network")
|
||||
.join("Cookies");
|
||||
let legacy_path = profile_data_path.join("Default").join("Cookies");
|
||||
if network_path.exists() {
|
||||
Ok(network_path)
|
||||
} else if legacy_path.exists() {
|
||||
Ok(legacy_path)
|
||||
let path = Self::wayfern_cookie_path(&profile_data_path);
|
||||
if path.exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Cookie database not found at: {}",
|
||||
network_path.display()
|
||||
))
|
||||
Err(format!("Cookie database not found at: {}", path.display()))
|
||||
}
|
||||
}
|
||||
"camoufox" => {
|
||||
@@ -241,21 +229,11 @@ impl CookieManager {
|
||||
|
||||
match profile.browser.as_str() {
|
||||
"wayfern" => {
|
||||
let network_path = profile_data_path
|
||||
.join("Default")
|
||||
.join("Network")
|
||||
.join("Cookies");
|
||||
let legacy_path = profile_data_path.join("Default").join("Cookies");
|
||||
if network_path.exists() {
|
||||
Ok(network_path)
|
||||
} else if legacy_path.exists() {
|
||||
Ok(legacy_path)
|
||||
} else {
|
||||
let dir = network_path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create Network dir: {e}"))?;
|
||||
Self::create_empty_chrome_cookies_db(&network_path)?;
|
||||
Ok(network_path)
|
||||
let path = Self::wayfern_cookie_path(&profile_data_path);
|
||||
if !path.exists() {
|
||||
Self::create_empty_chrome_cookies_db(&path)?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
"camoufox" => {
|
||||
let path = profile_data_path.join("cookies.sqlite");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use muda::{Menu, MenuItem};
|
||||
use std::process::Command;
|
||||
use tray_icon::menu::{Menu, MenuItem};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
pub fn load_icon() -> Icon {
|
||||
|
||||
+10
-41
@@ -675,11 +675,17 @@ fn find_claude_cli() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_mcp_in_claude_code() -> Result<bool, String> {
|
||||
async fn is_mcp_in_claude_code() -> Result<bool, String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
let output = std::process::Command::new(&cli)
|
||||
// `claude mcp list` health-checks every registered MCP server, so a
|
||||
// missing or stalled server can hang the call for many seconds. Cap it
|
||||
// — for this dialog, a slow `claude` is treated the same as "not registered".
|
||||
let fut = tokio::process::Command::new(&cli)
|
||||
.args(["mcp", "list"])
|
||||
.output()
|
||||
.output();
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
|
||||
.await
|
||||
.map_err(|_| "claude mcp list timed out".to_string())?
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.contains("donut-browser"))
|
||||
@@ -769,42 +775,6 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
|
||||
}
|
||||
|
||||
// VPN commands
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VpnDependencyStatus {
|
||||
is_available: bool,
|
||||
requires_external_install: bool,
|
||||
missing_binary: bool,
|
||||
missing_windows_adapter: bool,
|
||||
dependency_check_failed: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_vpn_dependency_status(vpn_type: vpn::VpnType) -> Result<VpnDependencyStatus, String> {
|
||||
match vpn_type {
|
||||
vpn::VpnType::WireGuard => Ok(VpnDependencyStatus {
|
||||
is_available: true,
|
||||
requires_external_install: false,
|
||||
missing_binary: false,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
}),
|
||||
vpn::VpnType::OpenVPN => {
|
||||
let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status();
|
||||
let is_available =
|
||||
status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed;
|
||||
|
||||
Ok(VpnDependencyStatus {
|
||||
is_available,
|
||||
requires_external_install: true,
|
||||
missing_binary: !status.binary_found,
|
||||
missing_windows_adapter: status.missing_windows_adapter,
|
||||
dependency_check_failed: status.dependency_check_failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_vpn_config(
|
||||
content: String,
|
||||
@@ -1268,7 +1238,7 @@ pub fn run() {
|
||||
#[allow(unused_variables)]
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("Donut Browser")
|
||||
.inner_size(800.0, 500.0)
|
||||
.inner_size(840.0, 500.0)
|
||||
.resizable(false)
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
@@ -2075,7 +2045,6 @@ pub fn run() {
|
||||
add_mcp_to_claude_code,
|
||||
remove_mcp_from_claude_code,
|
||||
// VPN commands
|
||||
get_vpn_dependency_status,
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
get_vpn_config,
|
||||
|
||||
@@ -848,17 +848,17 @@ impl McpServer {
|
||||
// VPN management tools
|
||||
McpTool {
|
||||
name: "import_vpn".to_string(),
|
||||
description: "Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration".to_string(),
|
||||
description: "Import a WireGuard (.conf) configuration".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Raw VPN config file content"
|
||||
"description": "Raw WireGuard config file content"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Original filename (.conf or .ovpn) for type detection"
|
||||
"description": "Original filename (.conf)"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
|
||||
+13
-197
@@ -11,8 +11,6 @@ pub enum VpnError {
|
||||
UnknownFormat,
|
||||
#[error("Invalid WireGuard config: {0}")]
|
||||
InvalidWireGuard(String),
|
||||
#[error("Invalid OpenVPN config: {0}")]
|
||||
InvalidOpenVpn(String),
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Connection error: {0}")]
|
||||
@@ -31,14 +29,12 @@ pub enum VpnError {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VpnType {
|
||||
WireGuard,
|
||||
OpenVPN,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VpnType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VpnType::WireGuard => write!(f, "WireGuard"),
|
||||
VpnType::OpenVPN => write!(f, "OpenVPN"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,19 +68,6 @@ pub struct WireGuardConfig {
|
||||
pub preshared_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed OpenVPN configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenVpnConfig {
|
||||
pub raw_config: String,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
pub protocol: String, // "udp" or "tcp"
|
||||
pub dev_type: String, // "tun" or "tap"
|
||||
pub has_inline_ca: bool,
|
||||
pub has_inline_cert: bool,
|
||||
pub has_inline_key: bool,
|
||||
}
|
||||
|
||||
/// Result of importing a VPN configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnImportResult {
|
||||
@@ -110,26 +93,16 @@ pub struct VpnStatus {
|
||||
pub fn detect_vpn_type(content: &str, filename: &str) -> Result<VpnType, VpnError> {
|
||||
let filename_lower = filename.to_lowercase();
|
||||
|
||||
// Check file extension first
|
||||
if filename_lower.ends_with(".conf") {
|
||||
// .conf could be WireGuard - check content
|
||||
if content.contains("[Interface]") && content.contains("[Peer]") {
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
}
|
||||
|
||||
if filename_lower.ends_with(".ovpn") {
|
||||
return Ok(VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
// Check content patterns
|
||||
if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]")
|
||||
if filename_lower.ends_with(".conf")
|
||||
&& content.contains("[Interface]")
|
||||
&& content.contains("[Peer]")
|
||||
{
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
|
||||
if content.contains("remote ") && (content.contains("client") || content.contains("dev tun")) {
|
||||
return Ok(VpnType::OpenVPN);
|
||||
if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]")
|
||||
{
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
|
||||
Err(VpnError::UnknownFormat)
|
||||
@@ -254,75 +227,6 @@ fn validate_wireguard_key(key: &str, field: &str) -> Result<(), VpnError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an OpenVPN configuration file
|
||||
pub fn parse_openvpn_config(content: &str) -> Result<OpenVpnConfig, VpnError> {
|
||||
let mut remote_host = String::new();
|
||||
let mut remote_port: u16 = 1194; // Default OpenVPN port
|
||||
let mut protocol = "udp".to_string();
|
||||
let mut dev_type = "tun".to_string();
|
||||
|
||||
let has_inline_ca = content.contains("<ca>") && content.contains("</ca>");
|
||||
let has_inline_cert = content.contains("<cert>") && content.contains("</cert>");
|
||||
let has_inline_key = content.contains("<key>") && content.contains("</key>");
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match parts[0] {
|
||||
"remote" => {
|
||||
if parts.len() >= 2 {
|
||||
remote_host = parts[1].to_string();
|
||||
}
|
||||
if let Some(port) = parts.get(2).and_then(|p| p.parse().ok()) {
|
||||
remote_port = port;
|
||||
}
|
||||
if parts.len() >= 4 {
|
||||
protocol = parts[3].to_string();
|
||||
}
|
||||
}
|
||||
"proto" if parts.len() >= 2 => {
|
||||
protocol = parts[1].to_string();
|
||||
}
|
||||
"port" => {
|
||||
if let Some(port) = parts.get(1).and_then(|p| p.parse().ok()) {
|
||||
remote_port = port;
|
||||
}
|
||||
}
|
||||
"dev" if parts.len() >= 2 => {
|
||||
dev_type = parts[1].to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if remote_host.is_empty() {
|
||||
return Err(VpnError::InvalidOpenVpn(
|
||||
"Missing 'remote' directive".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(OpenVpnConfig {
|
||||
raw_config: content.to_string(),
|
||||
remote_host,
|
||||
remote_port,
|
||||
protocol,
|
||||
dev_type,
|
||||
has_inline_ca,
|
||||
has_inline_cert,
|
||||
has_inline_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -336,15 +240,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_openvpn_by_extension() {
|
||||
let content = "client\nremote vpn.example.com 1194";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "test.ovpn").unwrap(),
|
||||
VpnType::OpenVPN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_wireguard_by_content() {
|
||||
let content = "[Interface]\nPrivateKey = testkey123\nAddress = 10.0.0.2/24\n\n[Peer]\nPublicKey = peerkey456\nEndpoint = vpn.example.com:51820";
|
||||
@@ -354,21 +249,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_openvpn_by_content() {
|
||||
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "config").unwrap(),
|
||||
VpnType::OpenVPN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_unknown_format() {
|
||||
let content = "random text that is not a vpn config";
|
||||
assert!(detect_vpn_type(content, "random.txt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_openvpn_content() {
|
||||
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
|
||||
assert!(detect_vpn_type(content, "test.ovpn").is_err());
|
||||
assert!(detect_vpn_type(content, "config").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wireguard_config() {
|
||||
let content = r#"
|
||||
@@ -444,81 +337,4 @@ Endpoint = 1.2.3.4:51820
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("PrivateKey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.example.com 1194
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
<ca>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...certificate data...
|
||||
-----END CERTIFICATE-----
|
||||
</ca>
|
||||
<cert>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...cert data...
|
||||
-----END CERTIFICATE-----
|
||||
</cert>
|
||||
<key>
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
...key data...
|
||||
-----END PRIVATE KEY-----
|
||||
</key>
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 1194);
|
||||
assert_eq!(config.protocol, "udp");
|
||||
assert_eq!(config.dev_type, "tun");
|
||||
assert!(config.has_inline_ca);
|
||||
assert!(config.has_inline_cert);
|
||||
assert!(config.has_inline_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config_minimal() {
|
||||
let content = r#"
|
||||
client
|
||||
remote vpn.example.com
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 1194); // Default
|
||||
assert_eq!(config.protocol, "udp"); // Default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config_with_port_and_proto() {
|
||||
let content = r#"
|
||||
client
|
||||
remote vpn.example.com 443 tcp
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 443);
|
||||
assert_eq!(config.protocol, "tcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_missing_remote() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
"#;
|
||||
|
||||
let result = parse_openvpn_config(content);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("remote"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
//! VPN support module for WireGuard and OpenVPN configurations.
|
||||
//! VPN support module for WireGuard configurations.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - VPN config parsing (WireGuard .conf and OpenVPN .ovpn files)
|
||||
//! - WireGuard config parsing (`.conf` files)
|
||||
//! - Encrypted storage for VPN configurations
|
||||
//! - Tunnel management with userspace WireGuard (boringtun) and OpenVPN process management
|
||||
//! - Tunnel management with userspace WireGuard (boringtun) routed through smoltcp
|
||||
|
||||
mod config;
|
||||
mod openvpn;
|
||||
pub mod openvpn_socks5;
|
||||
pub mod socks5_server;
|
||||
mod storage;
|
||||
mod tunnel;
|
||||
mod wireguard;
|
||||
|
||||
pub use config::{
|
||||
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
|
||||
VpnError, VpnImportResult, VpnStatus, VpnType, WireGuardConfig,
|
||||
detect_vpn_type, parse_wireguard_config, VpnConfig, VpnError, VpnImportResult, VpnStatus,
|
||||
VpnType, WireGuardConfig,
|
||||
};
|
||||
pub use openvpn::OpenVpnTunnel;
|
||||
pub use storage::VpnStorage;
|
||||
pub use tunnel::{TunnelManager, VpnTunnel};
|
||||
pub use wireguard::WireGuardTunnel;
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
//! OpenVPN tunnel implementation using system openvpn binary.
|
||||
|
||||
use super::config::{OpenVpnConfig, VpnError, VpnStatus};
|
||||
use super::tunnel::VpnTunnel;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// OpenVPN tunnel implementation
|
||||
pub struct OpenVpnTunnel {
|
||||
vpn_id: String,
|
||||
config: OpenVpnConfig,
|
||||
process: Arc<Mutex<Option<Child>>>,
|
||||
config_file: Option<NamedTempFile>,
|
||||
connected: AtomicBool,
|
||||
connected_at: Option<i64>,
|
||||
bytes_sent: AtomicU64,
|
||||
bytes_received: AtomicU64,
|
||||
}
|
||||
|
||||
impl OpenVpnTunnel {
|
||||
/// Create a new OpenVPN tunnel
|
||||
pub fn new(vpn_id: String, config: OpenVpnConfig) -> Self {
|
||||
Self {
|
||||
vpn_id,
|
||||
config,
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
config_file: None,
|
||||
connected: AtomicBool::new(false),
|
||||
connected_at: None,
|
||||
bytes_sent: AtomicU64::new(0),
|
||||
bytes_received: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the openvpn binary
|
||||
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
// Check common locations
|
||||
let locations = [
|
||||
"/usr/sbin/openvpn",
|
||||
"/usr/local/sbin/openvpn",
|
||||
"/opt/homebrew/bin/openvpn",
|
||||
"/usr/bin/openvpn",
|
||||
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
|
||||
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
|
||||
];
|
||||
|
||||
for loc in &locations {
|
||||
let path = PathBuf::from(loc);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find via which/where command
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("openvpn").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
if let Ok(output) = Command::new("where")
|
||||
.arg("openvpn")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Write config to temporary file
|
||||
fn write_config_file(&mut self) -> Result<PathBuf, VpnError> {
|
||||
let temp_file =
|
||||
NamedTempFile::new().map_err(|e| VpnError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
std::fs::write(temp_file.path(), &self.config.raw_config).map_err(VpnError::Io)?;
|
||||
|
||||
let path = temp_file.path().to_path_buf();
|
||||
self.config_file = Some(temp_file);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Start the OpenVPN process
|
||||
async fn start_process(&mut self) -> Result<(), VpnError> {
|
||||
let openvpn_bin = Self::find_openvpn_binary()?;
|
||||
let config_path = self.write_config_file()?;
|
||||
|
||||
log::info!(
|
||||
"[vpn] Starting OpenVPN with config: {}",
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
// Build command with common options
|
||||
let mut cmd = Command::new(&openvpn_bin);
|
||||
cmd
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.arg("--verb")
|
||||
.arg("3") // Verbosity level
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Unix, try to avoid requiring root if possible
|
||||
#[cfg(unix)]
|
||||
{
|
||||
cmd.arg("--script-security").arg("2");
|
||||
}
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
|
||||
|
||||
*self.process.lock().await = Some(child);
|
||||
|
||||
// Wait a bit and check if process is still running
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
let mut process_guard = self.process.lock().await;
|
||||
if let Some(ref mut child) = *process_guard {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
// Process exited early
|
||||
let mut error_msg = format!("OpenVPN exited with status: {status}");
|
||||
|
||||
// Try to get stderr output
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let lines: Vec<String> = reader.lines().map_while(Result::ok).take(5).collect();
|
||||
if !lines.is_empty() {
|
||||
error_msg.push_str(&format!("\nError: {}", lines.join("\n")));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(VpnError::Connection(error_msg));
|
||||
}
|
||||
Ok(None) => {
|
||||
// Still running, good
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Failed to check process status: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kill the OpenVPN process
|
||||
async fn kill_process(&mut self) -> Result<(), VpnError> {
|
||||
let mut process_guard = self.process.lock().await;
|
||||
|
||||
if let Some(mut child) = process_guard.take() {
|
||||
// Try graceful shutdown first
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
if let Ok(pid) = child.id().try_into() {
|
||||
let _ = kill(Pid::from_raw(pid), Signal::SIGTERM);
|
||||
// Wait a bit for graceful shutdown
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
// Clean up config file
|
||||
self.config_file = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpnTunnel for OpenVpnTunnel {
|
||||
async fn connect(&mut self) -> Result<(), VpnError> {
|
||||
if self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Start OpenVPN process
|
||||
self.start_process().await?;
|
||||
|
||||
// Wait for connection to be established
|
||||
// Note: In a real implementation, we'd monitor the OpenVPN management interface
|
||||
// For now, we assume success if the process starts and runs for a bit
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Check if process is still running
|
||||
let process_guard = self.process.lock().await;
|
||||
if let Some(ref child) = *process_guard {
|
||||
let id = child.id();
|
||||
if id > 0 {
|
||||
self.connected.store(true, Ordering::Release);
|
||||
self.connected_at = Some(Utc::now().timestamp());
|
||||
log::info!("[vpn] OpenVPN tunnel {} connected (PID: {id})", self.vpn_id);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"Failed to establish OpenVPN connection".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), VpnError> {
|
||||
if !self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.kill_process().await?;
|
||||
|
||||
self.connected.store(false, Ordering::Release);
|
||||
self.connected_at = None;
|
||||
|
||||
log::info!("[vpn] OpenVPN tunnel {} disconnected", self.vpn_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn vpn_id(&self) -> &str {
|
||||
&self.vpn_id
|
||||
}
|
||||
|
||||
fn get_status(&self) -> VpnStatus {
|
||||
VpnStatus {
|
||||
connected: self.is_connected(),
|
||||
vpn_id: self.vpn_id.clone(),
|
||||
connected_at: self.connected_at,
|
||||
bytes_sent: Some(self.bytes_sent.load(Ordering::Relaxed)),
|
||||
bytes_received: Some(self.bytes_received.load(Ordering::Relaxed)),
|
||||
last_handshake: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_sent(&self) -> u64 {
|
||||
self.bytes_sent.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn bytes_received(&self) -> u64 {
|
||||
self.bytes_received.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpenVpnTunnel {
|
||||
fn drop(&mut self) {
|
||||
// Clean up process on drop (synchronously)
|
||||
if let Ok(mut guard) = self.process.try_lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_config() -> OpenVpnConfig {
|
||||
OpenVpnConfig {
|
||||
raw_config: "client\nremote test.example.com 1194\ndev tun".to_string(),
|
||||
remote_host: "test.example.com".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
dev_type: "tun".to_string(),
|
||||
has_inline_ca: false,
|
||||
has_inline_cert: false,
|
||||
has_inline_key: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_tunnel_creation() {
|
||||
let config = create_test_config();
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn-1".to_string(), config);
|
||||
|
||||
assert_eq!(tunnel.vpn_id(), "test-ovpn-1");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_status() {
|
||||
let config = create_test_config();
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn-2".to_string(), config);
|
||||
|
||||
let status = tunnel.get_status();
|
||||
assert!(!status.connected);
|
||||
assert_eq!(status.vpn_id, "test-ovpn-2");
|
||||
assert!(status.connected_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_openvpn_binary_format() {
|
||||
// This test just checks that the function doesn't panic
|
||||
// It may or may not find openvpn depending on the system
|
||||
let result = OpenVpnTunnel::find_openvpn_binary();
|
||||
// Just check that it returns a valid Result
|
||||
match result {
|
||||
Ok(path) => assert!(!path.as_os_str().is_empty()),
|
||||
Err(e) => assert!(e.to_string().contains("not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
use super::config::{OpenVpnConfig, VpnError};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{lookup_host, TcpListener, TcpSocket, TcpStream};
|
||||
|
||||
const OPENVPN_CONNECT_TIMEOUT_SECS: u64 = 90;
|
||||
|
||||
enum SocksTarget {
|
||||
Address(SocketAddr),
|
||||
Domain(String, u16),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct OpenVpnDependencyStatus {
|
||||
pub binary_found: bool,
|
||||
pub missing_windows_adapter: bool,
|
||||
pub dependency_check_failed: bool,
|
||||
}
|
||||
|
||||
pub struct OpenVpnSocks5Server {
|
||||
config: OpenVpnConfig,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl OpenVpnSocks5Server {
|
||||
pub fn new(config: OpenVpnConfig, port: u16) -> Self {
|
||||
Self { config, port }
|
||||
}
|
||||
|
||||
fn read_log_tail(path: &Path, lines: usize) -> String {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.rev()
|
||||
.take(lines)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn extract_vpn_ip(line: &str) -> Option<Ipv4Addr> {
|
||||
for field in line.split(',') {
|
||||
let trimmed = field.trim();
|
||||
if let Ok(ip) = trimmed.parse::<Ipv4Addr>() {
|
||||
if ip.is_private() && !ip.is_loopback() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn log_indicates_connected(log_content: &str) -> bool {
|
||||
log_content.contains("Initialization Sequence Completed")
|
||||
}
|
||||
|
||||
fn log_indicates_failure(log_content: &str) -> bool {
|
||||
log_content.contains("AUTH_FAILED")
|
||||
|| log_content.contains("Exiting due to fatal error")
|
||||
|| log_content.contains("Fatal error")
|
||||
|| log_content.contains("Options error")
|
||||
|| log_content.contains("Exiting")
|
||||
}
|
||||
|
||||
fn has_config_directive(config: &str, directive: &str) -> bool {
|
||||
config.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty()
|
||||
&& !trimmed.starts_with('#')
|
||||
&& !trimmed.starts_with(';')
|
||||
&& trimmed.starts_with(directive)
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_config_directive(config: &str, directive: &str) -> String {
|
||||
config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.starts_with(directive)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_runtime_config(&self) -> String {
|
||||
let mut runtime_config = self.config.raw_config.clone();
|
||||
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "redirect-gateway");
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "block-outside-dns");
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "dhcp-option");
|
||||
|
||||
if !runtime_config.contains("pull-filter ignore \"redirect-gateway\"") {
|
||||
runtime_config.push_str("\npull-filter ignore \"redirect-gateway\"\n");
|
||||
}
|
||||
if !runtime_config.contains("pull-filter ignore \"block-outside-dns\"") {
|
||||
runtime_config.push_str("pull-filter ignore \"block-outside-dns\"\n");
|
||||
}
|
||||
if !runtime_config.contains("pull-filter ignore \"dhcp-option\"") {
|
||||
runtime_config.push_str("pull-filter ignore \"dhcp-option\"\n");
|
||||
}
|
||||
|
||||
if !Self::has_config_directive(&runtime_config, "route 0.0.0.0") {
|
||||
runtime_config.push_str("\nroute 0.0.0.0 0.0.0.0 vpn_gateway 9999\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if Self::has_config_directive(&runtime_config, "dev-node") {
|
||||
runtime_config = runtime_config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.starts_with("dev-node")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if !Self::has_config_directive(&runtime_config, "disable-dco") {
|
||||
runtime_config.push_str("\ndisable-dco\n");
|
||||
}
|
||||
|
||||
if self.config.dev_type.starts_with("tun")
|
||||
&& !Self::has_config_directive(&runtime_config, "windows-driver")
|
||||
{
|
||||
runtime_config.push_str("\nwindows-driver wintun\n");
|
||||
}
|
||||
}
|
||||
|
||||
runtime_config
|
||||
}
|
||||
|
||||
pub(crate) fn dependency_status() -> OpenVpnDependencyStatus {
|
||||
let Ok(openvpn_bin) = Self::find_openvpn_binary() else {
|
||||
return OpenVpnDependencyStatus {
|
||||
binary_found: false,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
};
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match Self::windows_openvpn_has_adapter(&openvpn_bin) {
|
||||
Ok(has_adapter) => OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: !has_adapter,
|
||||
dependency_check_failed: false,
|
||||
},
|
||||
Err(_) => OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = openvpn_bin;
|
||||
OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
|
||||
let path = PathBuf::from(path);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Configured OpenVPN binary does not exist: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let locations = [
|
||||
"/usr/sbin/openvpn",
|
||||
"/usr/local/sbin/openvpn",
|
||||
"/opt/homebrew/bin/openvpn",
|
||||
"/usr/bin/openvpn",
|
||||
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
|
||||
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
|
||||
];
|
||||
|
||||
for loc in &locations {
|
||||
let path = PathBuf::from(loc);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("openvpn").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
if let Ok(output) = Command::new("where")
|
||||
.arg("openvpn")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn openvpn_supports_management(openvpn_bin: &Path) -> bool {
|
||||
let mut command = Command::new(openvpn_bin);
|
||||
command.arg("--version");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let Ok(output) = command.output() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let version_text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
!version_text.contains("enable_management=no")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_openvpn_has_adapter(openvpn_bin: &Path) -> Result<bool, VpnError> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let output = Command::new(openvpn_bin)
|
||||
.arg("--show-adapters")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to inspect OpenVPN adapters: {e}")))?;
|
||||
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(
|
||||
text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| !line.is_empty() && !line.starts_with("Available adapters")),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_vpn_ip_from_log(log_content: &str) -> Option<Ipv4Addr> {
|
||||
for line in log_content.lines() {
|
||||
if let Some(ip) = Self::extract_vpn_ip(line) {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(position) = line.find("ifconfig ") {
|
||||
let after = &line[position + "ifconfig ".len()..];
|
||||
if let Some(ip_str) = after
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.or_else(|| after.split(',').next())
|
||||
{
|
||||
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
|
||||
if ip.is_private() && !ip.is_loopback() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn wait_for_openvpn_ready_via_management(
|
||||
child: &mut std::process::Child,
|
||||
mgmt_port: u16,
|
||||
log_path: &Path,
|
||||
) -> Result<Option<Ipv4Addr>, VpnError> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
|
||||
|
||||
let mgmt_stream = loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out connecting to OpenVPN management interface. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before the tunnel was established. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
match TcpStream::connect(("127.0.0.1", mgmt_port)).await {
|
||||
Ok(stream) => break stream,
|
||||
Err(_) => tokio::time::sleep(tokio::time::Duration::from_millis(500)).await,
|
||||
}
|
||||
};
|
||||
|
||||
let (mgmt_reader, mut mgmt_writer) = mgmt_stream.into_split();
|
||||
let _ = mgmt_writer.write_all(b"state on\nstate\n").await;
|
||||
|
||||
let mut lines = BufReader::new(mgmt_reader).lines();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
|
||||
interval.tick().await;
|
||||
|
||||
let mut vpn_ip = None;
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out waiting for OpenVPN to reach CONNECTED state. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
line_result = lines.next_line() => {
|
||||
match line_result {
|
||||
Ok(Some(line)) => {
|
||||
if let Some(ip) = Self::extract_vpn_ip(&line) {
|
||||
vpn_ip = Some(ip);
|
||||
}
|
||||
|
||||
if line.contains(",CONNECTED,") {
|
||||
break;
|
||||
}
|
||||
|
||||
if line.contains("AUTH_FAILED") {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN authentication failed. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if line.contains(",EXITING,") || line.contains(">FATAL:") {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN is exiting. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN management connection closed before CONNECTED state. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
let _ = mgmt_writer.write_all(b"state\n").await;
|
||||
|
||||
let log_path = log_path.to_path_buf();
|
||||
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok);
|
||||
|
||||
if let Some(content) = log_content {
|
||||
if Self::log_indicates_connected(&content) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vpn_ip.is_none() {
|
||||
if let Ok(log_content) = std::fs::read_to_string(log_path) {
|
||||
vpn_ip = Self::extract_vpn_ip_from_log(&log_content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vpn_ip)
|
||||
}
|
||||
|
||||
async fn wait_for_openvpn_ready_via_log(
|
||||
child: &mut std::process::Child,
|
||||
log_path: &Path,
|
||||
) -> Result<Option<Ipv4Addr>, VpnError> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out waiting for OpenVPN to connect. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
let log_path_buf = log_path.to_path_buf();
|
||||
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path_buf))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_default();
|
||||
|
||||
if Self::log_indicates_connected(&log_content) {
|
||||
return Ok(Self::extract_vpn_ip_from_log(&log_content));
|
||||
}
|
||||
|
||||
if Self::log_indicates_failure(&log_content) {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN reported a fatal error while connecting. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_target(
|
||||
target: SocksTarget,
|
||||
vpn_bind_ip: Ipv4Addr,
|
||||
) -> Result<(TcpStream, SocketAddr), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut addresses = match target {
|
||||
SocksTarget::Address(addr) => vec![addr],
|
||||
SocksTarget::Domain(host, port) => {
|
||||
let mut resolved = lookup_host((host.as_str(), port))
|
||||
.await?
|
||||
.collect::<Vec<_>>();
|
||||
resolved.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 });
|
||||
resolved
|
||||
}
|
||||
};
|
||||
|
||||
if addresses.is_empty() {
|
||||
return Err("No addresses resolved for SOCKS5 target".into());
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for address in addresses.drain(..) {
|
||||
let socket = if address.is_ipv4() {
|
||||
let socket = TcpSocket::new_v4()?;
|
||||
if !vpn_bind_ip.is_unspecified() {
|
||||
socket.bind(SocketAddr::new(IpAddr::V4(vpn_bind_ip), 0))?;
|
||||
}
|
||||
socket
|
||||
} else {
|
||||
TcpSocket::new_v6()?
|
||||
};
|
||||
|
||||
match socket.connect(address).await {
|
||||
Ok(stream) => return Ok((stream, address)),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(
|
||||
last_error
|
||||
.map(|error| error.into())
|
||||
.unwrap_or_else(|| "Failed to connect to any resolved SOCKS5 target".into()),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||
let openvpn_bin = Self::find_openvpn_binary()?;
|
||||
let supports_management = Self::openvpn_supports_management(&openvpn_bin);
|
||||
|
||||
#[cfg(windows)]
|
||||
if !Self::windows_openvpn_has_adapter(&openvpn_bin)? {
|
||||
return Err(VpnError::Connection(
|
||||
"OpenVPN requires a TAP/Wintun/ovpn-dco adapter on Windows, but none were found. Install or provision an adapter before connecting.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
|
||||
std::fs::write(&config_path, self.build_runtime_config()).map_err(VpnError::Io)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
let mgmt_port = if supports_management {
|
||||
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
|
||||
let port = mgmt_listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
|
||||
.port();
|
||||
drop(mgmt_listener);
|
||||
Some(port)
|
||||
} else {
|
||||
log::info!(
|
||||
"[vpn-worker] OpenVPN build does not support management; using log-based readiness"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
let openvpn_log_path = std::env::temp_dir().join(format!("openvpn-{}.log", config_id));
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&openvpn_log_path)
|
||||
.map_err(VpnError::Io)?;
|
||||
|
||||
let mut cmd = Command::new(&openvpn_bin);
|
||||
cmd.arg("--config").arg(&config_path);
|
||||
if let Some(mgmt_port) = mgmt_port {
|
||||
cmd
|
||||
.arg("--management")
|
||||
.arg("127.0.0.1")
|
||||
.arg(mgmt_port.to_string());
|
||||
}
|
||||
cmd
|
||||
.arg("--verb")
|
||||
.arg("3")
|
||||
.stdout(
|
||||
log_file
|
||||
.try_clone()
|
||||
.map(Stdio::from)
|
||||
.map_err(VpnError::Io)?,
|
||||
)
|
||||
.stderr(Stdio::from(log_file));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
cmd.arg("--disable-dco");
|
||||
if self.config.dev_type.starts_with("tun") {
|
||||
cmd.arg("--windows-driver").arg("wintun");
|
||||
}
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited immediately (status: {}). Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(&openvpn_log_path, 20)
|
||||
)));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Failed to check OpenVPN status: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let vpn_bind_ip = if let Some(mgmt_port) = mgmt_port {
|
||||
Self::wait_for_openvpn_ready_via_management(&mut child, mgmt_port, &openvpn_log_path).await?
|
||||
} else {
|
||||
Self::wait_for_openvpn_ready_via_log(&mut child, &openvpn_log_path).await?
|
||||
}
|
||||
.unwrap_or(Ipv4Addr::UNSPECIFIED);
|
||||
let vpn_bind_ip = Arc::new(vpn_bind_ip);
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", self.port))
|
||||
.await
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
|
||||
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
|
||||
.port();
|
||||
|
||||
if let Some(mut worker_config) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
worker_config.local_port = Some(actual_port);
|
||||
worker_config.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&worker_config);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}",
|
||||
actual_port
|
||||
);
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((client, _)) => {
|
||||
let bind_ip = vpn_bind_ip.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = Self::handle_socks5_client(client, bind_ip).await;
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("[vpn-worker] Accept error: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_socks5_client(
|
||||
mut client: TcpStream,
|
||||
vpn_bind_ip: Arc<Ipv4Addr>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut greeting = [0u8; 2];
|
||||
if let Err(error) = client.read_exact(&mut greeting).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read greeting header: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if greeting[0] != 0x05 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut methods = vec![0u8; greeting[1] as usize];
|
||||
if let Err(error) = client.read_exact(&mut methods).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read methods list: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
client.write_all(&[0x05, 0x00]).await?;
|
||||
|
||||
let mut request_header = [0u8; 4];
|
||||
if let Err(error) = client.read_exact(&mut request_header).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read request header: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request_header[0] != 0x05 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request_header[1] != 0x01 {
|
||||
let _ = client
|
||||
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let target = match request_header[3] {
|
||||
0x01 => {
|
||||
let mut addr_port = [0u8; 6];
|
||||
client.read_exact(&mut addr_port).await?;
|
||||
SocksTarget::Address(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(
|
||||
addr_port[0],
|
||||
addr_port[1],
|
||||
addr_port[2],
|
||||
addr_port[3],
|
||||
)),
|
||||
u16::from_be_bytes([addr_port[4], addr_port[5]]),
|
||||
))
|
||||
}
|
||||
0x03 => {
|
||||
let mut len = [0u8; 1];
|
||||
client.read_exact(&mut len).await?;
|
||||
if len[0] == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut domain = vec![0u8; len[0] as usize];
|
||||
client.read_exact(&mut domain).await?;
|
||||
|
||||
let mut port = [0u8; 2];
|
||||
client.read_exact(&mut port).await?;
|
||||
|
||||
SocksTarget::Domain(
|
||||
String::from_utf8_lossy(&domain).to_string(),
|
||||
u16::from_be_bytes(port),
|
||||
)
|
||||
}
|
||||
0x04 => {
|
||||
let mut addr_port = [0u8; 18];
|
||||
client.read_exact(&mut addr_port).await?;
|
||||
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&addr_port[..16]);
|
||||
|
||||
SocksTarget::Address(SocketAddr::new(
|
||||
IpAddr::V6(std::net::Ipv6Addr::from(octets)),
|
||||
u16::from_be_bytes([addr_port[16], addr_port[17]]),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let _ = client
|
||||
.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match Self::connect_target(target, *vpn_bind_ip).await {
|
||||
Ok((upstream, _address)) => {
|
||||
client
|
||||
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
|
||||
.await?;
|
||||
|
||||
let (mut client_read, mut client_write) = client.into_split();
|
||||
let (mut upstream_read, mut upstream_write) = upstream.into_split();
|
||||
|
||||
let client_to_upstream = tokio::io::copy(&mut client_read, &mut upstream_write);
|
||||
let upstream_to_client = tokio::io::copy(&mut upstream_read, &mut client_write);
|
||||
let _ = tokio::try_join!(client_to_upstream, upstream_to_client)?;
|
||||
}
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"[socks5] Failed to connect through OpenVPN tunnel: {}",
|
||||
error
|
||||
);
|
||||
client
|
||||
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_openvpn_binary_format() {
|
||||
let result = OpenVpnSocks5Server::find_openvpn_binary();
|
||||
match result {
|
||||
Ok(path) => assert!(!path.as_os_str().is_empty()),
|
||||
Err(e) => assert!(e.to_string().contains("not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,25 @@ impl WgDevice {
|
||||
let mut dst = vec![0u8; ip_packet.len() + 256];
|
||||
let mut tunn = self.tunn.lock().unwrap();
|
||||
let result = tunn.encapsulate(&ip_packet, &mut dst);
|
||||
if let TunnResult::WriteToNetwork(packet) = result {
|
||||
let _ = self.udp_socket.send_to(packet, self.peer_addr);
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(packet) => {
|
||||
if let Err(e) = self.udp_socket.send_to(packet, self.peer_addr) {
|
||||
log::error!("[wg] udp send_to failed: {e}");
|
||||
}
|
||||
}
|
||||
TunnResult::Done => {
|
||||
// boringtun has nothing to send right now (e.g. handshake not yet
|
||||
// complete); silently drop. smoltcp will retransmit.
|
||||
}
|
||||
TunnResult::Err(e) => {
|
||||
log::error!(
|
||||
"[wg] encapsulate error for {}B IP packet: {e:?}",
|
||||
ip_packet.len()
|
||||
);
|
||||
}
|
||||
TunnResult::WriteToTunnelV4(_, _) | TunnResult::WriteToTunnelV6(_, _) => {
|
||||
log::error!("[wg] encapsulate returned unexpected WriteToTunnel — bug?");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +330,11 @@ impl WireGuardSocks5Server {
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||
pub async fn run(
|
||||
self,
|
||||
config_id: String,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
) -> Result<(), VpnError> {
|
||||
let peer_addr = self.resolve_endpoint()?;
|
||||
let mut tunn = self.create_tunnel()?;
|
||||
|
||||
@@ -371,11 +392,37 @@ impl WireGuardSocks5Server {
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
|
||||
.port();
|
||||
|
||||
// Update config with actual port and local_url
|
||||
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
// Update config with actual port and local_url. Prefer the explicit
|
||||
// config path the worker was started with — see issue #287, where
|
||||
// get_storage_dir() in the worker process resolved to a different
|
||||
// directory than in the parent (Qubes/sandboxed Linux), causing the
|
||||
// write-back to land in the wrong place and the parent to time out.
|
||||
let updated = match &config_path {
|
||||
Some(path) => crate::vpn_worker_storage::get_vpn_worker_config_from_path(path)
|
||||
.or_else(|| crate::vpn_worker_storage::get_vpn_worker_config(&config_id)),
|
||||
None => crate::vpn_worker_storage::get_vpn_worker_config(&config_id),
|
||||
};
|
||||
if let Some(mut wc) = updated {
|
||||
wc.local_port = Some(actual_port);
|
||||
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
|
||||
let result = match &config_path {
|
||||
Some(path) => crate::vpn_worker_storage::save_vpn_worker_config_to_path(&wc, path)
|
||||
.map_err(|e| e.to_string()),
|
||||
None => crate::vpn_worker_storage::save_vpn_worker_config(&wc).map_err(|e| e.to_string()),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log::error!(
|
||||
"[vpn-worker] Failed to write back local_url to config: {} (path={:?})",
|
||||
e,
|
||||
config_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::error!(
|
||||
"[vpn-worker] Could not load worker config for write-back (id={}, path={:?})",
|
||||
config_id,
|
||||
config_path
|
||||
);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
|
||||
@@ -161,7 +161,17 @@ impl VpnStorage {
|
||||
let content = fs::read_to_string(&self.storage_path)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to read storage file: {e}")))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
// Drop entries whose vpn_type isn't recognized by the current build (e.g.
|
||||
// legacy "OpenVPN" entries after support was removed). Filtering at JSON
|
||||
// level keeps the rest of the file deserializable instead of the whole
|
||||
// load failing on a single unknown variant.
|
||||
let mut value: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))?;
|
||||
if let Some(arr) = value.get_mut("configs").and_then(|v| v.as_array_mut()) {
|
||||
arr.retain(|c| c.get("vpn_type").and_then(|t| t.as_str()) == Some("WireGuard"));
|
||||
}
|
||||
|
||||
serde_json::from_value(value)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))
|
||||
}
|
||||
|
||||
@@ -328,14 +338,10 @@ impl VpnStorage {
|
||||
vpn_type: VpnType,
|
||||
config_data: &str,
|
||||
) -> Result<VpnConfig, VpnError> {
|
||||
// Validate the config by parsing it
|
||||
match vpn_type {
|
||||
VpnType::WireGuard => {
|
||||
super::parse_wireguard_config(config_data)?;
|
||||
}
|
||||
VpnType::OpenVPN => {
|
||||
super::parse_openvpn_config(config_data)?;
|
||||
}
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
@@ -392,20 +398,15 @@ impl VpnStorage {
|
||||
) -> Result<VpnConfig, VpnError> {
|
||||
let vpn_type = super::detect_vpn_type(content, filename)?;
|
||||
|
||||
// Validate the config by parsing it
|
||||
match vpn_type {
|
||||
VpnType::WireGuard => {
|
||||
super::parse_wireguard_config(content)?;
|
||||
}
|
||||
VpnType::OpenVPN => {
|
||||
super::parse_openvpn_config(content)?;
|
||||
}
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let display_name = name.unwrap_or_else(|| {
|
||||
// Generate name from filename
|
||||
let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn");
|
||||
let base = filename.trim_end_matches(".conf");
|
||||
format!("{} ({})", base, vpn_type)
|
||||
});
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
@@ -491,7 +492,7 @@ mod tests {
|
||||
let config2 = VpnConfig {
|
||||
id: "id-2".to_string(),
|
||||
name: "VPN 2".to_string(),
|
||||
vpn_type: VpnType::OpenVPN,
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "secret2".to_string(),
|
||||
created_at: 2000,
|
||||
last_used: Some(3000),
|
||||
|
||||
@@ -9,7 +9,6 @@ use std::process::Stdio;
|
||||
|
||||
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
|
||||
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_000;
|
||||
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
|
||||
|
||||
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
|
||||
let Some(port) = config.local_port else {
|
||||
@@ -44,13 +43,8 @@ fn read_worker_log(id: &str) -> String {
|
||||
|
||||
async fn wait_for_vpn_worker_ready(
|
||||
id: &str,
|
||||
vpn_type: &str,
|
||||
) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
let startup_timeout = if vpn_type == "openvpn" {
|
||||
tokio::time::Duration::from_millis(OPENVPN_WORKER_STARTUP_TIMEOUT_MS)
|
||||
} else {
|
||||
tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS)
|
||||
};
|
||||
let startup_timeout = tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS);
|
||||
let startup_deadline = tokio::time::Instant::now() + startup_timeout;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(
|
||||
@@ -124,7 +118,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
return wait_for_vpn_worker_ready(&existing.id, &existing.vpn_type).await;
|
||||
return wait_for_vpn_worker_ready(&existing.id).await;
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
@@ -141,10 +135,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
.map_err(|e| format!("Failed to load VPN config: {e}"))?
|
||||
};
|
||||
|
||||
let vpn_type_str = match vpn_config.vpn_type {
|
||||
crate::vpn::VpnType::WireGuard => "wireguard",
|
||||
crate::vpn::VpnType::OpenVPN => "openvpn",
|
||||
};
|
||||
let vpn_type_str = "wireguard";
|
||||
|
||||
// Write decrypted config to a temp file
|
||||
let config_file_path = std::env::temp_dir()
|
||||
@@ -270,7 +261,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
drop(child);
|
||||
}
|
||||
|
||||
wait_for_vpn_worker_ready(&id, vpn_type_str).await
|
||||
wait_for_vpn_worker_ready(&id).await
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::proxy_storage::get_storage_dir;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnWorkerConfig {
|
||||
@@ -36,12 +37,34 @@ pub fn save_vpn_worker_config(config: &VpnWorkerConfig) -> Result<(), Box<dyn st
|
||||
fs::create_dir_all(&storage_dir)?;
|
||||
|
||||
let file_path = storage_dir.join(format!("vpn_worker_{}.json", config.id));
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&file_path, content)?;
|
||||
save_vpn_worker_config_to_path(config, &file_path)
|
||||
}
|
||||
|
||||
/// Write a worker config to a specific path. Used by detached worker
|
||||
/// processes that already know their config file path (passed via
|
||||
/// `--config-path`) and must write back to the same location regardless of
|
||||
/// how `get_storage_dir()` resolves in the worker process — which can
|
||||
/// differ from the parent on Linux distros that sandbox the GUI (Qubes,
|
||||
/// flatpak, etc.) and is the cause of issue #287.
|
||||
pub fn save_vpn_worker_config_to_path(
|
||||
config: &VpnWorkerConfig,
|
||||
path: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a worker config from a specific path. Counterpart to
|
||||
/// `save_vpn_worker_config_to_path`.
|
||||
pub fn get_vpn_worker_config_from_path(path: &Path) -> Option<VpnWorkerConfig> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
pub fn get_vpn_worker_config(id: &str) -> Option<VpnWorkerConfig> {
|
||||
let storage_dir = get_storage_dir();
|
||||
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.6",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
@@ -42,11 +42,11 @@
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "donutbrowser.desktop",
|
||||
"depends": ["xdg-utils"]
|
||||
"depends": ["xdg-utils", "libxdo3"]
|
||||
},
|
||||
"rpm": {
|
||||
"desktopTemplate": "donutbrowser.desktop",
|
||||
"depends": ["xdg-utils"]
|
||||
"depends": ["xdg-utils", "libxdo"]
|
||||
},
|
||||
"appimage": {
|
||||
"files": {
|
||||
|
||||
Vendored
-39
@@ -1,39 +0,0 @@
|
||||
# Sample OpenVPN configuration for testing
|
||||
# This is NOT a real configuration - for unit test purposes only
|
||||
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.example.com 1194
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
verb 3
|
||||
|
||||
<ca>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJaMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM
|
||||
DnRlc3QtY2EtZXhhbXBsZTAeFw0yMzAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBa
|
||||
MBkxFzAVBgNVBAMMDnRlc3QtY2EtZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49
|
||||
AwEHA0IABHfakeZYe3R6uCZoL5DqbZkW8mBVKnIYMrIIKV4FPYO9V1YL8V3Z9QC
|
||||
TEST_CERTIFICATE_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END CERTIFICATE-----
|
||||
</ca>
|
||||
|
||||
<cert>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJbMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM
|
||||
DnRlc3QtY2xpZW50LWV4YW1wbGUwHhcNMjMwMTAxMDAwMDAwWhcNMjUwMTAxMDAw
|
||||
MDAwWjAZMRcwFQYDVQQDDA50ZXN0LWNsaWVudC1leGFtcGxlMFkwEwYHKoZIzj0C
|
||||
AQYIKoZIzj0DAQcDQgAE
|
||||
TEST_CLIENT_CERT_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END CERTIFICATE-----
|
||||
</cert>
|
||||
|
||||
<key>
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZFG/NKjHmTJBNcuH
|
||||
TEST_PRIVATE_KEY_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END PRIVATE KEY-----
|
||||
</key>
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Test harness for VPN integration tests.
|
||||
//!
|
||||
//! This module provides Docker-based test infrastructure for WireGuard and OpenVPN tests.
|
||||
//! This module provides Docker-based test infrastructure for WireGuard tests.
|
||||
//! In CI environments, it uses pre-configured service containers.
|
||||
//! In local development, it spawns Docker containers on demand.
|
||||
//!
|
||||
@@ -13,10 +13,7 @@ use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const WIREGUARD_IMAGE: &str = "linuxserver/wireguard:latest";
|
||||
const OPENVPN_IMAGE: &str = "kylemanna/openvpn:latest";
|
||||
const WG_CONTAINER: &str = "donut-wg-test";
|
||||
const OVPN_CONTAINER: &str = "donut-ovpn-test";
|
||||
const OVPN_VOLUME: &str = "donut-ovpn-test-data";
|
||||
|
||||
/// Check if running in CI environment
|
||||
pub fn is_ci() -> bool {
|
||||
@@ -27,10 +24,6 @@ fn has_external_wireguard_service() -> bool {
|
||||
std::env::var("VPN_TEST_WG_HOST").is_ok()
|
||||
}
|
||||
|
||||
fn has_external_openvpn_service() -> bool {
|
||||
std::env::var("VPN_TEST_OVPN_HOST").is_ok()
|
||||
}
|
||||
|
||||
/// Check if Docker is available
|
||||
pub fn is_docker_available() -> bool {
|
||||
Command::new("docker")
|
||||
@@ -165,166 +158,10 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Start an OpenVPN test server and return client config
|
||||
pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
|
||||
if has_external_openvpn_service() {
|
||||
let host = std::env::var("VPN_TEST_OVPN_HOST").unwrap_or_else(|_| "localhost".into());
|
||||
let port = std::env::var("VPN_TEST_OVPN_PORT").unwrap_or_else(|_| "1194".into());
|
||||
|
||||
return get_ci_openvpn_config(&host, &port);
|
||||
}
|
||||
|
||||
if !is_docker_available() {
|
||||
return Err("Docker is not available for local testing".to_string());
|
||||
}
|
||||
|
||||
// Stop any existing container
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", OVPN_CONTAINER])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("docker")
|
||||
.args(["volume", "rm", "-f", OVPN_VOLUME])
|
||||
.output();
|
||||
|
||||
let create_volume = Command::new("docker")
|
||||
.args(["volume", "create", OVPN_VOLUME])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to create OpenVPN test volume: {e}"))?;
|
||||
if !create_volume.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to create OpenVPN test volume: {}",
|
||||
String::from_utf8_lossy(&create_volume.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let genconfig = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_genconfig",
|
||||
"-u",
|
||||
"udp://127.0.0.1",
|
||||
"-s",
|
||||
"10.9.0.0/24",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to generate OpenVPN config: {e}"))?;
|
||||
if !genconfig.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN config generation failed: {}",
|
||||
String::from_utf8_lossy(&genconfig.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let init_pki = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_initpki",
|
||||
"nopass",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to initialize OpenVPN PKI: {e}"))?;
|
||||
if !init_pki.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN PKI initialization failed: {}",
|
||||
String::from_utf8_lossy(&init_pki.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let build_client = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"easyrsa",
|
||||
"build-client-full",
|
||||
"donut-test-client",
|
||||
"nopass",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to build OpenVPN client certificate: {e}"))?;
|
||||
if !build_client.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN client certificate build failed: {}",
|
||||
String::from_utf8_lossy(&build_client.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let start_server = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
OVPN_CONTAINER,
|
||||
"--cap-add=NET_ADMIN",
|
||||
"-p",
|
||||
"1194:1194/udp",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
OPENVPN_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to start OpenVPN container: {e}"))?;
|
||||
if !start_server.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN container start failed: {}",
|
||||
String::from_utf8_lossy(&start_server.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
let client_config = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_getclient",
|
||||
"donut-test-client",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to fetch OpenVPN client config: {e}"))?;
|
||||
if !client_config.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to read OpenVPN client config: {}",
|
||||
String::from_utf8_lossy(&client_config.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let raw_config = String::from_utf8_lossy(&client_config.stdout).to_string();
|
||||
Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: "127.0.0.1".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop all VPN test servers
|
||||
pub async fn stop_vpn_servers() {
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", WG_CONTAINER, OVPN_CONTAINER])
|
||||
.output();
|
||||
let _ = Command::new("docker")
|
||||
.args(["volume", "rm", "-f", OVPN_VOLUME])
|
||||
.args(["rm", "-f", WG_CONTAINER])
|
||||
.output();
|
||||
}
|
||||
|
||||
@@ -343,14 +180,6 @@ pub struct WireGuardTestConfig {
|
||||
pub server_tunnel_ip: String,
|
||||
}
|
||||
|
||||
/// OpenVPN test configuration
|
||||
pub struct OpenVpnTestConfig {
|
||||
pub raw_config: String,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Parse WireGuard test config from INI content
|
||||
fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, String> {
|
||||
let mut private_key = String::new();
|
||||
@@ -436,7 +265,7 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
|
||||
|
||||
Ok(WireGuardTestConfig {
|
||||
private_key,
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
address: std::env::var("VPN_TEST_WG_ADDRESS").unwrap_or_else(|_| "10.0.0.2/24".to_string()),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
peer_public_key: public_key,
|
||||
peer_endpoint: format!("{host}:{port}"),
|
||||
@@ -446,35 +275,3 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
|
||||
.unwrap_or_else(|_| "10.0.0.1".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get OpenVPN config from CI environment
|
||||
fn get_ci_openvpn_config(host: &str, port: &str) -> Result<OpenVpnTestConfig, String> {
|
||||
if let Ok(raw_config) = std::env::var("VPN_TEST_OVPN_RAW_CONFIG") {
|
||||
return Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: host.to_string(),
|
||||
remote_port: port.parse().unwrap_or(1194),
|
||||
protocol: "udp".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw_config = format!(
|
||||
r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote {host} {port}
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
"#
|
||||
);
|
||||
|
||||
Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: host.to_string(),
|
||||
remote_port: port.parse().unwrap_or(1194),
|
||||
protocol: "udp".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ mod test_harness;
|
||||
|
||||
use common::TestUtils;
|
||||
use donutbrowser_lib::vpn::{
|
||||
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
|
||||
VpnStorage, VpnType, WireGuardConfig,
|
||||
detect_vpn_type, parse_wireguard_config, VpnConfig, VpnStorage, VpnType, WireGuardConfig,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
@@ -45,27 +44,6 @@ fn test_wireguard_config_import() {
|
||||
assert_eq!(wg.persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_import() {
|
||||
let config = include_str!("fixtures/test.ovpn");
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse OpenVPN config: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let ovpn = result.unwrap();
|
||||
assert_eq!(ovpn.remote_host, "vpn.example.com");
|
||||
assert_eq!(ovpn.remote_port, 1194);
|
||||
assert_eq!(ovpn.protocol, "udp");
|
||||
assert_eq!(ovpn.dev_type, "tun");
|
||||
assert!(ovpn.has_inline_ca);
|
||||
assert!(ovpn.has_inline_cert);
|
||||
assert!(ovpn.has_inline_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_wireguard_by_extension() {
|
||||
let content = "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer";
|
||||
@@ -75,15 +53,6 @@ fn test_detect_vpn_type_wireguard_by_extension() {
|
||||
assert_eq!(result.unwrap(), VpnType::WireGuard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_openvpn_by_extension() {
|
||||
let content = "client\nremote vpn.example.com 1194";
|
||||
let result = detect_vpn_type(content, "my-vpn.ovpn");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_wireguard_by_content() {
|
||||
let content = r#"
|
||||
@@ -101,20 +70,6 @@ Endpoint = 1.2.3.4:51820
|
||||
assert_eq!(result.unwrap(), VpnType::WireGuard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_openvpn_by_content() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.server.com 443
|
||||
"#;
|
||||
let result = detect_vpn_type(content, "config.txt");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_unknown() {
|
||||
let content = "this is just some random text that is not a vpn config";
|
||||
@@ -123,6 +78,13 @@ fn test_detect_vpn_type_unknown() {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_openvpn_content() {
|
||||
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
|
||||
assert!(detect_vpn_type(content, "old.ovpn").is_err());
|
||||
assert!(detect_vpn_type(content, "config.txt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wireguard_config_missing_private_key() {
|
||||
let config = r#"
|
||||
@@ -154,32 +116,6 @@ Address = 10.0.0.2/24
|
||||
assert!(err.contains("PublicKey") || err.contains("Peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_missing_remote() {
|
||||
let config = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
"#;
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_with_port_in_remote() {
|
||||
let config = "client\nremote server.example.com 443 tcp";
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let ovpn = result.unwrap();
|
||||
assert_eq!(ovpn.remote_host, "server.example.com");
|
||||
assert_eq!(ovpn.remote_port, 443);
|
||||
assert_eq!(ovpn.protocol, "tcp");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage Tests
|
||||
// ============================================================================
|
||||
@@ -228,16 +164,11 @@ fn test_vpn_storage_list() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
|
||||
// Save two configs
|
||||
for i in 1..=2 {
|
||||
let config = VpnConfig {
|
||||
id: format!("list-test-{i}"),
|
||||
name: format!("VPN {i}"),
|
||||
vpn_type: if i == 1 {
|
||||
VpnType::WireGuard
|
||||
} else {
|
||||
VpnType::OpenVPN
|
||||
},
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "secret data".to_string(),
|
||||
created_at: 1000 * i as i64,
|
||||
last_used: None,
|
||||
@@ -250,7 +181,6 @@ fn test_vpn_storage_list() {
|
||||
let list = storage.list_configs().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
|
||||
// Config data should be empty in listing
|
||||
for cfg in &list {
|
||||
assert!(cfg.config_data.is_empty());
|
||||
}
|
||||
@@ -297,6 +227,52 @@ fn test_vpn_storage_import() {
|
||||
assert!(!imported.id.is_empty());
|
||||
}
|
||||
|
||||
/// Existing OpenVPN entries on disk should be silently dropped at load time
|
||||
/// after support was removed. Stored configs are encrypted at rest, so we
|
||||
/// build the on-disk JSON by hand instead of going through `save_config`.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_vpn_storage_drops_legacy_openvpn_entries() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage_path = temp_dir.path().join("vpn_configs.json");
|
||||
std::fs::write(
|
||||
&storage_path,
|
||||
r#"{
|
||||
"version": 1,
|
||||
"configs": [
|
||||
{
|
||||
"id": "wg-keep",
|
||||
"name": "Keep me",
|
||||
"vpn_type": "WireGuard",
|
||||
"encrypted_data": "",
|
||||
"nonce": "",
|
||||
"created_at": 1,
|
||||
"last_used": null,
|
||||
"sync_enabled": false,
|
||||
"last_sync": null
|
||||
},
|
||||
{
|
||||
"id": "ovpn-drop",
|
||||
"name": "Drop me",
|
||||
"vpn_type": "OpenVPN",
|
||||
"encrypted_data": "",
|
||||
"nonce": "",
|
||||
"created_at": 2,
|
||||
"last_used": null,
|
||||
"sync_enabled": false,
|
||||
"last_sync": null
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
let configs = storage.list_configs().unwrap();
|
||||
let ids: Vec<_> = configs.iter().map(|c| c.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["wg-keep"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
@@ -309,13 +285,9 @@ fn create_test_storage(temp_dir: &tempfile::TempDir) -> VpnStorage {
|
||||
// Connection Tests (require Docker)
|
||||
// ============================================================================
|
||||
|
||||
/// These tests require Docker to be available.
|
||||
/// They are automatically skipped if Docker is not installed.
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_wireguard_tunnel_init() {
|
||||
// This test only verifies tunnel creation, not actual connection
|
||||
let config = WireGuardConfig {
|
||||
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
@@ -337,30 +309,6 @@ async fn test_wireguard_tunnel_init() {
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_openvpn_tunnel_init() {
|
||||
// This test only verifies tunnel creation, not actual connection
|
||||
let config = OpenVpnConfig {
|
||||
raw_config: "client\nremote localhost 1194".to_string(),
|
||||
remote_host: "localhost".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
dev_type: "tun".to_string(),
|
||||
has_inline_ca: false,
|
||||
has_inline_cert: false,
|
||||
has_inline_key: false,
|
||||
};
|
||||
|
||||
use donutbrowser_lib::vpn::{OpenVpnTunnel, VpnTunnel};
|
||||
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn".to_string(), config);
|
||||
assert_eq!(tunnel.vpn_id(), "test-ovpn");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_tunnel_manager() {
|
||||
@@ -565,45 +513,6 @@ fn build_wireguard_config(config: &test_harness::WireGuardTestConfig) -> String
|
||||
)
|
||||
}
|
||||
|
||||
fn openvpn_client_available() -> bool {
|
||||
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
|
||||
return PathBuf::from(path).exists();
|
||||
}
|
||||
|
||||
std::process::Command::new(if cfg!(windows) { "where" } else { "which" })
|
||||
.arg("openvpn")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn openvpn_adapter_available() -> bool {
|
||||
let openvpn = std::process::Command::new("openvpn")
|
||||
.arg("--show-adapters")
|
||||
.output();
|
||||
|
||||
openvpn
|
||||
.ok()
|
||||
.map(|output| {
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| !line.is_empty() && !line.starts_with("Available adapters"))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn openvpn_adapter_available() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn start_proxy_with_upstream(
|
||||
binary_path: &PathBuf,
|
||||
upstream_proxy: &str,
|
||||
@@ -750,10 +659,6 @@ async fn run_proxy_feature_suite(
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Test HTTP traffic through the tunnel to the internal HTTP server running
|
||||
// inside the WireGuard container. This avoids depending on internet access
|
||||
// from Docker (macOS Docker Desktop can't reliably NAT WireGuard tunnel
|
||||
// traffic through to the internet).
|
||||
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
|
||||
let internal_host = format!("{}:8080", server_tunnel_ip);
|
||||
let http_response =
|
||||
@@ -790,7 +695,6 @@ async fn run_proxy_feature_suite(
|
||||
|
||||
stop_proxy(binary_path, &proxy.id).await?;
|
||||
|
||||
// DNS blocklist test: blocklist the tunnel server IP so it gets rejected
|
||||
let blocklist_file = tempfile::NamedTempFile::new()?;
|
||||
std::fs::write(blocklist_file.path(), format!("{server_tunnel_ip}\n"))?;
|
||||
let blocked_proxy = start_proxy_with_upstream(
|
||||
@@ -896,56 +800,3 @@ async fn test_wireguard_traffic_flows_through_donut_proxy(
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_openvpn_traffic_flows_through_donut_proxy(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let _env = TestEnvGuard::new()?;
|
||||
cleanup_runtime().await;
|
||||
|
||||
if std::env::var("DONUTBROWSER_RUN_OPENVPN_E2E")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= Some("1")
|
||||
{
|
||||
eprintln!("skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !test_harness::is_docker_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because Docker is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !openvpn_client_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because the OpenVPN client binary is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !openvpn_adapter_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because no Windows OpenVPN adapter is available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let binary_path = ensure_donut_proxy_binary().await?;
|
||||
let ovpn_config = match test_harness::start_openvpn_server().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("skipping OpenVPN e2e test: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let vpn_config = new_test_vpn_config("OpenVPN E2E", VpnType::OpenVPN, ovpn_config.raw_config);
|
||||
{
|
||||
let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage.save_config(&vpn_config)?;
|
||||
}
|
||||
|
||||
// OpenVPN test uses the server's tunnel IP for internal-only traffic.
|
||||
// The OpenVPN server's subnet is 10.9.0.0/24, server at 10.9.0.1.
|
||||
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id, "10.9.0.1").await;
|
||||
cleanup_runtime().await;
|
||||
result
|
||||
}
|
||||
|
||||
+128
-101
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
@@ -67,6 +68,7 @@ interface PendingUrl {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
// Mount global version update listener/toasts
|
||||
useVersionUpdater();
|
||||
|
||||
@@ -428,9 +430,7 @@ export default function Home() {
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(
|
||||
"No profiles available. Please create a profile first before opening URLs.",
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
});
|
||||
|
||||
@@ -455,7 +455,7 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
console.error("Failed to setup URL listener:", error);
|
||||
}
|
||||
}, [handleUrlOpen]);
|
||||
}, [handleUrlOpen, t]);
|
||||
|
||||
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCamoufoxConfig(profile);
|
||||
@@ -474,12 +474,14 @@ export default function Home() {
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update camoufox config:", err);
|
||||
showErrorToast(
|
||||
`Failed to update camoufox config: ${JSON.stringify(err)}`,
|
||||
t("errors.updateCamoufoxConfigFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleSaveWayfernConfig = useCallback(
|
||||
@@ -494,12 +496,12 @@ export default function Home() {
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update wayfern config:", err);
|
||||
showErrorToast(
|
||||
`Failed to update wayfern config: ${JSON.stringify(err)}`,
|
||||
t("errors.updateWayfernConfigFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCreateProfile = useCallback(
|
||||
@@ -553,84 +555,92 @@ export default function Home() {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
`Failed to create profile: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
t("errors.createProfileFailed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[selectedGroupId],
|
||||
[selectedGroupId, t],
|
||||
);
|
||||
|
||||
const launchProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
const launchProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
// Show one-time warning about window resizing for fingerprinted browsers
|
||||
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
|
||||
try {
|
||||
const dismissed = await invoke<boolean>(
|
||||
"get_window_resize_warning_dismissed",
|
||||
);
|
||||
if (!dismissed) {
|
||||
const proceed = await new Promise<boolean>((resolve) => {
|
||||
windowResizeWarningResolver.current = resolve;
|
||||
setWindowResizeWarningBrowserType(profile.browser);
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
// Show one-time warning about window resizing for fingerprinted browsers
|
||||
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
|
||||
try {
|
||||
const dismissed = await invoke<boolean>(
|
||||
"get_window_resize_warning_dismissed",
|
||||
);
|
||||
if (!dismissed) {
|
||||
const proceed = await new Promise<boolean>((resolve) => {
|
||||
windowResizeWarningResolver.current = resolve;
|
||||
setWindowResizeWarningBrowserType(profile.browser);
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check window resize warning:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check window resize warning:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to launch browser: ${errorMessage}`);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(
|
||||
t("errors.launchBrowserFailed", { error: errorMessage }),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
|
||||
setCloneProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
const handleDeleteProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
|
||||
try {
|
||||
// First check if the browser is running for this profile
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
try {
|
||||
// First check if the browser is running for this profile
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
|
||||
if (isRunning) {
|
||||
if (isRunning) {
|
||||
showErrorToast(t("errors.cannotDeleteRunningProfile"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileId: profile.id });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
console.log("Profile deleted successfully");
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(
|
||||
"Cannot delete profile while browser is running. Please stop the browser first.",
|
||||
t("errors.deleteProfileFailed", { error: errorMessage }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileId: profile.id });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
console.log("Profile deleted successfully");
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to delete profile: ${errorMessage}`);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRenameProfile = useCallback(
|
||||
async (profileId: string, newName: string) => {
|
||||
@@ -639,28 +649,33 @@ export default function Home() {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to rename profile:", err);
|
||||
showErrorToast(`Failed to rename profile: ${JSON.stringify(err)}`);
|
||||
showErrorToast(
|
||||
t("errors.renameProfileFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleKillProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Starting kill for profile:", profile.name);
|
||||
const handleKillProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Starting kill for profile:", profile.name);
|
||||
|
||||
try {
|
||||
await invoke("kill_browser_profile", { profile });
|
||||
console.log("Successfully killed profile:", profile.name);
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to kill browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to kill browser: ${errorMessage}`);
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
try {
|
||||
await invoke("kill_browser_profile", { profile });
|
||||
console.log("Successfully killed profile:", profile.name);
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to kill browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(t("errors.killBrowserFailed", { error: errorMessage }));
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleDeleteSelectedProfiles = useCallback(
|
||||
async (profileIds: string[]) => {
|
||||
@@ -670,11 +685,13 @@ export default function Home() {
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete selected profiles:", err);
|
||||
showErrorToast(
|
||||
`Failed to delete selected profiles: ${JSON.stringify(err)}`,
|
||||
t("errors.deleteSelectedProfilesFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
|
||||
@@ -701,12 +718,14 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
console.error("Failed to delete selected profiles:", error);
|
||||
showErrorToast(
|
||||
`Failed to delete selected profiles: ${JSON.stringify(error)}`,
|
||||
t("errors.deleteSelectedProfilesFailed", {
|
||||
error: JSON.stringify(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
}, [selectedProfiles]);
|
||||
}, [selectedProfiles, t]);
|
||||
|
||||
const handleBulkGroupAssignment = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
@@ -749,14 +768,12 @@ export default function Home() {
|
||||
(p.browser === "wayfern" || p.browser === "camoufox"),
|
||||
);
|
||||
if (eligibleProfiles.length === 0) {
|
||||
showErrorToast(
|
||||
"Cookie copy only works with Wayfern and Camoufox profiles",
|
||||
);
|
||||
showErrorToast(t("errors.cookieCopyUnsupportedBrowser"));
|
||||
return;
|
||||
}
|
||||
setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id));
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, [selectedProfiles, profiles]);
|
||||
}, [selectedProfiles, profiles, t]);
|
||||
|
||||
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
||||
setSelectedProfilesForCookies([profile.id]);
|
||||
@@ -804,10 +821,10 @@ export default function Home() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast("Failed to update sync settings");
|
||||
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -825,19 +842,22 @@ export default function Home() {
|
||||
const { profile_id, status, error, profile_name } = event.payload;
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile_name || profile?.name || "Unknown";
|
||||
const name =
|
||||
profile_name || profile?.name || t("common.labels.unknownProfile");
|
||||
|
||||
if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
if (profilesWithTransfer.has(profile_id)) {
|
||||
profilesWithTransfer.delete(profile_id);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
showSuccessToast(t("sync.toast.profileSynced", { name }));
|
||||
}
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
profilesWithTransfer.delete(profile_id);
|
||||
showErrorToast(
|
||||
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
|
||||
error
|
||||
? t("sync.toast.profileSyncFailedWithError", { name, error })
|
||||
: t("sync.toast.profileSyncFailed", { name }),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -857,7 +877,10 @@ export default function Home() {
|
||||
const payload = event.payload;
|
||||
const toastId = `sync-${payload.profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === payload.profile_id);
|
||||
const name = payload.profile_name || profile?.name || "Unknown";
|
||||
const name =
|
||||
payload.profile_name ||
|
||||
profile?.name ||
|
||||
t("common.labels.unknownProfile");
|
||||
|
||||
if (
|
||||
payload.phase === "started" ||
|
||||
@@ -889,7 +912,7 @@ export default function Home() {
|
||||
if (unlistenStatus) unlistenStatus();
|
||||
if (unlistenProgress) unlistenProgress();
|
||||
};
|
||||
}, [profiles]);
|
||||
}, [profiles, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for startup default browser prompt
|
||||
@@ -1047,7 +1070,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
|
||||
<main className="flex flex-col items-center w-full max-w-3xl">
|
||||
<main className="flex flex-col items-center w-full max-w-4xl px-3">
|
||||
<div className="w-full">
|
||||
<HomeHeader
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
@@ -1272,9 +1295,13 @@ export default function Home() {
|
||||
setShowBulkDeleteConfirmation(false);
|
||||
}}
|
||||
onConfirm={confirmBulkDelete}
|
||||
title="Delete Selected Profiles"
|
||||
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
|
||||
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
|
||||
title={t("profiles.bulkDelete.title")}
|
||||
description={t("profiles.bulkDelete.description", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
confirmButtonText={t("profiles.bulkDelete.confirmButton", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
isLoading={isBulkDeleting}
|
||||
profileIds={selectedProfiles}
|
||||
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
||||
import { LuCheckCheck } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,6 +20,7 @@ export function AppUpdateToast({
|
||||
onDismiss,
|
||||
updateReady = false,
|
||||
}: AppUpdateToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleRestartClick = async () => {
|
||||
await onRestart();
|
||||
};
|
||||
@@ -43,10 +45,10 @@ export function AppUpdateToast({
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{updateReady
|
||||
? "Update ready, restart to apply"
|
||||
? t("appUpdate.toast.updateReady")
|
||||
: updateInfo.repo_update
|
||||
? "Update available via package manager"
|
||||
: "Manual download required"}
|
||||
: t("appUpdate.toast.manualDownloadRequired")}
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{updateInfo.current_version} → {updateInfo.new_version}
|
||||
@@ -71,7 +73,7 @@ export function AppUpdateToast({
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<LuCheckCheck className="w-3 h-3" />
|
||||
Restart Now
|
||||
{t("appUpdate.toast.restartNow")}
|
||||
</RippleButton>
|
||||
) : (
|
||||
!updateInfo.repo_update &&
|
||||
@@ -82,7 +84,7 @@ export function AppUpdateToast({
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<FaExternalLinkAlt className="w-3 h-3" />
|
||||
View Release
|
||||
{t("appUpdate.toast.viewRelease")}
|
||||
</RippleButton>
|
||||
)
|
||||
)}
|
||||
@@ -92,7 +94,7 @@ export function AppUpdateToast({
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Later
|
||||
{t("appUpdate.toast.later")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -51,6 +52,7 @@ export function CamoufoxConfigDialog({
|
||||
isRunning = false,
|
||||
crossOsUnlocked = false,
|
||||
}: CamoufoxConfigDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Use union type to support both Camoufox and Wayfern configs
|
||||
const [config, setConfig] = useState<CamoufoxConfig | WayfernConfig>(() => ({
|
||||
geoip: true,
|
||||
@@ -93,9 +95,8 @@ export function CamoufoxConfigDialog({
|
||||
JSON.parse(config.fingerprint);
|
||||
} catch (_error) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Invalid fingerprint configuration", {
|
||||
description:
|
||||
"The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
|
||||
toast.error(t("camoufoxDialog.invalidFingerprint"), {
|
||||
description: t("camoufoxDialog.invalidFingerprintDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -112,9 +113,11 @@ export function CamoufoxConfigDialog({
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error);
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Failed to save configuration", {
|
||||
toast.error(t("camoufoxDialog.saveFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("camoufoxDialog.unknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -149,8 +152,15 @@ export function CamoufoxConfigDialog({
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{isRunning ? "View" : "Configure"} Fingerprint Settings -{" "}
|
||||
{profile.name} ({browserName})
|
||||
{isRunning
|
||||
? t("camoufoxDialog.titleView", {
|
||||
name: profile.name,
|
||||
browser: browserName,
|
||||
})
|
||||
: t("camoufoxDialog.titleConfigure", {
|
||||
name: profile.name,
|
||||
browser: browserName,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -185,7 +195,7 @@ export function CamoufoxConfigDialog({
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{isRunning ? "Close" : "Cancel"}
|
||||
{isRunning ? t("common.buttons.close") : t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
{!isRunning && (
|
||||
<LoadingButton
|
||||
@@ -193,7 +203,7 @@ export function CamoufoxConfigDialog({
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function CloneProfileDialog({
|
||||
onCloneComplete?.();
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to clone profile: ${errorMessage}`);
|
||||
showErrorToast(t("errors.cloneProfileFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,6 +23,7 @@ export function CommercialTrialModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: CommercialTrialModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isAcknowledging, setIsAcknowledging] = useState(false);
|
||||
|
||||
const handleAcknowledge = useCallback(async () => {
|
||||
@@ -31,14 +33,16 @@ export function CommercialTrialModal({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to acknowledge trial expiration:", error);
|
||||
showErrorToast("Failed to save acknowledgment", {
|
||||
showErrorToast(t("commercialTrial.failed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("commercialTrial.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsAcknowledging(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -55,17 +59,15 @@ export function CommercialTrialModal({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Commercial Trial Expired</DialogTitle>
|
||||
<DialogTitle>{t("commercialTrial.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your 2-week commercial trial period has ended.
|
||||
{t("commercialTrial.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If you are using Donut Browser for business purposes, you need to
|
||||
purchase a commercial license to continue. You can still use it for
|
||||
personal use for free.
|
||||
{t("commercialTrial.body")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +76,7 @@ export function CommercialTrialModal({
|
||||
onClick={handleAcknowledge}
|
||||
isLoading={isAcknowledging}
|
||||
>
|
||||
I Understand
|
||||
{t("commercialTrial.understandButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
@@ -66,6 +67,7 @@ export function CookieCopyDialog({
|
||||
runningProfiles,
|
||||
onCopyComplete,
|
||||
}: CookieCopyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sourceProfileId, setSourceProfileId] = useState<string | null>(null);
|
||||
const [cookieData, setCookieData] = useState<CookieReadResult | null>(null);
|
||||
const [isLoadingCookies, setIsLoadingCookies] = useState(false);
|
||||
@@ -243,10 +245,11 @@ export function CookieCopyDialog({
|
||||
runningProfiles.has(p.id),
|
||||
);
|
||||
if (runningTargets.length > 0) {
|
||||
const names = runningTargets.map((p) => p.name).join(", ");
|
||||
toast.error(
|
||||
`Cannot copy cookies: ${runningTargets.map((p) => p.name).join(", ")} ${
|
||||
runningTargets.length === 1 ? "is" : "are"
|
||||
} still running`,
|
||||
runningTargets.length === 1
|
||||
? t("cookies.copy.cannotCopyRunningOne", { names })
|
||||
: t("cookies.copy.cannotCopyRunningMany", { names }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -277,10 +280,15 @@ export function CookieCopyDialog({
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(`Some errors occurred: ${errors.join(", ")}`);
|
||||
toast.error(
|
||||
t("cookies.copy.someErrors", { errors: errors.join(", ") }),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Successfully copied ${totalCopied + totalReplaced} cookies (${totalReplaced} replaced)`,
|
||||
t("cookies.copy.successMessage", {
|
||||
copied: totalCopied + totalReplaced,
|
||||
replaced: totalReplaced,
|
||||
}),
|
||||
);
|
||||
onCopyComplete?.();
|
||||
onClose();
|
||||
@@ -288,7 +296,9 @@ export function CookieCopyDialog({
|
||||
} catch (err) {
|
||||
console.error("Failed to copy cookies:", err);
|
||||
toast.error(
|
||||
`Failed to copy cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
t("cookies.copy.failedMessage", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
@@ -300,6 +310,7 @@ export function CookieCopyDialog({
|
||||
buildSelectedCookies,
|
||||
onCopyComplete,
|
||||
onClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -325,23 +336,30 @@ export function CookieCopyDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuCookie className="w-5 h-5" />
|
||||
Copy Cookies
|
||||
{t("cookies.copy.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy cookies from a source profile to {selectedProfiles.length}{" "}
|
||||
selected profile{selectedProfiles.length !== 1 ? "s" : ""}.
|
||||
{selectedProfiles.length === 1
|
||||
? t("cookies.copy.dialogDescription_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("cookies.copy.dialogDescription_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Source Profile</Label>
|
||||
<Label>{t("cookies.copy.sourceProfile")}</Label>
|
||||
<Select
|
||||
value={sourceProfileId ?? undefined}
|
||||
onValueChange={handleSourceChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a profile to copy cookies from" />
|
||||
<SelectValue
|
||||
placeholder={t("cookies.copy.sourcePlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligibleSourceProfiles.map((profile) => {
|
||||
@@ -358,7 +376,7 @@ export function CookieCopyDialog({
|
||||
<span>{profile.name}</span>
|
||||
{isRunning && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(running)
|
||||
{t("cookies.copy.running")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -370,13 +388,17 @@ export function CookieCopyDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Profiles ({targetProfiles.length})</Label>
|
||||
<Label>
|
||||
{t("cookies.copy.targetProfiles", {
|
||||
count: targetProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
|
||||
{targetProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sourceProfileId
|
||||
? "No other Wayfern/Camoufox profiles selected"
|
||||
: "Select a source profile first"}
|
||||
? t("cookies.copy.noOtherTargets")
|
||||
: t("cookies.copy.selectSourceFirst")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -388,7 +410,7 @@ export function CookieCopyDialog({
|
||||
{p.name}
|
||||
{runningProfiles.has(p.id) && (
|
||||
<span className="text-xs text-destructive">
|
||||
(running)
|
||||
{t("cookies.copy.running")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -402,11 +424,13 @@ export function CookieCopyDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Select Cookies{" "}
|
||||
{t("cookies.copy.selectCookies")}{" "}
|
||||
{cookieData && (
|
||||
<span className="text-muted-foreground">
|
||||
({selectedCookieCount} of {cookieData.total_count}{" "}
|
||||
selected)
|
||||
{t("cookies.copy.selectionStatus", {
|
||||
selected: selectedCookieCount,
|
||||
total: cookieData.total_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -415,7 +439,7 @@ export function CookieCopyDialog({
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search domains or cookies..."
|
||||
placeholder={t("cookies.copy.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -435,8 +459,8 @@ export function CookieCopyDialog({
|
||||
) : filteredDomains.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No matching cookies found"
|
||||
: "No cookies found"}
|
||||
? t("cookies.copy.noMatching")
|
||||
: t("cookies.copy.noFound")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
@@ -457,8 +481,7 @@ export function CookieCopyDialog({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Existing cookies with the same name and domain will be replaced.
|
||||
Other cookies will be kept.
|
||||
{t("cookies.copy.replaceNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -470,15 +493,22 @@ export function CookieCopyDialog({
|
||||
onClick={onClose}
|
||||
disabled={isCopying}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCopying}
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={!canCopy}
|
||||
>
|
||||
Copy {selectedCookieCount > 0 ? `${selectedCookieCount} ` : ""}
|
||||
Cookie{selectedCookieCount !== 1 ? "s" : ""}
|
||||
{selectedCookieCount === 0
|
||||
? t("cookies.copy.copyButtonEmpty")
|
||||
: selectedCookieCount === 1
|
||||
? t("cookies.copy.copyButton_one", {
|
||||
count: selectedCookieCount,
|
||||
})
|
||||
: t("cookies.copy.copyButton_other", {
|
||||
count: selectedCookieCount,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
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 { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -122,6 +123,7 @@ export function CookieManagementDialog({
|
||||
profile,
|
||||
initialTab = "import",
|
||||
}: CookieManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Import state
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
@@ -171,13 +173,15 @@ export function CookieManagementDialog({
|
||||
setExportSelection(initSelectionFromCookieData(result));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Failed to load cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
t("cookies.management.loadFailed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingExportCookies(false);
|
||||
}
|
||||
},
|
||||
[exportCookieData],
|
||||
[exportCookieData, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -220,19 +224,22 @@ export function CookieManagementDialog({
|
||||
[resetImportState, resetExportState],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error(t("cookies.management.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!fileContent || !profile) return;
|
||||
@@ -297,14 +304,14 @@ export function CookieManagementDialog({
|
||||
}
|
||||
|
||||
await writeTextFile(filePath, content);
|
||||
toast.success("Cookies exported successfully");
|
||||
toast.success(t("cookies.export.success"));
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [profile, format, getSelectedCookies, handleClose]);
|
||||
}, [profile, format, getSelectedCookies, handleClose, t]);
|
||||
|
||||
const toggleDomain = useCallback(
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
@@ -385,7 +392,7 @@ export function CookieManagementDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cookie Management</DialogTitle>
|
||||
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
@@ -394,15 +401,19 @@ export function CookieManagementDialog({
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
<TabsTrigger value="import">
|
||||
{t("cookies.management.tabImport")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="export">
|
||||
{t("cookies.management.tabExport")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4">
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Import cookies from a Netscape or JSON format file.
|
||||
{t("cookies.management.importDescription")}
|
||||
</p>
|
||||
<div
|
||||
role="button"
|
||||
@@ -420,9 +431,11 @@ export function CookieManagementDialog({
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a cookie file
|
||||
{t("cookies.management.dropPrompt")}
|
||||
<br />
|
||||
<span className="text-xs">(.txt, .cookies, or .json)</span>
|
||||
<span className="text-xs">
|
||||
{t("cookies.management.fileFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
@@ -445,20 +458,22 @@ export function CookieManagementDialog({
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{cookieCount} cookies found
|
||||
{t("cookies.management.cookiesFound", {
|
||||
count: cookieCount,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={resetImportState}>
|
||||
Back
|
||||
{t("cookies.management.backButton")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={cookieCount === 0}
|
||||
>
|
||||
Import
|
||||
{t("cookies.management.importButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,17 +483,23 @@ export function CookieManagementDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-success/10">
|
||||
<div className="font-medium text-success">
|
||||
Successfully imported {importResult.cookies_imported}{" "}
|
||||
cookies ({importResult.cookies_replaced} replaced)
|
||||
{t("cookies.management.importedSuccess", {
|
||||
imported: importResult.cookies_imported,
|
||||
replaced: importResult.cookies_replaced,
|
||||
})}
|
||||
</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{importResult.errors.length} line(s) skipped
|
||||
{t("cookies.management.linesSkipped", {
|
||||
count: importResult.errors.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("cookies.management.doneButton")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -486,7 +507,7 @@ export function CookieManagementDialog({
|
||||
|
||||
<TabsContent value="export" className="space-y-3 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<Label>{t("cookies.export.formatLabel")}</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => {
|
||||
@@ -497,8 +518,12 @@ export function CookieManagementDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="netscape">Netscape TXT</SelectItem>
|
||||
<SelectItem value="json">
|
||||
{t("cookies.export.json")}
|
||||
</SelectItem>
|
||||
<SelectItem value="netscape">
|
||||
{t("cookies.export.netscape")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -506,11 +531,13 @@ export function CookieManagementDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Cookies{" "}
|
||||
{t("cookies.management.cookiesLabel")}{" "}
|
||||
{exportCookieData && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({selectedExportCount} of {exportCookieData.total_count}{" "}
|
||||
selected)
|
||||
{t("cookies.management.selectionStatus", {
|
||||
selected: selectedExportCount,
|
||||
total: exportCookieData.total_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -521,8 +548,8 @@ export function CookieManagementDialog({
|
||||
onClick={toggleSelectAll}
|
||||
>
|
||||
{selectedExportCount === exportCookieData.total_count
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
? t("cookies.management.deselectAll")
|
||||
: t("cookies.management.selectAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -533,7 +560,7 @@ export function CookieManagementDialog({
|
||||
</div>
|
||||
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
|
||||
No cookies found in this profile
|
||||
{t("cookies.management.noCookies")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[200px] border rounded-md">
|
||||
@@ -556,14 +583,14 @@ export function CookieManagementDialog({
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isExporting}
|
||||
onClick={() => void handleExport()}
|
||||
disabled={selectedExportCount === 0}
|
||||
>
|
||||
Export
|
||||
{t("cookies.management.exportButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ export function CreateGroupDialog({
|
||||
onClose,
|
||||
onGroupCreated,
|
||||
}: CreateGroupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -42,20 +44,20 @@ export function CreateGroupDialog({
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group created successfully");
|
||||
toast.success(t("groups.createSuccess"));
|
||||
onGroupCreated(newGroup);
|
||||
setGroupName("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to create group";
|
||||
err instanceof Error ? err.message : t("groups.createFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [groupName, onGroupCreated, onClose]);
|
||||
}, [groupName, onGroupCreated, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setGroupName("");
|
||||
@@ -67,18 +69,16 @@ export function CreateGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new group to organize your browser profiles.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.createDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Label htmlFor="group-name">{t("groups.form.name")}</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
placeholder={t("groups.form.namePlaceholder")}
|
||||
value={groupName}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
@@ -105,14 +105,14 @@ export function CreateGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCreating}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!groupName.trim()}
|
||||
>
|
||||
Create
|
||||
{t("common.buttons.create")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -625,10 +625,10 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
Regular Browsers
|
||||
{t("createProfile.regular.title")}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose from supported regular browsers
|
||||
{t("createProfile.regular.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -655,7 +655,7 @@ export function CreateProfileDialog({
|
||||
{browser.label}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Regular Browser
|
||||
{t("createProfile.regular.badge")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -672,7 +672,9 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Label htmlFor="profile-name">
|
||||
{t("createProfile.profileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
@@ -688,7 +690,9 @@ export function CreateProfileDialog({
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
placeholder={t(
|
||||
"createProfile.profileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -722,7 +726,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching available versions...
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -739,7 +743,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -748,8 +752,9 @@ export function CreateProfileDialog({
|
||||
!getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
Wayfern is not available on your platform
|
||||
yet.
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Wayfern",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -760,11 +765,12 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `Wayfern version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -779,8 +785,8 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("wayfern")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -789,20 +795,22 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
isBrowserVersionAvailable("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `✓ Wayfern version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `Downloading Wayfern version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -826,7 +834,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching available versions...
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -843,7 +851,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -852,8 +860,9 @@ export function CreateProfileDialog({
|
||||
!getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
Camoufox is not available on your platform
|
||||
yet.
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Camoufox",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -864,11 +873,12 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -883,8 +893,8 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -893,20 +903,23 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `✓ Camoufox version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Downloading Camoufox version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -940,7 +953,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching available versions...
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -971,13 +984,15 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -992,7 +1007,7 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1005,26 +1020,31 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestDownloading",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1035,7 +1055,7 @@ export function CreateProfileDialog({
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy / VPN</Label>
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1044,7 +1064,8 @@ export function CreateProfileDialog({
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
@@ -1061,20 +1082,23 @@ export function CreateProfileDialog({
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
return t("createProfile.proxy.noProxy");
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
? `WG — ${vpn.name}`
|
||||
: t("createProfile.proxy.noProxy");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
return (
|
||||
proxy?.name ??
|
||||
t("createProfile.proxy.noProxy")
|
||||
);
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -1084,10 +1108,14 @@ export function CreateProfileDialog({
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"createProfile.proxy.search",
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -1105,7 +1133,7 @@ export function CreateProfileDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -1154,9 +1182,7 @@ export function CreateProfileDialog({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -1169,8 +1195,7 @@ export function CreateProfileDialog({
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1267,7 +1292,9 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Label htmlFor="profile-name">
|
||||
{t("createProfile.profileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
@@ -1283,7 +1310,9 @@ export function CreateProfileDialog({
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
placeholder={t(
|
||||
"createProfile.profileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1312,7 +1341,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1325,13 +1354,15 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -1346,7 +1377,7 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1357,24 +1388,30 @@ export function CreateProfileDialog({
|
||||
) &&
|
||||
isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestDownloading",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(selectedBrowser)
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1384,7 +1421,7 @@ export function CreateProfileDialog({
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy / VPN</Label>
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1393,7 +1430,8 @@ export function CreateProfileDialog({
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
@@ -1410,20 +1448,23 @@ export function CreateProfileDialog({
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
return t("createProfile.proxy.noProxy");
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
? `WG — ${vpn.name}`
|
||||
: t("createProfile.proxy.noProxy");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
return (
|
||||
proxy?.name ??
|
||||
t("createProfile.proxy.noProxy")
|
||||
);
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -1433,10 +1474,14 @@ export function CreateProfileDialog({
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"createProfile.proxy.search",
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -1454,7 +1499,7 @@ export function CreateProfileDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -1503,9 +1548,7 @@ export function CreateProfileDialog({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -1518,8 +1561,7 @@ export function CreateProfileDialog({
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1553,19 +1595,19 @@ export function CreateProfileDialog({
|
||||
{currentStep === "browser-config" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
Create
|
||||
{t("common.buttons.create")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
*/
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuCheckCheck,
|
||||
LuDownload,
|
||||
@@ -214,6 +215,7 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
}
|
||||
|
||||
export function UnifiedToast(props: ToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const { title, description, type, action, onCancel } = props;
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
@@ -231,7 +233,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Cancel"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -292,7 +294,9 @@ export function UnifiedToast(props: ToastProps) {
|
||||
"completed_files" in progress && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "}
|
||||
{progress.phase === "uploading"
|
||||
? t("appUpdate.toast.uploading")
|
||||
: t("appUpdate.toast.downloading")}{" "}
|
||||
{progress.completed_files}/{progress.total_files} files
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
@@ -347,17 +351,17 @@ export function UnifiedToast(props: ToastProps) {
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Extracting browser files... Please do not close the app.
|
||||
{t("browserDownload.toast.extracting")}
|
||||
</p>
|
||||
)}
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Verifying browser files...
|
||||
{t("browserDownload.toast.verifying")}
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Downloading rolling release build...
|
||||
{t("browserDownload.toast.downloadingRolling")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Table } from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -134,6 +135,7 @@ interface DataTableActionBarSelectionProps<TData> {
|
||||
function DataTableActionBarSelection<TData>({
|
||||
table,
|
||||
}: DataTableActionBarSelectionProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const onClearSelection = React.useCallback(() => {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}, [table]);
|
||||
@@ -141,7 +143,9 @@ function DataTableActionBarSelection<TData>({
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
{t("dataTableActionBar.selected", {
|
||||
count: table.getFilteredSelectedRowModel().rows.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="mr-1 ml-2 h-4 w-px bg-border" />
|
||||
<Tooltip>
|
||||
@@ -159,9 +163,9 @@ function DataTableActionBarSelection<TData>({
|
||||
sideOffset={10}
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
|
||||
>
|
||||
<p>Clear selection</p>
|
||||
<p>{t("dataTableActionBar.clearSelection")}</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<abbr title="Escape" className="no-underline">
|
||||
<abbr title={t("common.keys.escape")} className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
</kbd>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -29,11 +30,12 @@ export function DeleteConfirmationDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmButtonText = "Delete",
|
||||
confirmButtonText,
|
||||
isLoading = false,
|
||||
profileIds,
|
||||
profiles = [],
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
};
|
||||
@@ -47,7 +49,7 @@ export function DeleteConfirmationDialog({
|
||||
{profileIds && profileIds.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Profiles to be deleted:
|
||||
{t("deleteDialog.profilesToDelete")}
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
@@ -71,14 +73,14 @@ export function DeleteConfirmationDialog({
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{confirmButtonText}
|
||||
{confirmButtonText ?? t("common.buttons.delete")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -56,11 +56,13 @@ export function DeleteGroupDialog({
|
||||
setAssociatedProfiles(groupProfiles);
|
||||
} catch (err) {
|
||||
console.error("Failed to load associated profiles:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load profiles");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groups.loadProfilesFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [group]);
|
||||
}, [group, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && group) {
|
||||
@@ -90,19 +92,19 @@ export function DeleteGroupDialog({
|
||||
// Delete the group
|
||||
await invoke("delete_profile_group", { groupId: group.id });
|
||||
|
||||
toast.success("Group deleted successfully");
|
||||
toast.success(t("groups.deleteSuccess"));
|
||||
onGroupDeleted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to delete group";
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]);
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -115,17 +117,14 @@ export function DeleteGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the group
|
||||
"{group?.name}".
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.deleteTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.deleteDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading associated profiles...
|
||||
{t("groups.loadingProfiles")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -133,7 +132,9 @@ export function DeleteGroupDialog({
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Associated Profiles ({associatedProfiles.length})
|
||||
{t("groups.associatedProfiles", {
|
||||
count: associatedProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<div className="space-y-1">
|
||||
@@ -147,7 +148,7 @@ export function DeleteGroupDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>What should happen to these profiles?</Label>
|
||||
<Label>{t("groups.whatToDoWithProfiles")}</Label>
|
||||
<RadioGroup
|
||||
value={deleteAction}
|
||||
onValueChange={(value) => {
|
||||
@@ -166,7 +167,7 @@ export function DeleteGroupDialog({
|
||||
htmlFor="delete"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
Delete profiles along with the group
|
||||
{t("groups.deleteAlongWithGroup")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@@ -176,7 +177,7 @@ export function DeleteGroupDialog({
|
||||
|
||||
{associatedProfiles.length === 0 && !isLoading && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This group has no associated profiles.
|
||||
{t("groups.noAssociatedProfiles")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -195,7 +196,7 @@ export function DeleteGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
@@ -203,10 +204,9 @@ export function DeleteGroupDialog({
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete Group
|
||||
{deleteAction === "delete" &&
|
||||
associatedProfiles.length > 0 &&
|
||||
" & Profiles"}
|
||||
{deleteAction === "delete" && associatedProfiles.length > 0
|
||||
? t("groups.deleteGroupAndProfiles")
|
||||
: t("groups.deleteGroup")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ export function EditGroupDialog({
|
||||
group,
|
||||
onGroupUpdated,
|
||||
}: EditGroupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,19 +56,19 @@ export function EditGroupDialog({
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group updated successfully");
|
||||
toast.success(t("groups.updateSuccess"));
|
||||
onGroupUpdated(updatedGroup);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update group";
|
||||
err instanceof Error ? err.message : t("groups.updateFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [group, groupName, onGroupUpdated, onClose]);
|
||||
}, [group, groupName, onGroupUpdated, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -77,18 +79,16 @@ export function EditGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the name of the group "{group?.name}".
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.editTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.editDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Label htmlFor="group-name">{t("groups.form.name")}</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
placeholder={t("groups.form.namePlaceholder")}
|
||||
value={groupName}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
@@ -115,14 +115,14 @@ export function EditGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={!groupName.trim() || groupName === group?.name}
|
||||
>
|
||||
Update Group
|
||||
{t("groups.edit")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -55,12 +55,12 @@ export function ExtensionGroupAssignmentDialog({
|
||||
} catch (err) {
|
||||
console.error("Failed to load extension groups:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load extension groups",
|
||||
err instanceof Error ? err.message : t("extensions.loadGroupsFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
@@ -79,7 +79,7 @@ export function ExtensionGroupAssignmentDialog({
|
||||
} catch (err) {
|
||||
console.error("Failed to assign extension group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to assign extension group";
|
||||
err instanceof Error ? err.message : t("extensions.assignGroupFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -50,36 +50,43 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: t("profileTable.syncTooltipSyncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: item.last_sync
|
||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("profileTable.syncTooltipSyncedAt", {
|
||||
time: new Date(item.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("profileTable.syncTooltipSynced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("profileTable.syncTooltipWaiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: "Sync error",
|
||||
tooltip: t("profileTable.syncTooltipError"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("profileTable.syncTooltipNotSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -674,6 +681,7 @@ export function ExtensionManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
ext,
|
||||
extSyncStatus[ext.id],
|
||||
t,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
@@ -840,6 +848,7 @@ export function ExtensionManagementDialog({
|
||||
const groupSyncDot = getSyncStatusDot(
|
||||
group,
|
||||
extSyncStatus[group.id],
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -995,7 +1004,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1003,87 +1012,89 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editGroupName}
|
||||
onChange={(e) => {
|
||||
setEditGroupName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.groupNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
|
||||
.length > 0 && (
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.addToGroup")}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(extId) => {
|
||||
setEditGroupExtensionIds((prev) => [...prev, extId]);
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editGroupName}
|
||||
onChange={(e) => {
|
||||
setEditGroupName(e.target.value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("extensions.addToGroup")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{extensions
|
||||
.filter((e) => !editGroupExtensionIds.includes(e.id))
|
||||
.map((ext) => (
|
||||
<SelectItem key={ext.id} value={ext.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderExtensionIcon(ext, "sm")}
|
||||
{ext.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder={t("extensions.groupNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.groupExtensions")}</Label>
|
||||
{editGroupExtensionIds.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{editGroupExtensionIds.map((extId) => {
|
||||
const ext = extensions.find((e) => e.id === extId);
|
||||
if (!ext) return null;
|
||||
return (
|
||||
<div
|
||||
key={extId}
|
||||
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
|
||||
>
|
||||
{renderExtensionIcon(ext, "sm")}
|
||||
<span className="text-sm flex-1 truncate min-w-0">
|
||||
{ext.name}
|
||||
</span>
|
||||
{renderCompatIcons(ext.browser_compatibility)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => {
|
||||
setEditGroupExtensionIds((prev) =>
|
||||
prev.filter((id) => id !== extId),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
|
||||
.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.addToGroup")}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(extId) => {
|
||||
setEditGroupExtensionIds((prev) => [...prev, extId]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("extensions.addToGroup")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{extensions
|
||||
.filter((e) => !editGroupExtensionIds.includes(e.id))
|
||||
.map((ext) => (
|
||||
<SelectItem key={ext.id} value={ext.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderExtensionIcon(ext, "sm")}
|
||||
{ext.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.groupExtensions")}</Label>
|
||||
{editGroupExtensionIds.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{editGroupExtensionIds.map((extId) => {
|
||||
const ext = extensions.find((e) => e.id === extId);
|
||||
if (!ext) return null;
|
||||
return (
|
||||
<div
|
||||
key={extId}
|
||||
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
|
||||
>
|
||||
{renderExtensionIcon(ext, "sm")}
|
||||
<span className="text-sm flex-1 truncate min-w-0">
|
||||
{ext.name}
|
||||
</span>
|
||||
{renderCompatIcons(ext.browser_compatibility)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => {
|
||||
setEditGroupExtensionIds((prev) =>
|
||||
prev.filter((id) => id !== extId),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -1117,7 +1128,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editExtension")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1125,123 +1136,127 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{editingExtension && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editExtensionName}
|
||||
onChange={(e) => {
|
||||
setEditExtensionName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.namePlaceholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleUpdateExtension();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
{editingExtension && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editExtensionName}
|
||||
onChange={(e) => {
|
||||
setEditExtensionName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.namePlaceholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleUpdateExtension();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata from manifest.json */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{t("extensions.metadata")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
{editingExtension.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.version")}
|
||||
</span>
|
||||
<span>{editingExtension.version}</span>
|
||||
</>
|
||||
)}
|
||||
{editingExtension.author && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.author")}
|
||||
</span>
|
||||
<span>{editingExtension.author}</span>
|
||||
</>
|
||||
)}
|
||||
{editingExtension.description && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.labels.description")}
|
||||
</span>
|
||||
<span className="line-clamp-3">
|
||||
{editingExtension.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.compatibility.label")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{renderCompatIcons(editingExtension.browser_compatibility)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.labels.type")}
|
||||
</span>
|
||||
<span>.{editingExtension.file_type}</span>
|
||||
{editingExtension.homepage_url && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.homepage")}
|
||||
</span>
|
||||
<a
|
||||
href={editingExtension.homepage_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1 truncate"
|
||||
>
|
||||
<span className="truncate">
|
||||
{editingExtension.homepage_url}
|
||||
{/* Metadata from manifest.json */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{t("extensions.metadata")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
{editingExtension.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.version")}
|
||||
</span>
|
||||
<LuExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{!editingExtension.version &&
|
||||
!editingExtension.author &&
|
||||
!editingExtension.description &&
|
||||
!editingExtension.homepage_url && (
|
||||
<span className="col-span-2 text-muted-foreground text-xs">
|
||||
{t("extensions.noMetadata")}
|
||||
<span>{editingExtension.version}</span>
|
||||
</>
|
||||
)}
|
||||
{editingExtension.author && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.author")}
|
||||
</span>
|
||||
<span>{editingExtension.author}</span>
|
||||
</>
|
||||
)}
|
||||
{editingExtension.description && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.labels.description")}
|
||||
</span>
|
||||
<span className="line-clamp-3">
|
||||
{editingExtension.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.compatibility.label")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{renderCompatIcons(
|
||||
editingExtension.browser_compatibility,
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.labels.type")}
|
||||
</span>
|
||||
<span>.{editingExtension.file_type}</span>
|
||||
{editingExtension.homepage_url && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.homepage")}
|
||||
</span>
|
||||
<a
|
||||
href={editingExtension.homepage_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1 truncate"
|
||||
>
|
||||
<span className="truncate">
|
||||
{editingExtension.homepage_url}
|
||||
</span>
|
||||
<LuExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{!editingExtension.version &&
|
||||
!editingExtension.author &&
|
||||
!editingExtension.description &&
|
||||
!editingExtension.homepage_url && (
|
||||
<span className="col-span-2 text-muted-foreground text-xs">
|
||||
{t("extensions.noMetadata")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Re-upload */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.reupload")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
document.getElementById("ext-edit-file-input")?.click()
|
||||
}
|
||||
>
|
||||
<LuUpload className="w-3 h-3 mr-1" />
|
||||
{t("extensions.selectFile")}
|
||||
</RippleButton>
|
||||
<input
|
||||
id="ext-edit-file-input"
|
||||
type="file"
|
||||
accept=".xpi,.crx,.zip"
|
||||
className="hidden"
|
||||
onChange={handleEditFileSelect}
|
||||
/>
|
||||
{pendingUpdateFile && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{pendingUpdateFile.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Re-upload */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.reupload")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
document.getElementById("ext-edit-file-input")?.click()
|
||||
}
|
||||
>
|
||||
<LuUpload className="w-3 h-3 mr-1" />
|
||||
{t("extensions.selectFile")}
|
||||
</RippleButton>
|
||||
<input
|
||||
id="ext-edit-file-input"
|
||||
type="file"
|
||||
accept=".xpi,.crx,.zip"
|
||||
className="hidden"
|
||||
onChange={handleEditFileSelect}
|
||||
/>
|
||||
{pendingUpdateFile && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{pendingUpdateFile.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -57,11 +57,13 @@ export function GroupAssignmentDialog({
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
@@ -73,7 +75,8 @@ export function GroupAssignmentDialog({
|
||||
});
|
||||
|
||||
const groupName = selectedGroupId
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group"
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name ||
|
||||
t("groups.unknownGroup")
|
||||
: t("groups.defaultGroup");
|
||||
|
||||
toast.success(
|
||||
@@ -89,7 +92,7 @@ export function GroupAssignmentDialog({
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign profiles to group";
|
||||
: t("groupAssignment.failedFallback");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -116,15 +119,21 @@ export function GroupAssignmentDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Group</DialogTitle>
|
||||
<DialogTitle>{t("groupAssignment.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProfiles.length} selected profile(s) to a group.
|
||||
{selectedProfiles.length === 1
|
||||
? t("groupAssignment.description_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("groupAssignment.description_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
@@ -145,7 +154,9 @@ export function GroupAssignmentDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="group-select">Assign to Group:</Label>
|
||||
<Label htmlFor="group-select">
|
||||
{t("groupAssignment.assignGroupLabel")}
|
||||
</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -154,12 +165,13 @@ export function GroupAssignmentDialog({
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Create Group
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("groupManagement.createGroup")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
{t("groupManagement.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
@@ -169,7 +181,7 @@ export function GroupAssignmentDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a group" />
|
||||
<SelectValue placeholder={t("groupAssignment.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
@@ -198,14 +210,14 @@ export function GroupAssignmentDialog({
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Assign
|
||||
{t("groupAssignment.assignButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function GroupBadges({
|
||||
return (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
||||
Loading groups...
|
||||
{t("groups.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ export function GroupBadges({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
role="region"
|
||||
aria-label="Profile groups"
|
||||
aria-label={t("groups.profileGroupsAriaLabel")}
|
||||
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
onScroll={checkScrollPosition}
|
||||
onMouseDown={handleMouseDown}
|
||||
|
||||
@@ -44,37 +44,46 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
group: GroupWithCount,
|
||||
liveStatus: SyncStatus | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: t("syncTooltips.syncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: group.last_sync
|
||||
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("syncTooltips.syncedAt", {
|
||||
time: new Date(group.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("syncTooltips.synced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("syncTooltips.waiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("syncTooltips.errorWith", { error: errorMessage })
|
||||
: t("syncTooltips.error"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("syncTooltips.notSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -165,11 +174,13 @@ export function GroupManagementDialog({
|
||||
setGroupInUse(inUse);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleGroupCreated = useCallback(
|
||||
(_newGroup: ProfileGroup) => {
|
||||
@@ -210,18 +221,24 @@ export function GroupManagementDialog({
|
||||
groupId: group.id,
|
||||
enabled: !group.sync_enabled,
|
||||
});
|
||||
showSuccessToast(group.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
showSuccessToast(
|
||||
group.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [group.id]: false }));
|
||||
}
|
||||
},
|
||||
[loadGroups],
|
||||
[loadGroups, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -244,7 +261,7 @@ export function GroupManagementDialog({
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<Label>{t("groupManagement.groupsLabel")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -253,7 +270,7 @@ export function GroupManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +283,7 @@ export function GroupManagementDialog({
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -278,10 +295,16 @@ export function GroupManagementDialog({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("groupManagement.profilesCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -289,6 +312,7 @@ export function GroupManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
t,
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
@@ -332,14 +356,13 @@ export function GroupManagementDialog({
|
||||
<TooltipContent>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
{t("groupManagement.syncCannotDisable")}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
? t("proxies.management.disableSync")
|
||||
: t("proxies.management.enableSync")}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
@@ -360,7 +383,9 @@ export function GroupManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
<p>
|
||||
{t("groupManagement.editGroupTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -376,7 +401,9 @@ export function GroupManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
<p>
|
||||
{t("groupManagement.deleteGroupTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -393,7 +420,7 @@ export function GroupManagementDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -49,6 +50,7 @@ export function ImportProfileDialog({
|
||||
onClose,
|
||||
crossOsUnlocked,
|
||||
}: ImportProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
|
||||
[],
|
||||
);
|
||||
@@ -103,11 +105,11 @@ export function ImportProfileDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to detect existing profiles:", error);
|
||||
toast.error("Failed to detect existing browser profiles");
|
||||
toast.error(t("importProfile.detectFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const selectedProfile = detectedProfiles.find(
|
||||
(p) => p.path === selectedDetectedProfile,
|
||||
@@ -118,7 +120,7 @@ export function ImportProfileDialog({
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Select Browser Profile Folder",
|
||||
title: t("importProfile.selectFolderTitle"),
|
||||
});
|
||||
|
||||
if (selected && typeof selected === "string") {
|
||||
@@ -126,7 +128,7 @@ export function ImportProfileDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open folder dialog:", error);
|
||||
toast.error("Failed to open folder dialog");
|
||||
toast.error(t("importProfile.folderDialogFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,14 +139,14 @@ export function ImportProfileDialog({
|
||||
|
||||
if (importMode === "auto-detect") {
|
||||
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
|
||||
toast.error("Please select a profile and provide a name");
|
||||
toast.error(t("importProfile.selectAndName"));
|
||||
return;
|
||||
}
|
||||
const profile = detectedProfiles.find(
|
||||
(p) => p.path === selectedDetectedProfile,
|
||||
);
|
||||
if (!profile) {
|
||||
toast.error("Selected profile not found");
|
||||
toast.error(t("importProfile.profileNotFound"));
|
||||
return;
|
||||
}
|
||||
sourcePath = profile.path;
|
||||
@@ -156,7 +158,7 @@ export function ImportProfileDialog({
|
||||
!manualProfilePath.trim() ||
|
||||
!manualProfileName.trim()
|
||||
) {
|
||||
toast.error("Please fill in all fields");
|
||||
toast.error(t("importProfile.fillFields"));
|
||||
return;
|
||||
}
|
||||
sourcePath = manualProfilePath.trim();
|
||||
@@ -180,7 +182,9 @@ export function ImportProfileDialog({
|
||||
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||
});
|
||||
|
||||
toast.success(`Successfully imported profile "${newProfileName}"`);
|
||||
toast.success(
|
||||
t("importProfile.importedSuccess", { name: newProfileName }),
|
||||
);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import profile:", error);
|
||||
@@ -190,13 +194,13 @@ export function ImportProfileDialog({
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||
toast.error(
|
||||
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
|
||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||
{
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to import profile: ${errorMessage}`);
|
||||
toast.error(t("importProfile.importFailed", { error: errorMessage }));
|
||||
}
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
@@ -214,6 +218,7 @@ export function ImportProfileDialog({
|
||||
wayfernConfig,
|
||||
onClose,
|
||||
selectedProfile,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -290,7 +295,7 @@ export function ImportProfileDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Import Browser Profile</DialogTitle>
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
||||
@@ -305,7 +310,7 @@ export function ImportProfileDialog({
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Auto-Detect
|
||||
{t("importProfile.autoDetect")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant={importMode === "manual" ? "default" : "outline"}
|
||||
@@ -315,30 +320,29 @@ export function ImportProfileDialog({
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Manual Import
|
||||
{t("importProfile.manualImport")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{importMode === "auto-detect" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Detected Browser Profiles
|
||||
{t("importProfile.detectedProfilesTitle")}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Scanning for browser profiles...
|
||||
{t("importProfile.scanning")}
|
||||
</p>
|
||||
</div>
|
||||
) : detectedProfiles.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No browser profiles found on your system.
|
||||
{t("importProfile.noneFound")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Try the manual import option if you have profiles in
|
||||
custom locations.
|
||||
{t("importProfile.noneFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -348,7 +352,7 @@ export function ImportProfileDialog({
|
||||
htmlFor="detected-profile-select"
|
||||
className="mb-2"
|
||||
>
|
||||
Select Profile:
|
||||
{t("importProfile.selectProfile")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedDetectedProfile ?? undefined}
|
||||
@@ -357,7 +361,11 @@ export function ImportProfileDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="detected-profile-select">
|
||||
<SelectValue placeholder="Choose a detected profile" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"importProfile.selectProfilePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detectedProfiles.map((profile) => {
|
||||
@@ -395,11 +403,15 @@ export function ImportProfileDialog({
|
||||
{selectedProfile && (
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Path:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("importProfile.pathLabel")}
|
||||
</span>{" "}
|
||||
{selectedProfile.path}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Browser:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("importProfile.browserLabel")}
|
||||
</span>{" "}
|
||||
{getBrowserDisplayName(selectedProfile.browser)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -407,7 +419,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="auto-profile-name" className="mb-2">
|
||||
New Profile Name:
|
||||
{t("importProfile.newProfileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="auto-profile-name"
|
||||
@@ -415,7 +427,9 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setAutoDetectProfileName(e.target.value);
|
||||
}}
|
||||
placeholder="Enter a name for the imported profile"
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,12 +439,14 @@ export function ImportProfileDialog({
|
||||
|
||||
{importMode === "manual" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Manual Profile Import</h3>
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.manualTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="manual-browser-select" className="mb-2">
|
||||
Browser Type:
|
||||
{t("importProfile.browserType")}
|
||||
</Label>
|
||||
<Select
|
||||
value={manualBrowserType ?? undefined}
|
||||
@@ -443,8 +459,8 @@ export function ImportProfileDialog({
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingSupport
|
||||
? "Loading browsers..."
|
||||
: "Select browser type"
|
||||
? t("importProfile.loadingBrowsers")
|
||||
: t("importProfile.selectBrowserType")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@@ -468,7 +484,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-path" className="mb-2">
|
||||
Profile Folder Path:
|
||||
{t("importProfile.profileFolderPath")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -477,19 +493,21 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setManualProfilePath(e.target.value);
|
||||
}}
|
||||
placeholder="Enter the full path to the profile folder"
|
||||
placeholder={t(
|
||||
"importProfile.profileFolderPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseFolder()}
|
||||
title="Browse for folder"
|
||||
title={t("importProfile.browseFolderTitle")}
|
||||
>
|
||||
<FaFolder className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Example paths:
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
Support/Firefox/Profiles/xxx.default
|
||||
@@ -502,7 +520,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-name" className="mb-2">
|
||||
New Profile Name:
|
||||
{t("importProfile.newProfileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-profile-name"
|
||||
@@ -510,7 +528,9 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setManualProfileName(e.target.value);
|
||||
}}
|
||||
placeholder="Enter a name for the imported profile"
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,14 +543,16 @@ export function ImportProfileDialog({
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This profile will be imported as a{" "}
|
||||
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
|
||||
profile.
|
||||
{t("importProfile.importedAs", {
|
||||
browser: getBrowserDisplayName(currentMappedBrowser),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Proxy (Optional)</Label>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.proxyOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedProxyId ?? "none"}
|
||||
onValueChange={(value) => {
|
||||
@@ -538,10 +560,12 @@ export function ImportProfileDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
<SelectValue placeholder={t("importProfile.noProxy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noProxy")}
|
||||
</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
@@ -580,7 +604,7 @@ export function ImportProfileDialog({
|
||||
{currentStep === "select" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
disabled={!canProceedToNext}
|
||||
@@ -588,7 +612,7 @@ export function ImportProfileDialog({
|
||||
setCurrentStep("configure");
|
||||
}}
|
||||
>
|
||||
Next
|
||||
{t("importProfile.nextButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
) : (
|
||||
@@ -599,7 +623,7 @@ export function ImportProfileDialog({
|
||||
setCurrentStep("select");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
@@ -607,7 +631,7 @@ export function ImportProfileDialog({
|
||||
void handleImport();
|
||||
}}
|
||||
>
|
||||
Import
|
||||
{t("importProfile.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function IntegrationsDialog({
|
||||
settings: { ...settings, api_enabled: true },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast(`API server started on port ${port}`);
|
||||
showSuccessToast(t("integrations.apiStarted", { port }));
|
||||
} else {
|
||||
await invoke("stop_api_server");
|
||||
setApiServerPort(null);
|
||||
@@ -156,12 +156,13 @@ export function IntegrationsDialog({
|
||||
settings: { ...settings, api_enabled: false, api_token: null },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast("API server stopped");
|
||||
showSuccessToast(t("integrations.apiStopped"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle API:", e);
|
||||
showErrorToast("Failed to toggle API server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
showErrorToast(t("integrations.apiToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
@@ -178,7 +179,7 @@ export function IntegrationsDialog({
|
||||
});
|
||||
setSettings(next);
|
||||
void loadMcpConfig();
|
||||
showSuccessToast(`MCP server started on port ${port}`);
|
||||
showSuccessToast(t("integrations.mcpStarted", { port }));
|
||||
} else {
|
||||
await invoke("stop_mcp_server");
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
@@ -186,12 +187,13 @@ export function IntegrationsDialog({
|
||||
});
|
||||
setSettings(next);
|
||||
setMcpConfig(null);
|
||||
showSuccessToast("MCP server stopped");
|
||||
showSuccessToast(t("integrations.mcpStopped"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle MCP server:", e);
|
||||
showErrorToast("Failed to toggle MCP server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
showErrorToast(t("integrations.mcpToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsMcpStarting(false);
|
||||
@@ -207,14 +209,14 @@ export function IntegrationsDialog({
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>Integrations</DialogTitle>
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<Tabs defaultValue="api" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="api">Local API</TabsTrigger>
|
||||
<TabsTrigger value="mcp">MCP (AI Assistants)</TabsTrigger>
|
||||
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
|
||||
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="api" className="space-y-4 mt-4">
|
||||
@@ -230,10 +232,10 @@ export function IntegrationsDialog({
|
||||
htmlFor="api-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable Local API Server
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow managing profiles, groups, and proxies via REST API.
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +243,9 @@ export function IntegrationsDialog({
|
||||
{settings.api_enabled && (
|
||||
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Port</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -251,8 +255,10 @@ export function IntegrationsDialog({
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast("Invalid port", {
|
||||
description: "Port must be between 1 and 65535",
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -270,20 +276,28 @@ export function IntegrationsDialog({
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(`Port ${port} is already in use`, {
|
||||
description: `Server started on fallback port ${actualPort}`,
|
||||
});
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
`API server running on port ${actualPort}`,
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast("Failed to start API server", {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Unknown error",
|
||||
: t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
@@ -315,7 +329,7 @@ export function IntegrationsDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Authentication Token
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -343,11 +357,13 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage="Token copied"
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include in Authorization header: Bearer {"<token>"}
|
||||
{t("integrations.apiTokenHint", {
|
||||
tokenSlot: "<token>",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,13 +383,13 @@ export function IntegrationsDialog({
|
||||
htmlFor="mcp-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable MCP Server (Model Context Protocol)
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow AI assistants like Claude Desktop to control browsers.
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
(Accept Wayfern terms in Settings first)
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ export function LaunchOnLoginDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LaunchOnLoginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
const [isDeclining, setIsDeclining] = useState(false);
|
||||
|
||||
@@ -29,18 +31,18 @@ export function LaunchOnLoginDialog({
|
||||
setIsEnabling(true);
|
||||
try {
|
||||
await invoke("enable_launch_on_login");
|
||||
showSuccessToast("Launch on login enabled");
|
||||
showSuccessToast(t("launchOnLogin.enableSuccess"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to enable launch on login:", error);
|
||||
showErrorToast("Failed to enable launch on login", {
|
||||
showErrorToast(t("launchOnLogin.enableFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
const handleDecline = useCallback(async () => {
|
||||
setIsDeclining(true);
|
||||
@@ -49,14 +51,14 @@ export function LaunchOnLoginDialog({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to decline launch on login:", error);
|
||||
showErrorToast("Failed to save preference", {
|
||||
showErrorToast(t("launchOnLogin.declineFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsDeclining(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -73,11 +75,11 @@ export function LaunchOnLoginDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Launch on Login?</DialogTitle>
|
||||
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running in the background helps keep your proxies and browsers alive.
|
||||
{t("launchOnLogin.description")}
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
@@ -86,14 +88,16 @@ export function LaunchOnLoginDialog({
|
||||
onClick={handleDecline}
|
||||
disabled={isEnabling || isDeclining}
|
||||
>
|
||||
{isDeclining ? "..." : "Don't Ask Again"}
|
||||
{isDeclining
|
||||
? t("launchOnLogin.declining")
|
||||
: t("launchOnLogin.declineButton")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleEnable}
|
||||
isLoading={isEnabling}
|
||||
disabled={isDeclining}
|
||||
>
|
||||
Enable
|
||||
{t("launchOnLogin.enableButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
@@ -29,6 +30,7 @@ export function LocationProxyDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LocationProxyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [countries, setCountries] = useState<LocationItem[]>([]);
|
||||
const [regions, setRegions] = useState<LocationItem[]>([]);
|
||||
const [cities, setCities] = useState<LocationItem[]>([]);
|
||||
@@ -68,12 +70,12 @@ export function LocationProxyDialog({
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch countries:", err);
|
||||
toast.error("Failed to load countries");
|
||||
toast.error(t("locationProxy.loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCountries(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
}, [isOpen, t]);
|
||||
|
||||
// Fetch regions when country changes
|
||||
useEffect(() => {
|
||||
@@ -188,13 +190,13 @@ export function LocationProxyDialog({
|
||||
city: selectedCity || null,
|
||||
isp: selectedIsp || null,
|
||||
});
|
||||
toast.success("Location proxy created");
|
||||
toast.success(t("locationProxy.createSuccess"));
|
||||
await emit("stored-proxies-changed");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create location proxy:", error);
|
||||
toast.error(
|
||||
typeof error === "string" ? error : "Failed to create location proxy",
|
||||
typeof error === "string" ? error : t("locationProxy.createFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -206,6 +208,7 @@ export function LocationProxyDialog({
|
||||
selectedIsp,
|
||||
proxyName,
|
||||
handleClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
@@ -224,9 +227,9 @@ export function LocationProxyDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Location Proxy</DialogTitle>
|
||||
<DialogTitle>{t("locationProxy.titleCreate")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a geo-targeted proxy with a 24-hour sticky session
|
||||
{t("locationProxy.descriptionCreate")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -234,7 +237,7 @@ export function LocationProxyDialog({
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Country (required)
|
||||
{t("locationProxy.countryLabel")}
|
||||
{isLoadingCountries && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -242,9 +245,11 @@ export function LocationProxyDialog({
|
||||
value={selectedCountry}
|
||||
onValueChange={setSelectedCountry}
|
||||
placeholder={
|
||||
isLoadingCountries ? "Loading countries..." : "Select country"
|
||||
isLoadingCountries
|
||||
? t("locationProxy.loadingCountries")
|
||||
: t("locationProxy.selectCountryPh")
|
||||
}
|
||||
searchPlaceholder="Search countries..."
|
||||
searchPlaceholder={t("locationProxy.searchCountries")}
|
||||
disabled={isLoadingCountries}
|
||||
/>
|
||||
</div>
|
||||
@@ -252,7 +257,7 @@ export function LocationProxyDialog({
|
||||
{/* Region - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Region (optional)
|
||||
{t("locationProxy.regionLabel")}
|
||||
{isLoadingRegions && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -261,14 +266,14 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedRegion}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingRegions
|
||||
? "Loading regions..."
|
||||
? t("locationProxy.loadingRegions")
|
||||
: regionOptions.length === 0
|
||||
? "No regions available"
|
||||
: "Select region"
|
||||
? t("locationProxy.noRegions")
|
||||
: t("locationProxy.selectRegion")
|
||||
}
|
||||
searchPlaceholder="Search regions..."
|
||||
searchPlaceholder={t("locationProxy.searchRegions")}
|
||||
disabled={!selectedCountry || isLoadingRegions}
|
||||
/>
|
||||
</div>
|
||||
@@ -276,7 +281,7 @@ export function LocationProxyDialog({
|
||||
{/* City - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
City (optional)
|
||||
{t("locationProxy.cityLabel")}
|
||||
{isLoadingCities && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -285,14 +290,14 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingCities
|
||||
? "Loading cities..."
|
||||
? t("locationProxy.loadingCities")
|
||||
: cityOptions.length === 0
|
||||
? "No cities available"
|
||||
: "Select city"
|
||||
? t("locationProxy.noCities")
|
||||
: t("locationProxy.selectCity")
|
||||
}
|
||||
searchPlaceholder="Search cities..."
|
||||
searchPlaceholder={t("locationProxy.searchCities")}
|
||||
disabled={!selectedCountry || isLoadingCities}
|
||||
/>
|
||||
</div>
|
||||
@@ -300,7 +305,7 @@ export function LocationProxyDialog({
|
||||
{/* ISP - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
ISP (optional)
|
||||
{t("locationProxy.ispLabel")}
|
||||
{isLoadingIsps && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -309,40 +314,42 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedIsp}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingIsps
|
||||
? "Loading ISPs..."
|
||||
? t("locationProxy.loadingIsps")
|
||||
: ispOptions.length === 0
|
||||
? "No ISPs available"
|
||||
: "Select ISP"
|
||||
? t("locationProxy.noIsps")
|
||||
: t("locationProxy.selectIsp")
|
||||
}
|
||||
searchPlaceholder="Search ISPs..."
|
||||
searchPlaceholder={t("locationProxy.searchIsps")}
|
||||
disabled={!selectedCountry || isLoadingIsps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Label>{t("locationProxy.nameLabel")}</Label>
|
||||
<Input
|
||||
value={proxyName}
|
||||
onChange={(e) => {
|
||||
setProxyName(e.target.value);
|
||||
}}
|
||||
placeholder="Proxy name"
|
||||
placeholder={t("locationProxy.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedCountry || !proxyName.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create"}
|
||||
{isCreating
|
||||
? t("locationProxy.creatingButton")
|
||||
: t("locationProxy.createButton")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ export function PermissionDialog({
|
||||
permissionType,
|
||||
onPermissionGranted,
|
||||
}: PermissionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const {
|
||||
@@ -74,18 +76,18 @@ export function PermissionDialog({
|
||||
const getPermissionTitle = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone Access Required";
|
||||
return t("permissionDialog.titleMicrophone");
|
||||
case "camera":
|
||||
return "Camera Access Required";
|
||||
return t("permissionDialog.titleCamera");
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionDescription = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually.";
|
||||
return t("permissionDialog.descMicrophone");
|
||||
case "camera":
|
||||
return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually.";
|
||||
return t("permissionDialog.descCamera");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,14 +96,13 @@ export function PermissionDialog({
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionTitle(permissionType).replace(
|
||||
" Required",
|
||||
"",
|
||||
)} permission requested`,
|
||||
permissionType === "microphone"
|
||||
? t("permissionDialog.requestSuccessMicrophone")
|
||||
: t("permissionDialog.requestSuccessCamera"),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
showErrorToast("Failed to request permission");
|
||||
showErrorToast(t("permissionDialog.requestFailed"));
|
||||
} finally {
|
||||
setIsRequesting(false);
|
||||
}
|
||||
@@ -131,8 +132,9 @@ export function PermissionDialog({
|
||||
{isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-success/10 rounded-lg">
|
||||
<p className="text-sm text-success">
|
||||
✅ Permission granted! Browsers launched from Donut Browser can
|
||||
now access your {permissionType}.
|
||||
{permissionType === "microphone"
|
||||
? t("permissionDialog.grantedMicrophone")
|
||||
: t("permissionDialog.grantedCamera")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -140,8 +142,9 @@ export function PermissionDialog({
|
||||
{!isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-warning/10 rounded-lg">
|
||||
<p className="text-sm text-warning">
|
||||
⚠️ Permission not granted. Click the button below to request
|
||||
access to your {permissionType}.
|
||||
{permissionType === "microphone"
|
||||
? t("permissionDialog.notGrantedMicrophone")
|
||||
: t("permissionDialog.notGrantedCamera")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,7 +152,9 @@ export function PermissionDialog({
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{isCurrentPermissionGranted ? "Done" : "Cancel"}
|
||||
{isCurrentPermissionGranted
|
||||
? t("permissionDialog.doneButton")
|
||||
: t("permissionDialog.cancelButton")}
|
||||
</RippleButton>
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
@@ -162,7 +167,7 @@ export function PermissionDialog({
|
||||
}}
|
||||
className="min-w-24"
|
||||
>
|
||||
Grant Access
|
||||
{t("permissionDialog.grantAccessButton")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -236,6 +236,7 @@ function getProfileSyncStatusDot(
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): SyncStatusDot | null {
|
||||
const encrypted = profile.sync_mode === "Encrypted";
|
||||
@@ -249,14 +250,14 @@ function getProfileSyncStatusDot(
|
||||
case "syncing":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Syncing...",
|
||||
tooltip: t("profileTable.syncTooltipSyncing"),
|
||||
animate: true,
|
||||
encrypted,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Close the profile to sync",
|
||||
tooltip: t("profileTable.syncTooltipCloseToSync"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
@@ -264,15 +265,19 @@ function getProfileSyncStatusDot(
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: profile.last_sync
|
||||
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("profileTable.syncTooltipSyncedAt", {
|
||||
time: new Date(profile.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("profileTable.syncTooltipSynced"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("profileTable.syncTooltipErrorWith", { error: errorMessage })
|
||||
: t("profileTable.syncTooltipError"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
@@ -280,7 +285,9 @@ function getProfileSyncStatusDot(
|
||||
if (profile.last_sync) {
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
|
||||
tooltip: t("profileTable.syncTooltipDisabledWithLast", {
|
||||
time: formatRelativeTime(profile.last_sync),
|
||||
}),
|
||||
animate: false,
|
||||
encrypted: false,
|
||||
};
|
||||
@@ -313,6 +320,7 @@ const TagsCell = React.memo<{
|
||||
setOpenTagsEditorFor,
|
||||
setTagsOverrides,
|
||||
}) => {
|
||||
const { t: translate } = useTranslation();
|
||||
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id)
|
||||
? tagsOverrides[profile.id]
|
||||
: (profile.tags ?? []);
|
||||
@@ -475,7 +483,9 @@ const TagsCell = React.memo<{
|
||||
</Badge>
|
||||
))}
|
||||
{effectiveTags.length === 0 && (
|
||||
<span className="text-muted-foreground">No tags</span>
|
||||
<span className="text-muted-foreground">
|
||||
{translate("profileTable.noTags")}
|
||||
</span>
|
||||
)}
|
||||
{hiddenCount > 0 && (
|
||||
<Badge variant="outline" className="px-2 py-0 text-xs">
|
||||
@@ -526,7 +536,11 @@ const TagsCell = React.memo<{
|
||||
onChange={(opts) => void handleChange(opts)}
|
||||
creatable
|
||||
selectFirstItem={false}
|
||||
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
||||
placeholder={
|
||||
effectiveTags.length === 0
|
||||
? translate("profileTable.addTagsPlaceholder")
|
||||
: ""
|
||||
}
|
||||
className={cn(
|
||||
"bg-transparent border-0! focus-within:ring-0!",
|
||||
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
|
||||
@@ -630,6 +644,7 @@ const NoteCell = React.memo<{
|
||||
setOpenNoteEditorFor,
|
||||
setNoteOverrides,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const effectiveNote: string | null = Object.hasOwn(
|
||||
noteOverrides,
|
||||
profile.id,
|
||||
@@ -745,14 +760,14 @@ const NoteCell = React.memo<{
|
||||
!effectiveNote && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{effectiveNote ? trimmedNote : "No Note"}
|
||||
{effectiveNote ? trimmedNote : t("profiles.note.empty")}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent className="max-w-[320px]">
|
||||
<p className="whitespace-pre-wrap wrap-break-word">
|
||||
{effectiveNote ?? "No Note"}
|
||||
{effectiveNote ?? t("profiles.note.empty")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
@@ -789,7 +804,7 @@ const NoteCell = React.memo<{
|
||||
void onNoteChange(noteValue);
|
||||
setOpenNoteEditorFor(null);
|
||||
}}
|
||||
placeholder="Add a note..."
|
||||
placeholder={t("profiles.note.placeholder")}
|
||||
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
|
||||
style={{
|
||||
overflow: "auto",
|
||||
@@ -1334,12 +1349,14 @@ export function ProfilesDataTable({
|
||||
setRenameError(null);
|
||||
} catch (error) {
|
||||
setRenameError(
|
||||
error instanceof Error ? error.message : "Failed to rename profile",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("errors.renameProfileFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsRenamingSaving(false);
|
||||
}
|
||||
}, [profileToRename, newProfileName, onRenameProfile]);
|
||||
}, [profileToRename, newProfileName, onRenameProfile, t]);
|
||||
|
||||
// Cancel inline rename on outside click
|
||||
React.useEffect(() => {
|
||||
@@ -1661,7 +1678,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleToggleAll(!!value);
|
||||
}}
|
||||
aria-label="Select all"
|
||||
aria-label={t("common.aria.selectAll")}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</span>
|
||||
@@ -1707,7 +1724,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
|
||||
@@ -1745,7 +1762,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
aria-label={t("common.aria.selectRow")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
@@ -1793,7 +1810,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
aria-label={t("common.aria.selectRow")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
@@ -1814,7 +1831,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
{IconComponent && (
|
||||
@@ -1833,6 +1850,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 100,
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -1951,7 +1969,7 @@ export function ProfilesDataTable({
|
||||
size="sm"
|
||||
disabled={!canLaunch || isLaunching || isStopping}
|
||||
className={cn(
|
||||
"min-w-[70px] h-7",
|
||||
"min-w-[80px] h-7 px-3",
|
||||
!canLaunch && "opacity-50 cursor-not-allowed",
|
||||
canLaunch && "cursor-pointer",
|
||||
isFollower && "border-accent",
|
||||
@@ -1967,9 +1985,9 @@ export function ProfilesDataTable({
|
||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : isRunning ? (
|
||||
"Stop"
|
||||
meta.t("profiles.actions.stop")
|
||||
) : (
|
||||
"Launch"
|
||||
meta.t("profiles.actions.launch")
|
||||
)}
|
||||
</RippleButton>
|
||||
</span>
|
||||
@@ -1986,7 +2004,9 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
size: 130,
|
||||
header: ({ column, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1995,7 +2015,7 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
Name
|
||||
{meta.t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 w-4 h-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
@@ -2124,7 +2144,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
header: "Tags",
|
||||
size: 110,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profileTable.tagsHeader");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2153,7 +2177,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "note",
|
||||
header: "Note",
|
||||
size: 110,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profileTable.noteHeader");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2180,7 +2208,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "proxy",
|
||||
header: "Proxy / VPN",
|
||||
size: 130,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profiles.table.proxy");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2218,12 +2250,8 @@ export function ProfilesDataTable({
|
||||
? effectiveVpn.name
|
||||
: effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
const vpnBadge = effectiveVpn
|
||||
? effectiveVpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"
|
||||
: null;
|
||||
: meta.t("profiles.table.notSelected");
|
||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||
const tooltipText = hasAssignment ? displayName : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||
@@ -2303,8 +2331,8 @@ export function ProfilesDataTable({
|
||||
<CommandInput
|
||||
placeholder={
|
||||
meta.canCreateLocationProxy
|
||||
? "Search proxies, VPNs, or countries..."
|
||||
: "Search proxies or VPNs..."
|
||||
? t("createProfile.proxy.searchWithCountries")
|
||||
: t("createProfile.proxy.search")
|
||||
}
|
||||
onFocus={() => {
|
||||
if (meta.canCreateLocationProxy)
|
||||
@@ -2312,7 +2340,9 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -2328,7 +2358,7 @@ export function ProfilesDataTable({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{meta.storedProxies
|
||||
.filter(
|
||||
@@ -2361,7 +2391,7 @@ export function ProfilesDataTable({
|
||||
))}
|
||||
</CommandGroup>
|
||||
{meta.vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
<CommandGroup heading={t("profileTable.vpnsHeading")}>
|
||||
{meta.vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
@@ -2385,7 +2415,7 @@ export function ProfilesDataTable({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -2394,7 +2424,9 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{meta.canCreateLocationProxy &&
|
||||
meta.countries.length > 0 && (
|
||||
<CommandGroup heading="Create by country">
|
||||
<CommandGroup
|
||||
heading={t("profileTable.createByCountryHeading")}
|
||||
>
|
||||
{meta.countries
|
||||
.filter(
|
||||
(c) =>
|
||||
@@ -2470,6 +2502,7 @@ export function ProfilesDataTable({
|
||||
const dot = getProfileSyncStatusDot(
|
||||
profile,
|
||||
liveStatus,
|
||||
meta.t,
|
||||
syncEntry?.error,
|
||||
);
|
||||
if (!dot) return null;
|
||||
@@ -2511,7 +2544,9 @@ export function ProfilesDataTable({
|
||||
setProfileForInfoDialog(profile);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Profile info</span>
|
||||
<span className="sr-only">
|
||||
{t("profiles.aria.profileInfo")}
|
||||
</span>
|
||||
<LuInfo className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2555,7 +2590,7 @@ export function ProfilesDataTable({
|
||||
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
||||
)}
|
||||
>
|
||||
<Table className="overflow-visible">
|
||||
<Table className="overflow-visible table-fixed">
|
||||
<TableHeader className="overflow-visible">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="overflow-visible">
|
||||
@@ -2630,7 +2665,7 @@ export function ProfilesDataTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No profiles found.
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -2643,9 +2678,11 @@ export function ProfilesDataTable({
|
||||
setProfileToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Profile"
|
||||
description={`This action cannot be undone. This will permanently delete the profile "${profileToDelete?.name}" and all its associated data.`}
|
||||
confirmButtonText="Delete Profile"
|
||||
title={t("profiles.delete.title")}
|
||||
description={t("profiles.delete.description", {
|
||||
profileName: profileToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("profiles.delete.confirmButton")}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
{profileForInfoDialog &&
|
||||
@@ -2704,7 +2741,7 @@ export function ProfilesDataTable({
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign to Group"
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
onClick={onBulkGroupAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2713,7 +2750,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkProxyAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign Proxy"
|
||||
tooltip={t("profiles.actionBar.assignProxy")}
|
||||
onClick={onBulkProxyAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2722,7 +2759,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkExtensionGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign Extension Group"
|
||||
tooltip={t("profiles.actionBar.assignExtensionGroup")}
|
||||
onClick={onBulkExtensionGroupAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2731,7 +2768,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Copy Cookies"
|
||||
tooltip={t("profiles.actionBar.copyCookies")}
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2740,7 +2777,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkDelete && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Delete"
|
||||
tooltip={t("common.buttons.delete")}
|
||||
onClick={onBulkDelete}
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -47,8 +48,17 @@ export function ProfileSelectorDialog({
|
||||
runningProfiles: externalRunningProfiles,
|
||||
isUpdating,
|
||||
}: ProfileSelectorDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Use the centralized profile events hook
|
||||
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents();
|
||||
const { profiles: rawProfiles, runningProfiles: hookRunningProfiles } =
|
||||
useProfileEvents();
|
||||
const profiles = useMemo(
|
||||
() =>
|
||||
[...rawProfiles].sort((a, b) =>
|
||||
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
),
|
||||
[rawProfiles],
|
||||
);
|
||||
|
||||
// Use external runningProfiles if provided, otherwise use hook's runningProfiles
|
||||
const runningProfiles = externalRunningProfiles ?? hookRunningProfiles;
|
||||
@@ -146,11 +156,7 @@ export function ProfileSelectorDialog({
|
||||
if (runningAvailableProfile) {
|
||||
setSelectedProfile(runningAvailableProfile.name);
|
||||
} else {
|
||||
// Sort profiles by name and select first
|
||||
const sortedProfiles = [...profiles].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
setSelectedProfile(sortedProfiles[0].name);
|
||||
setSelectedProfile(profiles[0].name);
|
||||
}
|
||||
}
|
||||
}, [isOpen, profiles, selectedProfile, runningProfiles]);
|
||||
@@ -159,17 +165,19 @@ export function ProfileSelectorDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose Profile</DialogTitle>
|
||||
<DialogTitle>{t("profileSelector.chooseProfileTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{url && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">Opening URL:</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
{t("profileSelector.openingUrl")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
successMessage="URL copied to clipboard!"
|
||||
successMessage={t("profileSelector.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
@@ -179,15 +187,16 @@ export function ProfileSelectorDialog({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-select">Select Profile:</Label>
|
||||
<Label htmlFor="profile-select">
|
||||
{t("profileSelector.selectProfileLabel")}
|
||||
</Label>
|
||||
{profiles.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No profiles available. Please create a profile first.
|
||||
{t("profileSelector.noneAvailableShort")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Close this dialog and create a profile from the main window to
|
||||
get started.
|
||||
{t("profileSelector.noneAvailableLong")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -196,7 +205,9 @@ export function ProfileSelectorDialog({
|
||||
onValueChange={setSelectedProfile}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a profile" />
|
||||
<SelectValue
|
||||
placeholder={t("profileSelector.chooseAProfile")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
@@ -241,12 +252,12 @@ export function ProfileSelectorDialog({
|
||||
</Badge>
|
||||
{hasProxy(profile) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Proxy
|
||||
{t("profileSelector.badgeProxy")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Running
|
||||
{t("profileSelector.badgeRunning")}
|
||||
</Badge>
|
||||
)}
|
||||
{!canUseForLinks && (
|
||||
@@ -254,7 +265,7 @@ export function ProfileSelectorDialog({
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Unavailable
|
||||
{t("profileSelector.badgeUnavailable")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -275,7 +286,7 @@ export function ProfileSelectorDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -289,7 +300,7 @@ export function ProfileSelectorDialog({
|
||||
!canOpenWithSelectedProfile()
|
||||
}
|
||||
>
|
||||
Open
|
||||
{t("profileSelector.openButton")}
|
||||
</LoadingButton>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -166,7 +166,7 @@ export function ProfileSyncDialog({
|
||||
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
|
||||
|
||||
const formatLastSync = (timestamp?: number) => {
|
||||
if (!timestamp) return t("common.labels.never", "Never");
|
||||
if (!timestamp) return t("common.labels.never");
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
@@ -177,7 +177,7 @@ export function ProfileSyncDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle>
|
||||
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sync.mode.description", {
|
||||
name: profile.name,
|
||||
@@ -194,9 +194,7 @@ export function ProfileSyncDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="p-3 text-sm rounded-md bg-muted">
|
||||
<p className="mb-2">
|
||||
{t("sync.mode.notConfigured", "Sync service not configured.")}
|
||||
</p>
|
||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -205,7 +203,7 @@ export function ProfileSyncDialog({
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("sync.mode.configureService", "Configure Sync Service")}
|
||||
{t("sync.mode.configureService")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -222,13 +220,10 @@ export function ProfileSyncDialog({
|
||||
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
||||
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.disabled", "Disabled")}
|
||||
{t("sync.mode.disabled")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.disabledDescription",
|
||||
"No sync for this profile",
|
||||
)}
|
||||
{t("sync.mode.disabledDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -237,13 +232,10 @@ export function ProfileSyncDialog({
|
||||
<RadioGroupItem value="Regular" id="sync-regular" />
|
||||
<Label htmlFor="sync-regular" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.regular", "Regular Sync")}
|
||||
{t("sync.mode.regular")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.regularDescription",
|
||||
"Fast sync, unencrypted",
|
||||
)}
|
||||
{t("sync.mode.regularDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -263,18 +255,12 @@ export function ProfileSyncDialog({
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
|
||||
{t("sync.mode.encrypted")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canUseEncryption
|
||||
? t(
|
||||
"sync.mode.encryptedDescription",
|
||||
"Encrypted before upload. Server never sees plaintext data.",
|
||||
)
|
||||
: t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
? t("sync.mode.encryptedDescription")
|
||||
: t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -284,15 +270,12 @@ export function ProfileSyncDialog({
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t(
|
||||
"sync.mode.noPasswordWarning",
|
||||
"E2E password not set. Please set a password in Settings.",
|
||||
)}
|
||||
{t("sync.mode.noPasswordWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
|
||||
<Label>{t("sync.mode.lastSynced")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
@@ -319,7 +302,7 @@ export function ProfileSyncDialog({
|
||||
</Button>
|
||||
{hasConfig && isSyncEnabled(profile) && (
|
||||
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
|
||||
{t("sync.mode.syncNow", "Sync Now")}
|
||||
{t("sync.mode.syncNow")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -53,6 +54,7 @@ export function ProxyAssignmentDialog({
|
||||
storedProxies = [],
|
||||
vpnConfigs = [],
|
||||
}: ProxyAssignmentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
|
||||
"none",
|
||||
@@ -84,7 +86,7 @@ export function ProxyAssignmentDialog({
|
||||
});
|
||||
|
||||
if (validProfiles.length === 0) {
|
||||
setError("No valid profiles selected.");
|
||||
setError(t("proxyAssignment.noValidProfiles"));
|
||||
setIsAssigning(false);
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export function ProxyAssignmentDialog({
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign proxy/VPN to profiles";
|
||||
: t("proxyAssignment.failedFallback");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -124,6 +126,7 @@ export function ProxyAssignmentDialog({
|
||||
profiles,
|
||||
onAssignmentComplete,
|
||||
onClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,16 +141,21 @@ export function ProxyAssignmentDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Proxy / VPN</DialogTitle>
|
||||
<DialogTitle>{t("proxyAssignment.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a proxy or VPN to {selectedProfiles.length} selected
|
||||
profile(s).
|
||||
{selectedProfiles.length === 1
|
||||
? t("proxyAssignment.description_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("proxyAssignment.description_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
@@ -166,7 +174,9 @@ export function ProxyAssignmentDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
|
||||
<Label htmlFor="proxy-vpn-select">
|
||||
{t("proxyAssignment.assignProxyVpnLabel")}
|
||||
</Label>
|
||||
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -176,26 +186,29 @@ export function ProxyAssignmentDialog({
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (selectionType === "none") return "None";
|
||||
if (selectionType === "none")
|
||||
return t("proxyAssignment.noneOption");
|
||||
if (selectionType === "vpn") {
|
||||
const vpn = vpnConfigs.find((v) => v.id === selectedId);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "None";
|
||||
? `WG — ${vpn.name}`
|
||||
: t("proxyAssignment.noneOption");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedId,
|
||||
);
|
||||
return proxy ? proxy.name : "None";
|
||||
return proxy ? proxy.name : t("proxyAssignment.noneOption");
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t("proxyAssignment.searchPlaceholder")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandEmpty>{t("proxyAssignment.notFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -212,7 +225,7 @@ export function ProxyAssignmentDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("proxyAssignment.noneOption")}
|
||||
</CommandItem>
|
||||
{storedProxies
|
||||
.filter(
|
||||
@@ -242,7 +255,9 @@ export function ProxyAssignmentDialog({
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
<CommandGroup
|
||||
heading={t("proxyAssignment.vpnGroupHeading")}
|
||||
>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
@@ -264,7 +279,7 @@ export function ProxyAssignmentDialog({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
WG
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
@@ -290,13 +305,13 @@ export function ProxyAssignmentDialog({
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
>
|
||||
Assign
|
||||
{t("proxyAssignment.assignButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { FlagIcon } from "@/components/flag-icon";
|
||||
@@ -35,6 +36,7 @@ export function ProxyCheckButton({
|
||||
disabled = false,
|
||||
setCheckingProfileId,
|
||||
}: ProxyCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [localResult, setLocalResult] = React.useState<
|
||||
ProxyCheckResult | undefined
|
||||
>(cachedResult);
|
||||
@@ -60,11 +62,13 @@ export function ProxyCheckButton({
|
||||
if (result.city) locationParts.push(result.city);
|
||||
if (result.country) locationParts.push(result.country);
|
||||
const location =
|
||||
locationParts.length > 0 ? locationParts.join(", ") : "Unknown";
|
||||
locationParts.length > 0
|
||||
? locationParts.join(", ")
|
||||
: t("proxyCheck.unknownLocation");
|
||||
|
||||
toast.success(
|
||||
<div className="flex flex-col">
|
||||
Your proxy location is:
|
||||
{t("proxyCheck.locationToast")}
|
||||
<div className="flex items-center whitespace-nowrap">
|
||||
{location}
|
||||
{result.country_code && (
|
||||
@@ -79,7 +83,7 @@ export function ProxyCheckButton({
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Proxy check failed: ${errorMessage}`);
|
||||
toast.error(t("proxyCheck.failed", { error: errorMessage }));
|
||||
|
||||
// Save failed check result
|
||||
const failedResult: ProxyCheckResult = {
|
||||
@@ -102,6 +106,7 @@ export function ProxyCheckButton({
|
||||
onCheckComplete,
|
||||
onCheckFailed,
|
||||
setCheckingProfileId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const isCurrentlyChecking = checkingProfileId === profileId;
|
||||
@@ -133,7 +138,7 @@ export function ProxyCheckButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking proxy...</p>
|
||||
<p>{t("proxyCheck.tooltipChecking")}</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1">
|
||||
@@ -141,24 +146,28 @@ export function ProxyCheckButton({
|
||||
<FlagIcon countryCode={result.country_code} />
|
||||
)}
|
||||
{[result.city, result.country].filter(Boolean).join(", ") ||
|
||||
"Unknown"}
|
||||
t("proxyCheck.unknownLocation")}
|
||||
</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
IP: {result.ip}
|
||||
{t("proxyCheck.tooltipIp", { ip: result.ip })}
|
||||
</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("proxyCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Proxy check failed</p>
|
||||
<p>{t("proxyCheck.tooltipFailedTitle")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Failed {formatRelativeTime(result.timestamp)}
|
||||
{t("proxyCheck.tooltipFailed", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check proxy validity</p>
|
||||
<p>{t("proxyCheck.tooltipDefault")}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCopy, LuDownload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ interface ProxyExportDialogProps {
|
||||
}
|
||||
|
||||
export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [format, setFormat] = useState<"json" | "txt">("json");
|
||||
const [exportContent, setExportContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -35,12 +37,12 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
setExportContent(content);
|
||||
} catch (error) {
|
||||
console.error("Failed to export proxies:", error);
|
||||
toast.error("Failed to export proxies");
|
||||
toast.error(t("proxies.exportDialog.failed"));
|
||||
setExportContent("");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [format]);
|
||||
}, [format, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -52,15 +54,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportContent);
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
toast.success(t("toasts.success.copied"));
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
toast.error("Failed to copy to clipboard");
|
||||
toast.error(t("toasts.error.copyFailed"));
|
||||
}
|
||||
}, [exportContent]);
|
||||
}, [exportContent, t]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const filename = format === "json" ? "proxies.json" : "proxies.txt";
|
||||
@@ -76,8 +78,8 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Downloaded ${filename}`);
|
||||
}, [format, exportContent]);
|
||||
toast.success(t("proxies.exportDialog.downloaded", { filename }));
|
||||
}, [format, exportContent, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setFormat("json");
|
||||
@@ -90,15 +92,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Proxies</DialogTitle>
|
||||
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export your proxy configurations to a file
|
||||
{t("proxies.exportDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Export Format</Label>
|
||||
<Label>{t("proxies.exportDialog.format")}</Label>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
@@ -109,24 +111,24 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="json" id="format-json" />
|
||||
<Label htmlFor="format-json" className="cursor-pointer">
|
||||
JSON
|
||||
{t("proxies.exportDialog.json")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="txt" id="format-txt" />
|
||||
<Label htmlFor="format-txt" className="cursor-pointer">
|
||||
TXT (URL format)
|
||||
{t("proxies.exportDialog.txt")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<Label>{t("proxies.exportDialog.preview")}</Label>
|
||||
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : exportContent ? (
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
@@ -134,7 +136,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
No proxies to export
|
||||
{t("proxies.exportDialog.noProxies")}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@@ -143,7 +145,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
@@ -156,7 +158,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
) : (
|
||||
<LuCopy className="w-4 h-4" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied
|
||||
? t("proxies.exportDialog.copied")
|
||||
: t("common.buttons.copy")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleDownload}
|
||||
@@ -164,7 +168,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -83,14 +83,12 @@ export function ProxyFormDialog({
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
toast.error(t("proxies.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.host.trim() || !form.port) {
|
||||
toast.error(
|
||||
t("proxies.form.hostPortRequired", "Host and port are required"),
|
||||
);
|
||||
toast.error(t("proxies.form.hostPortRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,12 +96,7 @@ export function ProxyFormDialog({
|
||||
form.proxy_type === "ss" &&
|
||||
(!form.username.trim() || !form.password.trim())
|
||||
) {
|
||||
toast.error(
|
||||
t(
|
||||
"proxies.form.ssCipherRequired",
|
||||
"Cipher and password are required for Shadowsocks",
|
||||
),
|
||||
);
|
||||
toast.error(t("proxies.form.ssCipherRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +129,7 @@ export function ProxyFormDialog({
|
||||
console.error("Failed to save proxy:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to save proxy: ${errorMessage}`);
|
||||
toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -189,7 +182,7 @@ export function ProxyFormDialog({
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
<SelectValue placeholder={t("proxies.form.selectType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5", "ss"].map((type) => (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -39,6 +40,7 @@ interface AmbiguousProxy {
|
||||
}
|
||||
|
||||
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
|
||||
@@ -52,7 +54,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
null,
|
||||
);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [namePrefix, setNamePrefix] = useState("Imported");
|
||||
const [namePrefix, setNamePrefix] = useState(
|
||||
t("proxies.importDialog.namePrefixDefault"),
|
||||
);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
@@ -65,8 +69,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
setInvalidProxies([]);
|
||||
setImportResult(null);
|
||||
setIsImporting(false);
|
||||
setNamePrefix("Imported");
|
||||
}, []);
|
||||
setNamePrefix(t("proxies.importDialog.namePrefixDefault"));
|
||||
}, [t]);
|
||||
|
||||
const processContent = useCallback(
|
||||
async (content: string, isJson: boolean, _filename = "") => {
|
||||
@@ -116,19 +120,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
} else if (parsed.length > 0) {
|
||||
setStep("preview");
|
||||
} else {
|
||||
toast.error("No valid proxies found in the file");
|
||||
toast.error(t("proxies.importDialog.noValidProxies"));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process content:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to process file",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.importDialog.fileProcessError"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
@@ -140,11 +146,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
void processContent(content, isJson, file.name);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("proxies.importDialog.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[processContent],
|
||||
[processContent, t],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -160,10 +166,10 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .json or .txt file");
|
||||
toast.error(t("proxies.importDialog.wrongFileType"));
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
[handleFileRead, t],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -206,7 +212,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
"import_proxies_from_parsed",
|
||||
{
|
||||
parsedProxies,
|
||||
namePrefix: namePrefix.trim() || "Imported",
|
||||
namePrefix:
|
||||
namePrefix.trim() || t("proxies.importDialog.namePrefixDefault"),
|
||||
},
|
||||
);
|
||||
setImportResult(result);
|
||||
@@ -215,12 +222,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
} catch (error) {
|
||||
console.error("Failed to import proxies:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import proxies",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.importDialog.failed"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [parsedProxies, namePrefix]);
|
||||
}, [parsedProxies, namePrefix, t]);
|
||||
|
||||
const handleAmbiguousFormatSelect = useCallback(
|
||||
(index: number, format: string) => {
|
||||
@@ -273,13 +282,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Proxies</DialogTitle>
|
||||
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" && "Import proxies from a JSON or TXT file"}
|
||||
{step === "preview" && "Review the proxies to import"}
|
||||
{step === "ambiguous" &&
|
||||
"Some proxies have ambiguous formats. Please select the correct format."}
|
||||
{step === "result" && "Import completed"}
|
||||
{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>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -309,9 +317,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a proxy config file
|
||||
{t("proxies.importDialog.dropzonePrompt")}
|
||||
<br />
|
||||
<span className="text-xs">(.json, .txt)</span>
|
||||
<span className="text-xs">
|
||||
{t("proxies.importDialog.dropzoneFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
@@ -326,7 +336,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Paste from clipboard with {modKey}+V
|
||||
{t("proxies.importDialog.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -334,27 +344,35 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "preview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name-prefix">Name Prefix</Label>
|
||||
<Label htmlFor="name-prefix">
|
||||
{t("proxies.importDialog.namePrefix")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name-prefix"
|
||||
placeholder="Imported"
|
||||
placeholder={t("proxies.importDialog.namePrefixDefault")}
|
||||
value={namePrefix}
|
||||
onChange={(e) => {
|
||||
setNamePrefix(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proxies will be named "{namePrefix || "Imported"} Proxy
|
||||
1", "{namePrefix || "Imported"} Proxy 2", etc.
|
||||
{t("proxies.importDialog.namePrefixHint", {
|
||||
prefix:
|
||||
namePrefix || t("proxies.importDialog.namePrefixDefault"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Proxies to import ({parsedProxies.length})
|
||||
{t("proxies.importDialog.proxiesToImport", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
{invalidProxies.length > 0 && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({invalidProxies.length} invalid)
|
||||
{t("proxies.importDialog.invalidCount", {
|
||||
count: invalidProxies.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -387,8 +405,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "ambiguous" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The following proxies have an ambiguous format. Please select the
|
||||
correct interpretation for each.
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-3 space-y-4">
|
||||
@@ -430,14 +447,18 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Imported:</span>
|
||||
<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">Skipped (duplicates):</span>
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.skippedDuplicates")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{importResult.skipped_count}
|
||||
</span>
|
||||
@@ -445,7 +466,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
)}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Errors:</span>
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.errors")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
{importResult.errors.length}
|
||||
</span>
|
||||
@@ -455,7 +478,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Errors</Label>
|
||||
<Label>{t("proxies.importDialog.errors")}</Label>
|
||||
<ScrollArea className="h-[100px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
{importResult.errors.map((error, i) => (
|
||||
@@ -476,21 +499,23 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={parsedProxies.length === 0}
|
||||
>
|
||||
Import {parsedProxies.length} Proxies
|
||||
{t("proxies.importDialog.importButton", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
@@ -498,19 +523,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "ambiguous" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleResolveAmbiguous}
|
||||
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
|
||||
>
|
||||
Continue
|
||||
{t("proxies.importDialog.continueButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("proxies.importDialog.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,37 +52,46 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: t("syncTooltips.syncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: item.last_sync
|
||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("syncTooltips.syncedAt", {
|
||||
time: new Date(item.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("syncTooltips.synced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("syncTooltips.waiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("syncTooltips.errorWith", { error: errorMessage })
|
||||
: t("syncTooltips.error"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("syncTooltips.notSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -96,6 +106,7 @@ export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Proxy state
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
@@ -260,16 +271,16 @@ export function ProxyManagementDialog({
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
|
||||
toast.success("Proxy deleted successfully");
|
||||
toast.success(t("proxies.management.deleteSuccess"));
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete proxy:", error);
|
||||
toast.error("Failed to delete proxy");
|
||||
toast.error(t("proxies.management.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setProxyToDelete(null);
|
||||
}
|
||||
}, [proxyToDelete]);
|
||||
}, [proxyToDelete, t]);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setEditingProxy(null);
|
||||
@@ -286,24 +297,33 @@ export function ProxyManagementDialog({
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleSync = useCallback(async (proxy: StoredProxy) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
||||
try {
|
||||
await invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: !proxy.sync_enabled,
|
||||
});
|
||||
showSuccessToast(proxy.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
const handleToggleSync = useCallback(
|
||||
async (proxy: StoredProxy) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
||||
try {
|
||||
await invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: !proxy.sync_enabled,
|
||||
});
|
||||
showSuccessToast(
|
||||
proxy.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// VPN handlers
|
||||
const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
|
||||
@@ -315,16 +335,16 @@ export function ProxyManagementDialog({
|
||||
setIsDeletingVpn(true);
|
||||
try {
|
||||
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
|
||||
toast.success("VPN deleted successfully");
|
||||
toast.success(t("vpns.management.deleteSuccess"));
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete VPN:", error);
|
||||
toast.error("Failed to delete VPN");
|
||||
toast.error(t("vpns.management.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeletingVpn(false);
|
||||
setVpnToDelete(null);
|
||||
}
|
||||
}, [vpnToDelete]);
|
||||
}, [vpnToDelete, t]);
|
||||
|
||||
const handleCreateVpn = useCallback(() => {
|
||||
setEditingVpn(null);
|
||||
@@ -341,33 +361,42 @@ export function ProxyManagementDialog({
|
||||
setEditingVpn(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
const handleToggleVpnSync = useCallback(
|
||||
async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(
|
||||
vpn.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxies & VPNs</DialogTitle>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage your proxy and VPN configurations for reuse across profiles
|
||||
{t("proxies.management.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -375,14 +404,14 @@ export function ProxyManagementDialog({
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
Proxies
|
||||
{t("proxies.management.tabProxies")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
VPNs
|
||||
{t("proxies.management.tabVpns")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="proxies">
|
||||
<TabsContent value="proxies" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
@@ -395,7 +424,7 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -407,7 +436,7 @@ export function ProxyManagementDialog({
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
{t("common.buttons.export")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -417,183 +446,202 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
{t("proxies.management.loading")}
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No proxies created yet. Create your first proxy using the
|
||||
button above.
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{proxy.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="border rounded-md max-h-[240px] overflow-auto">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
t,
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
void handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this
|
||||
proxy is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ProxyCheckButton
|
||||
proxy={proxy}
|
||||
profileId={proxy.id}
|
||||
checkingProfileId={checkingProxyId}
|
||||
cachedResult={
|
||||
proxyCheckResults[proxy.id]
|
||||
}
|
||||
setCheckingProfileId={
|
||||
setCheckingProxyId
|
||||
}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{proxy.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
void handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
{t(
|
||||
"proxies.management.syncCannotDisable",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? t(
|
||||
"proxies.management.disableSync",
|
||||
)
|
||||
: t(
|
||||
"proxies.management.enableSync",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ProxyCheckButton
|
||||
proxy={proxy}
|
||||
profileId={proxy.id}
|
||||
checkingProfileId={checkingProxyId}
|
||||
cachedResult={proxyCheckResults[proxy.id]}
|
||||
setCheckingProfileId={setCheckingProxyId}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleEditProxy(proxy);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("proxies.management.editProxy")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleEditProxy(proxy);
|
||||
handleDeleteProxy(proxy);
|
||||
}}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleDeleteProxy(proxy);
|
||||
}}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1
|
||||
? "s"
|
||||
: ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
{(proxyUsage[proxy.id] ?? 0) === 1
|
||||
? t(
|
||||
"proxies.management.cannotDelete_one",
|
||||
{
|
||||
count: proxyUsage[proxy.id],
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"proxies.management.cannotDelete_other",
|
||||
{
|
||||
count: proxyUsage[proxy.id],
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{t(
|
||||
"proxies.management.deleteProxy",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vpns">
|
||||
<TabsContent value="vpns" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
@@ -606,7 +654,7 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
@@ -615,165 +663,180 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading VPNs...
|
||||
{t("vpns.management.loading")}
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No VPN configs created yet. Import or create one using the
|
||||
buttons above.
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-16">Type</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{vpn.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{vpnUsage[vpn.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="border rounded-md max-h-[240px] overflow-auto">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("common.labels.type")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
t,
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
void handleToggleVpnSync(vpn)
|
||||
}
|
||||
disabled={
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this
|
||||
VPN is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<VpnCheckButton
|
||||
vpnId={vpn.id}
|
||||
vpnName={vpn.name}
|
||||
checkingVpnId={checkingVpnId}
|
||||
setCheckingVpnId={setCheckingVpnId}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{vpn.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">WG</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{vpnUsage[vpn.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
void handleToggleVpnSync(vpn)
|
||||
}
|
||||
disabled={
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
{t(
|
||||
"vpns.management.syncCannotDisable",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? t(
|
||||
"proxies.management.disableSync",
|
||||
)
|
||||
: t(
|
||||
"proxies.management.enableSync",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<VpnCheckButton
|
||||
vpnId={vpn.id}
|
||||
vpnName={vpn.name}
|
||||
checkingVpnId={checkingVpnId}
|
||||
setCheckingVpnId={setCheckingVpnId}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleEditVpn(vpn);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("vpns.management.editVpn")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleEditVpn(vpn);
|
||||
handleDeleteVpn(vpn);
|
||||
}}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit VPN</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleDeleteVpn(vpn);
|
||||
}}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{vpnUsage[vpn.id]} profile
|
||||
{vpnUsage[vpn.id] > 1 ? "s" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete VPN</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
{(vpnUsage[vpn.id] ?? 0) === 1
|
||||
? t(
|
||||
"vpns.management.cannotDelete_one",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)
|
||||
: t(
|
||||
"vpns.management.cannotDelete_other",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{t("vpns.management.deleteVpn")}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -783,7 +846,7 @@ export function ProxyManagementDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -800,9 +863,11 @@ export function ProxyManagementDialog({
|
||||
setProxyToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Proxy"
|
||||
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
title={t("proxies.management.deleteTitle")}
|
||||
description={t("proxies.management.deleteDescription", {
|
||||
name: proxyToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
<ProxyImportDialog
|
||||
@@ -828,9 +893,11 @@ export function ProxyManagementDialog({
|
||||
setVpnToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDeleteVpn}
|
||||
title="Delete VPN"
|
||||
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
title={t("vpns.management.deleteTitle")}
|
||||
description={t("vpns.management.deleteDescription", {
|
||||
name: vpnToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
isLoading={isDeletingVpn}
|
||||
/>
|
||||
<VpnImportDialog
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -37,11 +38,14 @@ export function ReleaseTypeSelector({
|
||||
availableReleaseTypes,
|
||||
isDownloading,
|
||||
onDownload,
|
||||
placeholder = "Select release type...",
|
||||
placeholder,
|
||||
showDownloadButton = true,
|
||||
downloadedVersions = [],
|
||||
}: ReleaseTypeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const effectivePlaceholder =
|
||||
placeholder ?? t("releaseTypeSelector.placeholder");
|
||||
|
||||
const releaseOptions = [
|
||||
...(availableReleaseTypes.stable
|
||||
@@ -64,9 +68,9 @@ export function ReleaseTypeSelector({
|
||||
|
||||
const selectedDisplayText = selectedReleaseType
|
||||
? selectedReleaseType === "stable"
|
||||
? "Stable"
|
||||
: "Nightly"
|
||||
: placeholder;
|
||||
? t("releaseTypeSelector.stable")
|
||||
: t("releaseTypeSelector.nightly")
|
||||
: effectivePlaceholder;
|
||||
|
||||
const selectedVersion =
|
||||
selectedReleaseType === "stable"
|
||||
@@ -95,7 +99,9 @@ export function ReleaseTypeSelector({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandEmpty>No release types available.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{t("releaseTypeSelector.noReleaseTypes")}
|
||||
</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{releaseOptions.map((option) => {
|
||||
@@ -130,7 +136,7 @@ export function ReleaseTypeSelector({
|
||||
<span className="capitalize">{option.type}</span>
|
||||
{option.type === "nightly" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Nightly
|
||||
{t("releaseTypeSelector.nightly")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
@@ -138,7 +144,7 @@ export function ReleaseTypeSelector({
|
||||
</Badge>
|
||||
{isDownloaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
{t("releaseTypeSelector.downloaded")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,7 +168,7 @@ export function ReleaseTypeSelector({
|
||||
</Badge>
|
||||
{downloadedVersions.includes(releaseOptions[0].version) && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
{t("releaseTypeSelector.downloaded")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -182,7 +188,9 @@ export function ReleaseTypeSelector({
|
||||
className="w-full"
|
||||
>
|
||||
<LuDownload className="mr-2 w-4 h-4" />
|
||||
{isDownloading ? "Downloading..." : "Download Browser"}
|
||||
{isDownloading
|
||||
? t("releaseTypeSelector.downloading")
|
||||
: t("releaseTypeSelector.downloadBrowser")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+131
-128
@@ -165,34 +165,46 @@ export function SettingsDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPermissionDisplayName = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone";
|
||||
case "camera":
|
||||
return "Camera";
|
||||
}
|
||||
}, []);
|
||||
const getPermissionDisplayName = useCallback(
|
||||
(type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return t("settings.permissions.microphone");
|
||||
case "camera":
|
||||
return t("settings.permissions.camera");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getStatusBadge = useCallback((isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge variant="default" className="text-success-foreground bg-success">
|
||||
Granted
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">Not Granted</Badge>;
|
||||
}, []);
|
||||
const getStatusBadge = useCallback(
|
||||
(isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
>
|
||||
{t("common.status.granted")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{t("common.status.notGranted")}</Badge>;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getPermissionDescription = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Access to microphone for browser applications";
|
||||
case "camera":
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
const getPermissionDescription = useCallback(
|
||||
(type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return t("settings.permissions.microphoneDescription");
|
||||
case "camera":
|
||||
return t("settings.permissions.cameraDescription");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -332,15 +344,15 @@ export function SettingsDialog({
|
||||
// Don't show immediate success toast - let the version update progress events handle it
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
showErrorToast("Failed to clear cache", {
|
||||
showErrorToast(t("settings.advanced.clearCacheFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error instanceof Error ? error.message : t("common.errors.unknown"),
|
||||
duration: 4000,
|
||||
});
|
||||
} finally {
|
||||
setIsClearingCache(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleRequestPermission = useCallback(
|
||||
async (permissionType: PermissionType) => {
|
||||
@@ -348,7 +360,9 @@ export function SettingsDialog({
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionDisplayName(permissionType)} access requested`,
|
||||
t("settings.permissions.accessRequested", {
|
||||
permission: getPermissionDisplayName(permissionType),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
@@ -356,7 +370,7 @@ export function SettingsDialog({
|
||||
setRequestingPermission(null);
|
||||
}
|
||||
},
|
||||
[getPermissionDisplayName, requestPermission],
|
||||
[getPermissionDisplayName, requestPermission, t],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
@@ -592,11 +606,13 @@ export function SettingsDialog({
|
||||
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
|
||||
{/* Appearance Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Appearance</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.appearance.title")}
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="theme-select" className="text-sm">
|
||||
Theme
|
||||
{t("settings.appearance.theme")}
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.theme}
|
||||
@@ -614,20 +630,29 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-select">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
<SelectValue
|
||||
placeholder={t("settings.appearance.selectTheme")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
<SelectItem value="light">
|
||||
{t("settings.appearance.light")}
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
{t("settings.appearance.dark")}
|
||||
</SelectItem>
|
||||
<SelectItem value="system">
|
||||
{t("settings.appearance.system")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("common.labels.custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred theme or follow your system settings.
|
||||
Custom theme changes are applied only when you save.
|
||||
{t("settings.appearance.themeDescription")}
|
||||
</p>
|
||||
|
||||
{settings.theme === "custom" && (
|
||||
@@ -637,7 +662,7 @@ export function SettingsDialog({
|
||||
htmlFor="theme-preset-select"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Theme Preset
|
||||
{t("settings.appearance.themePreset")}
|
||||
</Label>
|
||||
<Select
|
||||
value={customThemeState.selectedThemeId ?? "custom"}
|
||||
@@ -659,7 +684,11 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-preset-select">
|
||||
<SelectValue placeholder="Select a theme preset" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"settings.appearance.selectThemePreset",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THEMES.map((theme) => (
|
||||
@@ -667,12 +696,16 @@ export function SettingsDialog({
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Your Own</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("settings.appearance.yourOwn")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">Custom Colors</div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("settings.appearance.customColors")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
@@ -744,11 +777,13 @@ export function SettingsDialog({
|
||||
|
||||
{/* Language Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Language</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.language.title")}
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="language-select" className="text-sm">
|
||||
Interface Language
|
||||
{t("settings.language.interface")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedLanguage ?? "system"}
|
||||
@@ -758,10 +793,14 @@ export function SettingsDialog({
|
||||
disabled={isLanguageLoading}
|
||||
>
|
||||
<SelectTrigger id="language-select">
|
||||
<SelectValue placeholder="Select language" />
|
||||
<SelectValue
|
||||
placeholder={t("settings.language.selectLanguage")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System Default</SelectItem>
|
||||
<SelectItem value="system">
|
||||
{t("settings.language.systemDefault")}
|
||||
</SelectItem>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.nativeName} ({lang.name})
|
||||
@@ -772,7 +811,7 @@ export function SettingsDialog({
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred language for the application interface.
|
||||
{t("settings.language.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -781,10 +820,12 @@ export function SettingsDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base font-medium">
|
||||
Default Browser
|
||||
{t("settings.defaultBrowser.title")}
|
||||
</Label>
|
||||
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
|
||||
{isDefaultBrowser ? "Active" : "Inactive"}
|
||||
{isDefaultBrowser
|
||||
? t("common.status.active")
|
||||
: t("common.status.inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -800,13 +841,12 @@ export function SettingsDialog({
|
||||
className="w-full"
|
||||
>
|
||||
{isDefaultBrowser
|
||||
? "Already Default Browser"
|
||||
: "Set as Default Browser"}
|
||||
? t("settings.defaultBrowser.alreadyDefault")
|
||||
: t("settings.defaultBrowser.setAsDefault")}
|
||||
</LoadingButton>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When set as default, Donut Browser will handle web links and
|
||||
allow you to choose which profile to use.
|
||||
{t("settings.defaultBrowser.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -815,12 +855,12 @@ export function SettingsDialog({
|
||||
{isMacOS && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
System Permissions
|
||||
{t("settings.permissions.title")}
|
||||
</Label>
|
||||
|
||||
{isLoadingPermissions ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading permissions...
|
||||
{t("settings.permissions.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -878,17 +918,18 @@ export function SettingsDialog({
|
||||
|
||||
{/* Integrations Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Integrations</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.integrations.title")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure Local API and MCP (Model Context Protocol) for
|
||||
integrating with external tools and AI assistants.
|
||||
{t("settings.integrations.description")}
|
||||
</p>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onIntegrationsOpen}
|
||||
>
|
||||
Open Integrations Settings
|
||||
{t("integrations.openSettings")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -912,33 +953,24 @@ export function SettingsDialog({
|
||||
{/* Sync Encryption Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.encryption.title", "Sync Encryption")}
|
||||
{t("settings.encryption.title")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.description",
|
||||
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
|
||||
)}
|
||||
{t("settings.encryption.description")}
|
||||
</p>
|
||||
|
||||
{!canUseEncryption ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
{t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
) : hasE2ePassword ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">
|
||||
{t("settings.encryption.passwordSet", "Active")}
|
||||
{t("settings.encryption.passwordSet")}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.passwordSetDescription",
|
||||
"E2E encryption password is set",
|
||||
)}
|
||||
{t("settings.encryption.passwordSetDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -952,10 +984,7 @@ export function SettingsDialog({
|
||||
setE2eError("");
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"settings.encryption.changePassword",
|
||||
"Change Password",
|
||||
)}
|
||||
{t("settings.encryption.changePassword")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -964,21 +993,13 @@ export function SettingsDialog({
|
||||
try {
|
||||
await invoke("delete_e2e_password");
|
||||
setHasE2ePassword(false);
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.removed",
|
||||
"Encryption password removed",
|
||||
),
|
||||
);
|
||||
showSuccessToast(t("settings.encryption.removed"));
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"settings.encryption.removePassword",
|
||||
"Remove Password",
|
||||
)}
|
||||
{t("settings.encryption.removePassword")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -986,10 +1007,7 @@ export function SettingsDialog({
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"settings.encryption.passwordPlaceholder",
|
||||
"Password (min 8 characters)",
|
||||
)}
|
||||
placeholder={t("settings.encryption.passwordPlaceholder")}
|
||||
value={e2ePassword}
|
||||
onChange={(e) => {
|
||||
setE2ePassword(e.target.value);
|
||||
@@ -998,10 +1016,7 @@ export function SettingsDialog({
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"settings.encryption.confirmPlaceholder",
|
||||
"Confirm password",
|
||||
)}
|
||||
placeholder={t("settings.encryption.confirmPlaceholder")}
|
||||
value={e2ePasswordConfirm}
|
||||
onChange={(e) => {
|
||||
setE2ePasswordConfirm(e.target.value);
|
||||
@@ -1017,21 +1032,11 @@ export function SettingsDialog({
|
||||
isLoading={isSavingE2e}
|
||||
onClick={async () => {
|
||||
if (e2ePassword.length < 8) {
|
||||
setE2eError(
|
||||
t(
|
||||
"settings.encryption.passwordTooShort",
|
||||
"Password must be at least 8 characters",
|
||||
),
|
||||
);
|
||||
setE2eError(t("settings.encryption.passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
if (e2ePassword !== e2ePasswordConfirm) {
|
||||
setE2eError(
|
||||
t(
|
||||
"settings.encryption.passwordMismatch",
|
||||
"Passwords do not match",
|
||||
),
|
||||
);
|
||||
setE2eError(t("settings.encryption.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
setIsSavingE2e(true);
|
||||
@@ -1043,10 +1048,7 @@ export function SettingsDialog({
|
||||
setE2ePassword("");
|
||||
setE2ePasswordConfirm("");
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.passwordSaved",
|
||||
"Encryption password set",
|
||||
),
|
||||
t("settings.encryption.passwordSaved"),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
@@ -1055,7 +1057,7 @@ export function SettingsDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.setPassword", "Set Password")}
|
||||
{t("settings.encryption.setPassword")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1064,28 +1066,29 @@ export function SettingsDialog({
|
||||
{/* Commercial License Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
Commercial License
|
||||
{t("settings.commercial.title")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
{trialStatus?.type === "Active" ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Trial: {trialStatus.days_remaining} days,{" "}
|
||||
{trialStatus.hours_remaining} hours remaining
|
||||
{t("settings.commercial.trialActive", {
|
||||
days: trialStatus.days_remaining,
|
||||
hours: trialStatus.hours_remaining,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Commercial use is free during the trial period
|
||||
{t("settings.commercial.trialActiveDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-warning">
|
||||
Trial expired
|
||||
{t("settings.commercial.trialExpired")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Personal use remains free. Commercial use requires a
|
||||
license.
|
||||
{t("settings.commercial.trialExpiredDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1094,7 +1097,9 @@ export function SettingsDialog({
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.advanced.title")}
|
||||
</Label>
|
||||
|
||||
{!isLinux && (
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg border">
|
||||
@@ -1129,13 +1134,11 @@ export function SettingsDialog({
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Clear All Version Cache
|
||||
{t("settings.advanced.clearCache")}
|
||||
</LoadingButton>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Clear all cached browser version data and refresh all browser
|
||||
versions from their sources. This will force a fresh download of
|
||||
version information for all browsers.
|
||||
{t("settings.advanced.clearCacheDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1151,7 +1154,7 @@ export function SettingsDialog({
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
@@ -1162,7 +1165,7 @@ export function SettingsDialog({
|
||||
}}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
Save Settings
|
||||
{t("common.buttons.saveSettings")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -74,6 +74,7 @@ function ObjectEditor({
|
||||
title,
|
||||
readOnly = false,
|
||||
}: ObjectEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [jsonString, setJsonString] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,7 +112,7 @@ function ObjectEditor({
|
||||
onChange={(e) => {
|
||||
handleChange(e.target.value);
|
||||
}}
|
||||
placeholder={`Enter ${title} as JSON`}
|
||||
placeholder={t("fingerprint.enterAsJson", { title })}
|
||||
className="font-mono text-sm"
|
||||
rows={6}
|
||||
disabled={readOnly}
|
||||
@@ -465,7 +466,9 @@ export function SharedCamoufoxConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Intel Mac OS X 10.15"
|
||||
placeholder={t(
|
||||
"config.camoufox.fingerprint.osCpuPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -904,7 +907,9 @@ export function SharedCamoufoxConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., llvmpipe, or similar"
|
||||
placeholder={t(
|
||||
"config.camoufox.fingerprint.webglRendererPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1010,7 +1015,7 @@ export function SharedCamoufoxConfigForm({
|
||||
selected.map((s: Option) => s.value),
|
||||
);
|
||||
}}
|
||||
placeholder="Add fonts..."
|
||||
placeholder={t("fingerprint.addFontsPlaceholder")}
|
||||
creatable
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
showErrorToast("Please enter a server URL");
|
||||
showErrorToast(t("sync.config.serverUrlRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,18 +137,18 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const response = await fetch(healthUrl);
|
||||
if (response.ok) {
|
||||
setConnectionStatus("connected");
|
||||
showSuccessToast("Connection successful!");
|
||||
showSuccessToast(t("sync.config.connectionSuccess"));
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Server responded with an error");
|
||||
showErrorToast(t("sync.config.serverError"));
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Failed to connect to server");
|
||||
showErrorToast(t("sync.config.connectFailed"));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [serverUrl]);
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -162,15 +162,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
showSuccessToast("Sync settings saved");
|
||||
showSuccessToast(t("sync.config.settingsSaved"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save sync settings:", error);
|
||||
showErrorToast("Failed to save settings");
|
||||
showErrorToast(t("sync.config.saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [serverUrl, token, onClose]);
|
||||
}, [serverUrl, token, onClose, t]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -187,14 +187,14 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setServerUrl("");
|
||||
setToken("");
|
||||
setConnectionStatus("unknown");
|
||||
showSuccessToast("Sync disconnected");
|
||||
showSuccessToast(t("sync.config.disconnected"));
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
showErrorToast("Failed to disconnect");
|
||||
showErrorToast(t("sync.config.disconnectFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleOpenLogin = useCallback(async () => {
|
||||
try {
|
||||
@@ -452,7 +452,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setShowToken(!showToken);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -152,6 +153,7 @@ export function TrafficDetailsDialog({
|
||||
profileId,
|
||||
profileName,
|
||||
}: TrafficDetailsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
|
||||
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
|
||||
|
||||
@@ -211,7 +213,9 @@ export function TrafficDetailsDialog({
|
||||
{payload.map((entry) => (
|
||||
<p key={String(entry.dataKey)} className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{entry.dataKey === "sent" ? "↑ Sent: " : "↓ Received: "}
|
||||
{entry.dataKey === "sent"
|
||||
? t("traffic.tooltipSent")
|
||||
: t("traffic.tooltipReceived")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatBytesPerSecond(
|
||||
@@ -223,7 +227,7 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
// Top domains sorted by total traffic
|
||||
@@ -255,7 +259,7 @@ export function TrafficDetailsDialog({
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Traffic Details
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="text-muted-foreground font-normal ml-2">
|
||||
— {profileName}
|
||||
@@ -269,7 +273,9 @@ export function TrafficDetailsDialog({
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">Bandwidth Over Time</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
@@ -277,19 +283,21 @@ export function TrafficDetailsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] h-8">
|
||||
<SelectValue placeholder="Time period" />
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">Last 1 min</SelectItem>
|
||||
<SelectItem value="5m">Last 5 min</SelectItem>
|
||||
<SelectItem value="30m">Last 30 min</SelectItem>
|
||||
<SelectItem value="1h">Last 1 hour</SelectItem>
|
||||
<SelectItem value="2h">Last 2 hours</SelectItem>
|
||||
<SelectItem value="4h">Last 4 hours</SelectItem>
|
||||
<SelectItem value="1d">Last 1 day</SelectItem>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
<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>
|
||||
@@ -393,7 +401,9 @@ export function TrafficDetailsDialog({
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Sent</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
@@ -401,7 +411,7 @@ export function TrafficDetailsDialog({
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Received
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +421,12 @@ export function TrafficDetailsDialog({
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sent ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{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)}
|
||||
@@ -419,7 +434,12 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Received ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{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)}
|
||||
@@ -427,7 +447,12 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requests ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
@@ -438,38 +463,50 @@ export function TrafficDetailsDialog({
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
|
||||
<div>
|
||||
<span className="font-medium">All-time traffic:</span>{" "}
|
||||
<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">All-time requests:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Note: If you are using a proxy, VPN, or similar service, your
|
||||
provider may calculate traffic differently due to encryption
|
||||
overhead and protocol differences.
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Top Domains by Traffic (
|
||||
{timePeriod === "all" ? "all time" : timePeriod})
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<span>Domain</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span className="text-right">Sent</span>
|
||||
<span className="text-right">Received</span>
|
||||
<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-[180px] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
@@ -503,14 +540,22 @@ export function TrafficDetailsDialog({
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Top Domains by Requests (
|
||||
{timePeriod === "all" ? "all time" : timePeriod})
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<span>Domain</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span className="text-right">Total Traffic</span>
|
||||
<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-[180px] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
@@ -543,7 +588,7 @@ export function TrafficDetailsDialog({
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Unique IPs ({stats.unique_ips.length})
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -563,10 +608,8 @@ export function TrafficDetailsDialog({
|
||||
{/* No data state */}
|
||||
{!stats && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No traffic data available for this profile.</p>
|
||||
<p className="text-sm mt-1">
|
||||
Traffic data will appear after you launch the profile.
|
||||
</p>
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPipette } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -366,12 +367,13 @@ export const ColorPickerOutput = ({
|
||||
className: _className,
|
||||
...props
|
||||
}: ColorPickerOutputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { mode, setMode } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||
<SelectValue placeholder="Mode" />
|
||||
<SelectValue placeholder={t("common.labels.mode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formats.map((format) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -39,13 +40,18 @@ export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
className,
|
||||
disabled,
|
||||
}: ComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
|
||||
const resolvedSearchPlaceholder =
|
||||
searchPlaceholder ?? t("common.buttons.search");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -58,15 +64,15 @@ export function Combobox({
|
||||
>
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.label
|
||||
: placeholder}
|
||||
: resolvedPlaceholder}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandInput placeholder={resolvedSearchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No option found.</CommandEmpty>
|
||||
<CommandEmpty>{t("common.noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
@@ -100,77 +106,3 @@ export function Combobox({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
value: "next.js",
|
||||
label: "Next.js",
|
||||
},
|
||||
{
|
||||
value: "sveltekit",
|
||||
label: "SvelteKit",
|
||||
},
|
||||
{
|
||||
value: "nuxt.js",
|
||||
label: "Nuxt.js",
|
||||
},
|
||||
{
|
||||
value: "remix",
|
||||
label: "Remix",
|
||||
},
|
||||
{
|
||||
value: "astro",
|
||||
label: "Astro",
|
||||
},
|
||||
];
|
||||
|
||||
export function ComboboxDemo() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState("");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value
|
||||
? frameworks.find((framework) => framework.value === value)?.label
|
||||
: "Select framework..."}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search framework..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No framework found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{frameworks.map((framework) => (
|
||||
<CommandItem
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === framework.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{framework.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
|
||||
import {
|
||||
@@ -30,19 +31,23 @@ function Command({
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||
const resolvedDescription =
|
||||
description ?? t("common.commandPalette.description");
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
<DialogTitle>{resolvedTitle}</DialogTitle>
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
@@ -111,7 +116,7 @@ function CommandGroup({
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium overflow-y-scroll",
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCopy } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { showSuccessToast } from "@/lib/toast-utils";
|
||||
@@ -26,6 +27,7 @@ export function CopyToClipboard({
|
||||
className,
|
||||
successMessage = "Copied to clipboard",
|
||||
}: CopyToClipboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
@@ -47,9 +49,11 @@ export function CopyToClipboard({
|
||||
size={size}
|
||||
className={`relative ${className ?? ""}`}
|
||||
onClick={copyToClipboard}
|
||||
aria-label={copied ? "Copied" : "Copy to clipboard"}
|
||||
aria-label={copied ? t("common.aria.copied") : t("common.aria.copy")}
|
||||
>
|
||||
<span className="sr-only">{copied ? "Copied" : "Copy"}</span>
|
||||
<span className="sr-only">
|
||||
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
|
||||
</span>
|
||||
<LuCopy
|
||||
className={`h-4 w-4 transition-all duration-300 ${
|
||||
copied ? "scale-0" : "scale-100"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RxCross2 } from "react-icons/rx";
|
||||
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
@@ -115,6 +116,7 @@ function DialogContent({
|
||||
transition = { type: "spring", stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const initialRotation =
|
||||
from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
@@ -158,7 +160,7 @@ function DialogContent({
|
||||
}}
|
||||
transition={transition}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -166,7 +168,7 @@ function DialogContent({
|
||||
{children}
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,6 +29,7 @@ export function VpnCheckButton({
|
||||
setCheckingVpnId,
|
||||
disabled = false,
|
||||
}: VpnCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
|
||||
|
||||
const handleCheck = React.useCallback(async () => {
|
||||
@@ -41,14 +43,14 @@ export function VpnCheckButton({
|
||||
setResult(checkResult);
|
||||
|
||||
if (checkResult.is_valid) {
|
||||
toast.success(`VPN "${vpnName}" configuration is valid`);
|
||||
toast.success(t("vpnCheck.valid", { name: vpnName }));
|
||||
} else {
|
||||
toast.error(`VPN "${vpnName}" configuration is invalid`);
|
||||
toast.error(t("vpnCheck.invalid", { name: vpnName }));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`VPN check failed: ${errorMessage}`);
|
||||
toast.error(t("vpnCheck.failed", { error: errorMessage }));
|
||||
|
||||
setResult({
|
||||
ip: "",
|
||||
@@ -58,7 +60,7 @@ export function VpnCheckButton({
|
||||
} finally {
|
||||
setCheckingVpnId(null);
|
||||
}
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]);
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId, t]);
|
||||
|
||||
const isCurrentlyChecking = checkingVpnId === vpnId;
|
||||
|
||||
@@ -85,23 +87,27 @@ export function VpnCheckButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking VPN config...</p>
|
||||
<p>{t("vpnCheck.tooltipChecking")}</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p>Configuration valid</p>
|
||||
<p>{t("vpnCheck.tooltipValid")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("vpnCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Configuration invalid</p>
|
||||
<p>{t("vpnCheck.tooltipInvalid")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("vpnCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check VPN config validity</p>
|
||||
<p>{t("vpnCheck.tooltipDefault")}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
+179
-384
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,15 +18,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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { VpnConfig, VpnType } from "@/types";
|
||||
import type { VpnConfig } from "@/types";
|
||||
|
||||
interface VpnFormDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -48,19 +39,6 @@ interface WireGuardFormData {
|
||||
presharedKey: string;
|
||||
}
|
||||
|
||||
interface OpenVpnFormData {
|
||||
name: string;
|
||||
rawConfig: string;
|
||||
}
|
||||
|
||||
interface VpnDependencyStatus {
|
||||
isAvailable: boolean;
|
||||
requiresExternalInstall: boolean;
|
||||
missingBinary: boolean;
|
||||
missingWindowsAdapter: boolean;
|
||||
dependencyCheckFailed: boolean;
|
||||
}
|
||||
|
||||
const defaultWireGuardForm: WireGuardFormData = {
|
||||
name: "",
|
||||
privateKey: "",
|
||||
@@ -74,11 +52,6 @@ const defaultWireGuardForm: WireGuardFormData = {
|
||||
presharedKey: "",
|
||||
};
|
||||
|
||||
const defaultOpenVpnForm: OpenVpnFormData = {
|
||||
name: "",
|
||||
rawConfig: "",
|
||||
};
|
||||
|
||||
function buildWireGuardConfig(form: WireGuardFormData): string {
|
||||
const lines: string[] = ["[Interface]"];
|
||||
lines.push(`PrivateKey = ${form.privateKey.trim()}`);
|
||||
@@ -104,61 +77,23 @@ export function VpnFormDialog({
|
||||
}: VpnFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [vpnType, setVpnType] = useState<VpnType>("WireGuard");
|
||||
const [wireGuardForm, setWireGuardForm] =
|
||||
useState<WireGuardFormData>(defaultWireGuardForm);
|
||||
const [openVpnForm, setOpenVpnForm] =
|
||||
useState<OpenVpnFormData>(defaultOpenVpnForm);
|
||||
const [vpnDependencyStatus, setVpnDependencyStatus] =
|
||||
useState<VpnDependencyStatus | null>(null);
|
||||
|
||||
const resetForms = useCallback(() => {
|
||||
setVpnType("WireGuard");
|
||||
setWireGuardForm(defaultWireGuardForm);
|
||||
setOpenVpnForm(defaultOpenVpnForm);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingVpn) {
|
||||
setVpnType(editingVpn.vpn_type);
|
||||
if (editingVpn.vpn_type === "WireGuard") {
|
||||
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
|
||||
} else {
|
||||
setOpenVpnForm({ name: editingVpn.name, rawConfig: "" });
|
||||
}
|
||||
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
|
||||
} else {
|
||||
resetForms();
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingVpn, resetForms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setVpnDependencyStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void invoke<VpnDependencyStatus>("get_vpn_dependency_status", { vpnType })
|
||||
.then((status) => {
|
||||
if (!cancelled) {
|
||||
setVpnDependencyStatus(status);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load VPN dependency status:", error);
|
||||
if (!cancelled) {
|
||||
setVpnDependencyStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, vpnType]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
@@ -167,13 +102,10 @@ export function VpnFormDialog({
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (editingVpn) {
|
||||
const name =
|
||||
vpnType === "WireGuard"
|
||||
? wireGuardForm.name.trim()
|
||||
: openVpnForm.name.trim();
|
||||
const name = wireGuardForm.name.trim();
|
||||
|
||||
if (!name) {
|
||||
toast.error("VPN name is required");
|
||||
toast.error(t("vpns.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,92 +116,61 @@ export function VpnFormDialog({
|
||||
name,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("VPN updated successfully");
|
||||
toast.success(t("vpns.form.updated"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to update VPN: ${errorMessage}`);
|
||||
toast.error(t("vpns.form.updateFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnType === "WireGuard") {
|
||||
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
|
||||
wireGuardForm;
|
||||
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
|
||||
wireGuardForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!privateKey.trim()) {
|
||||
toast.error("Private key is required");
|
||||
return;
|
||||
}
|
||||
if (!address.trim()) {
|
||||
toast.error("Address is required");
|
||||
return;
|
||||
}
|
||||
if (!peerPublicKey.trim()) {
|
||||
toast.error("Peer public key is required");
|
||||
return;
|
||||
}
|
||||
if (!peerEndpoint.trim()) {
|
||||
toast.error("Peer endpoint is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const configData = buildWireGuardConfig(wireGuardForm);
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "WireGuard",
|
||||
configData,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("WireGuard VPN created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
const { name, rawConfig } = openVpnForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!rawConfig.trim()) {
|
||||
toast.error("OpenVPN config content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "OpenVPN",
|
||||
configData: rawConfig,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("OpenVPN configuration created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
if (!name.trim()) {
|
||||
toast.error(t("vpns.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
}, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]);
|
||||
if (!privateKey.trim()) {
|
||||
toast.error(t("vpns.form.privateKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!address.trim()) {
|
||||
toast.error(t("vpns.form.addressRequired"));
|
||||
return;
|
||||
}
|
||||
if (!peerPublicKey.trim()) {
|
||||
toast.error(t("vpns.form.peerPublicKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!peerEndpoint.trim()) {
|
||||
toast.error(t("vpns.form.peerEndpointRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const configData = buildWireGuardConfig(wireGuardForm);
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "WireGuard",
|
||||
configData,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success(t("vpns.form.created"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("vpns.form.createFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [editingVpn, wireGuardForm, onClose, t]);
|
||||
|
||||
const updateWireGuard = useCallback(
|
||||
(field: keyof WireGuardFormData, value: string) => {
|
||||
@@ -278,54 +179,12 @@ export function VpnFormDialog({
|
||||
[],
|
||||
);
|
||||
|
||||
const updateOpenVpn = useCallback(
|
||||
(field: keyof OpenVpnFormData, value: string) => {
|
||||
setOpenVpnForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dialogTitle = editingVpn
|
||||
? "Edit VPN"
|
||||
: vpnType === "WireGuard"
|
||||
? "Create WireGuard VPN"
|
||||
: "Create OpenVPN Configuration";
|
||||
|
||||
? t("vpns.form.titleEdit")
|
||||
: t("vpns.form.titleCreate");
|
||||
const dialogDescription = editingVpn
|
||||
? "Update the name of your VPN configuration."
|
||||
: vpnType === "WireGuard"
|
||||
? "Enter your WireGuard interface and peer details."
|
||||
: "Paste your .ovpn configuration file content.";
|
||||
|
||||
let dependencyWarningTitle: string | null = null;
|
||||
let dependencyWarningDescription: string | null = null;
|
||||
|
||||
if (
|
||||
vpnType === "OpenVPN" &&
|
||||
vpnDependencyStatus?.requiresExternalInstall &&
|
||||
!vpnDependencyStatus.isAvailable
|
||||
) {
|
||||
if (vpnDependencyStatus.missingBinary) {
|
||||
dependencyWarningTitle = t("vpnForm.dependencies.openVpnMissingTitle");
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnMissingDescription",
|
||||
);
|
||||
} else if (vpnDependencyStatus.missingWindowsAdapter) {
|
||||
dependencyWarningTitle = t(
|
||||
"vpnForm.dependencies.openVpnAdapterMissingTitle",
|
||||
);
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnAdapterMissingDescription",
|
||||
);
|
||||
} else if (vpnDependencyStatus.dependencyCheckFailed) {
|
||||
dependencyWarningTitle = t(
|
||||
"vpnForm.dependencies.openVpnCheckFailedTitle",
|
||||
);
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnCheckFailedDescription",
|
||||
);
|
||||
}
|
||||
}
|
||||
? t("vpns.form.descEdit")
|
||||
: t("vpns.form.descCreate");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -337,221 +196,155 @@ export function VpnFormDialog({
|
||||
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<div className="grid gap-4 py-2">
|
||||
{dependencyWarningTitle && dependencyWarningDescription && (
|
||||
<Alert className="border-warning/50 bg-warning/10">
|
||||
<AlertTitle className="text-warning">
|
||||
{dependencyWarningTitle}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-warning">
|
||||
{dependencyWarningDescription}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
|
||||
<Input
|
||||
id="wg-name"
|
||||
value={wireGuardForm.name}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("name", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.namePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<div className="grid gap-2">
|
||||
<Label>VPN Type</Label>
|
||||
<Select
|
||||
value={vpnType}
|
||||
onValueChange={(value) => {
|
||||
setVpnType(value as VpnType);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select VPN type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="WireGuard">WireGuard</SelectItem>
|
||||
<SelectItem value="OpenVPN">OpenVPN</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vpnType === "WireGuard" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">Name</Label>
|
||||
<Label htmlFor="wg-private-key">
|
||||
{t("vpns.form.privateKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-name"
|
||||
value={wireGuardForm.name}
|
||||
id="wg-private-key"
|
||||
value={wireGuardForm.privateKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("name", e.target.value);
|
||||
updateWireGuard("privateKey", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Home WireGuard"
|
||||
placeholder={t("vpns.form.privateKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-private-key">Private Key</Label>
|
||||
<Input
|
||||
id="wg-private-key"
|
||||
value={wireGuardForm.privateKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("privateKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded private key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-address">Address</Label>
|
||||
<Input
|
||||
id="wg-address"
|
||||
value={wireGuardForm.address}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("address", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 10.0.0.2/24"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-dns">DNS (optional)</Label>
|
||||
<Input
|
||||
id="wg-dns"
|
||||
value={wireGuardForm.dns}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("dns", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 1.1.1.1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-mtu">MTU (optional)</Label>
|
||||
<Input
|
||||
id="wg-mtu"
|
||||
type="number"
|
||||
value={wireGuardForm.mtu}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("mtu", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 1420"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-public-key">
|
||||
Peer Public Key
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-public-key"
|
||||
value={wireGuardForm.peerPublicKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerPublicKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded peer public key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
|
||||
<Input
|
||||
id="wg-peer-endpoint"
|
||||
value={wireGuardForm.peerEndpoint}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerEndpoint", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. vpn.example.com:51820"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
|
||||
<Input
|
||||
id="wg-allowed-ips"
|
||||
value={wireGuardForm.allowedIps}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("allowedIps", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 0.0.0.0/0, ::/0"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-keepalive">
|
||||
Persistent Keepalive (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-keepalive"
|
||||
type="number"
|
||||
value={wireGuardForm.persistentKeepalive}
|
||||
onChange={(e) => {
|
||||
updateWireGuard(
|
||||
"persistentKeepalive",
|
||||
e.target.value,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g. 25"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-preshared-key">
|
||||
Preshared Key (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-preshared-key"
|
||||
value={wireGuardForm.presharedKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("presharedKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded preshared key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{vpnType === "OpenVPN" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-name">Name</Label>
|
||||
<Label htmlFor="wg-address">{t("vpns.form.address")}</Label>
|
||||
<Input
|
||||
id="ovpn-name"
|
||||
value={openVpnForm.name}
|
||||
id="wg-address"
|
||||
value={wireGuardForm.address}
|
||||
onChange={(e) => {
|
||||
updateOpenVpn("name", e.target.value);
|
||||
updateWireGuard("address", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Work OpenVPN"
|
||||
placeholder={t("vpns.form.addressPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-config">Raw Config</Label>
|
||||
<Textarea
|
||||
id="ovpn-config"
|
||||
value={openVpnForm.rawConfig}
|
||||
<Label htmlFor="wg-dns">{t("vpns.form.dnsOptional")}</Label>
|
||||
<Input
|
||||
id="wg-dns"
|
||||
value={wireGuardForm.dns}
|
||||
onChange={(e) => {
|
||||
updateOpenVpn("rawConfig", e.target.value);
|
||||
updateWireGuard("dns", e.target.value);
|
||||
}}
|
||||
placeholder="Paste your .ovpn file content here..."
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder={t("vpns.form.dnsPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-mtu">{t("vpns.form.mtuOptional")}</Label>
|
||||
<Input
|
||||
id="wg-mtu"
|
||||
type="number"
|
||||
value={wireGuardForm.mtu}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("mtu", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.mtuPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-public-key">
|
||||
{t("vpns.form.peerPublicKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-public-key"
|
||||
value={wireGuardForm.peerPublicKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerPublicKey", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.peerPublicKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-endpoint">
|
||||
{t("vpns.form.peerEndpoint")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-endpoint"
|
||||
value={wireGuardForm.peerEndpoint}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerEndpoint", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.peerEndpointPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-allowed-ips">
|
||||
{t("vpns.form.allowedIps")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-allowed-ips"
|
||||
value={wireGuardForm.allowedIps}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("allowedIps", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.allowedIpsPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-keepalive">
|
||||
{t("vpns.form.keepaliveOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-keepalive"
|
||||
type="number"
|
||||
value={wireGuardForm.persistentKeepalive}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("persistentKeepalive", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.keepalivePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-preshared-key">
|
||||
{t("vpns.form.presharedKeyOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-preshared-key"
|
||||
value={wireGuardForm.presharedKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("presharedKey", e.target.value);
|
||||
}}
|
||||
placeholder={t("vpns.form.presharedKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -563,10 +356,12 @@ export function VpnFormDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton isLoading={isSubmitting} onClick={handleSubmit}>
|
||||
{editingVpn ? "Update VPN" : "Create VPN"}
|
||||
{editingVpn
|
||||
? t("vpns.form.updateButton")
|
||||
: t("vpns.form.createButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuShield, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -52,21 +53,11 @@ const detectVpnType = (
|
||||
endpoint: endpointMatch ? endpointMatch[1] : null,
|
||||
};
|
||||
}
|
||||
if (
|
||||
lowerFilename.endsWith(".ovpn") ||
|
||||
(content.includes("remote ") &&
|
||||
(content.includes("client") || content.includes("dev tun")))
|
||||
) {
|
||||
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
|
||||
const endpoint = remoteMatch
|
||||
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
|
||||
: null;
|
||||
return { isVpn: true, type: "OpenVPN", endpoint };
|
||||
}
|
||||
return { isVpn: false, type: null, endpoint: null };
|
||||
};
|
||||
|
||||
export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
@@ -92,25 +83,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
const processContent = useCallback((content: string, filename: string) => {
|
||||
const detection = detectVpnType(content, filename);
|
||||
if (!detection.isVpn) {
|
||||
toast.error("Content does not appear to be a valid VPN configuration");
|
||||
return;
|
||||
}
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: detection.type,
|
||||
endpoint: detection.endpoint,
|
||||
});
|
||||
const baseName = filename
|
||||
.replace(/\.(conf|ovpn)$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${detection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
}, []);
|
||||
const processContent = useCallback(
|
||||
(content: string, filename: string) => {
|
||||
const detection = detectVpnType(content, filename);
|
||||
if (!detection.isVpn) {
|
||||
toast.error(t("vpns.import.invalidContent"));
|
||||
return;
|
||||
}
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: detection.type,
|
||||
endpoint: detection.endpoint,
|
||||
});
|
||||
const baseName = filename
|
||||
.replace(/\.conf$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${detection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
@@ -120,11 +114,11 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
processContent(content, file.name);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("vpns.import.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[processContent],
|
||||
[processContent, t],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -132,16 +126,14 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFile = files.find(
|
||||
(f) => f.name.endsWith(".conf") || f.name.endsWith(".ovpn"),
|
||||
);
|
||||
const validFile = files.find((f) => f.name.endsWith(".conf"));
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .conf or .ovpn file");
|
||||
toast.error(t("vpns.import.wrongFileType"));
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
[handleFileRead, t],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -186,23 +178,22 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import VPN config",
|
||||
error instanceof Error ? error.message : t("vpns.import.failedGeneric"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [vpnPreview, vpnName]);
|
||||
}, [vpnPreview, vpnName, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import VPN Config</DialogTitle>
|
||||
<DialogTitle>{t("vpns.import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" &&
|
||||
"Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration file"}
|
||||
{step === "vpn-preview" && "Review the VPN configuration to import"}
|
||||
{step === "vpn-result" && "VPN import completed"}
|
||||
{step === "dropzone" && t("vpns.import.descDropzone")}
|
||||
{step === "vpn-preview" && t("vpns.import.descPreview")}
|
||||
{step === "vpn-result" && t("vpns.import.descResult")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -230,16 +221,12 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a VPN config file here or click to browse
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
(.conf for WireGuard, .ovpn for OpenVPN)
|
||||
</span>
|
||||
{t("vpns.import.dropzonePrompt")}
|
||||
</p>
|
||||
<input
|
||||
id="vpn-file-input"
|
||||
type="file"
|
||||
accept=".conf,.ovpn"
|
||||
accept=".conf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -249,7 +236,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Paste from clipboard with {modKey}+V
|
||||
{t("vpns.import.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -260,21 +247,25 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<LuShield className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{vpnPreview.detectedType} Configuration
|
||||
{t("vpns.import.configurationLabel", {
|
||||
type: vpnPreview.detectedType,
|
||||
})}
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Endpoint: {vpnPreview.endpoint}
|
||||
{t("vpns.import.endpointLabel", {
|
||||
endpoint: vpnPreview.endpoint,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">VPN Name</Label>
|
||||
<Label htmlFor="vpn-name">{t("vpns.import.vpnNameLabel")}</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder="My VPN"
|
||||
placeholder={t("vpns.import.vpnNamePlaceholder")}
|
||||
value={vpnName}
|
||||
onChange={(e) => {
|
||||
setVpnName(e.target.value);
|
||||
@@ -283,7 +274,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Config Preview</Label>
|
||||
<Label>{t("vpns.import.configPreview")}</Label>
|
||||
<ScrollArea className="h-[150px] border rounded-md">
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
@@ -304,7 +295,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<LuShield className="w-8 h-8 text-success" />
|
||||
<div>
|
||||
<div className="font-medium text-success">
|
||||
VPN Imported Successfully
|
||||
{t("vpns.import.importedSuccess")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
@@ -314,7 +305,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-destructive">
|
||||
Import Failed
|
||||
{t("vpns.import.importFailed")}
|
||||
</div>
|
||||
<div className="text-sm text-destructive">
|
||||
{vpnImportResult.error}
|
||||
@@ -328,26 +319,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
Import VPN
|
||||
{t("vpns.import.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("vpns.import.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -316,7 +316,9 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Win32, MacIntel, Linux x86_64"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.platformPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -755,7 +757,9 @@ export function WayfernConfigForm({
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 300 for EST (UTC-5)"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.timezoneOffsetPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -841,7 +845,9 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Intel(R) HD Graphics"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.webglRendererPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -880,7 +886,7 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="Enter a seed string for canvas fingerprint"
|
||||
placeholder={t("fingerprint.canvasNoiseSeedPlaceholder")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fingerprint.canvasNoiseSeedDescription")}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,24 +23,25 @@ export function WayfernTermsDialog({
|
||||
isOpen,
|
||||
onAccepted,
|
||||
}: WayfernTermsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
setIsAccepting(true);
|
||||
try {
|
||||
await invoke("accept_wayfern_terms");
|
||||
showSuccessToast("Terms accepted successfully");
|
||||
showSuccessToast(t("wayfernTerms.acceptSuccess"));
|
||||
onAccepted();
|
||||
} catch (error) {
|
||||
console.error("Failed to accept terms:", error);
|
||||
showErrorToast("Failed to accept terms", {
|
||||
showErrorToast(t("wayfernTerms.acceptFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("wayfernTerms.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsAccepting(false);
|
||||
}
|
||||
}, [onAccepted]);
|
||||
}, [onAccepted, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -56,33 +58,30 @@ export function WayfernTermsDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wayfern Terms and Conditions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Before using Donut Browser, you must read and agree to Wayfern's
|
||||
Terms and Conditions.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("wayfernTerms.title")}</DialogTitle>
|
||||
<DialogDescription>{t("wayfernTerms.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please review the Terms and Conditions at:
|
||||
{t("wayfernTerms.reviewLabel")}
|
||||
</p>
|
||||
<a
|
||||
href="https://wayfern.com/terms-and-conditions"
|
||||
href="https://wayfern.com/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm font-medium block"
|
||||
>
|
||||
https://wayfern.com/terms-and-conditions
|
||||
https://wayfern.com/tos
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
By clicking "I Accept", you agree to be bound by these terms.
|
||||
{t("wayfernTerms.agreeNotice")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<LoadingButton onClick={handleAccept} isLoading={isAccepting}>
|
||||
I Accept
|
||||
{t("wayfernTerms.acceptButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Platform = "macos" | "windows" | "linux";
|
||||
|
||||
@@ -13,6 +14,7 @@ function detectPlatform(): Platform {
|
||||
}
|
||||
|
||||
export function WindowDragArea() {
|
||||
const { t } = useTranslation();
|
||||
const [platform, setPlatform] = useState<Platform | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,7 +106,7 @@ export function WindowDragArea() {
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Minimize"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
@@ -124,7 +126,7 @@ export function WindowDragArea() {
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label="Close"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AppUpdateToast } from "@/components/app-update-toast";
|
||||
import { showToast } from "@/lib/toast-utils";
|
||||
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
||||
|
||||
export function useAppUpdateNotifications() {
|
||||
const { t } = useTranslation();
|
||||
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateProgress, setUpdateProgress] =
|
||||
@@ -60,32 +62,35 @@ export function useAppUpdateNotifications() {
|
||||
}
|
||||
}, [isClient]);
|
||||
|
||||
const handleAppUpdate = useCallback(async (appUpdateInfo: AppUpdateInfo) => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
setUpdateProgress({
|
||||
stage: "downloading",
|
||||
percentage: 0,
|
||||
speed: undefined,
|
||||
eta: undefined,
|
||||
message: "Starting update...",
|
||||
});
|
||||
const handleAppUpdate = useCallback(
|
||||
async (appUpdateInfo: AppUpdateInfo) => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
setUpdateProgress({
|
||||
stage: "downloading",
|
||||
percentage: 0,
|
||||
speed: undefined,
|
||||
eta: undefined,
|
||||
message: "Starting update...",
|
||||
});
|
||||
|
||||
await invoke("download_and_prepare_app_update", {
|
||||
updateInfo: appUpdateInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update app:", error);
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Failed to update Donut Browser",
|
||||
description: String(error),
|
||||
duration: 6000,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setUpdateProgress(null);
|
||||
}
|
||||
}, []);
|
||||
await invoke("download_and_prepare_app_update", {
|
||||
updateInfo: appUpdateInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update app:", error);
|
||||
showToast({
|
||||
type: "error",
|
||||
title: t("appUpdate.toast.updateFailed"),
|
||||
description: String(error),
|
||||
duration: 6000,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setUpdateProgress(null);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
try {
|
||||
@@ -94,12 +99,12 @@ export function useAppUpdateNotifications() {
|
||||
console.error("Failed to restart app:", error);
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Failed to restart",
|
||||
title: t("appUpdate.toast.restartFailed"),
|
||||
description: String(error),
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const dismissAppUpdate = useCallback(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Event as TauriEvent } from "@tauri-apps/api/event";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import {
|
||||
dismissToast,
|
||||
@@ -106,11 +107,18 @@ export function useBrowserDownload() {
|
||||
return githubReleases;
|
||||
} catch (error) {
|
||||
console.error("Failed to load versions:", error);
|
||||
showErrorToast(`Failed to fetch ${browserName} versions`, {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
duration: 4000,
|
||||
});
|
||||
showErrorToast(
|
||||
i18n.t("browserDownload.toast.fetchVersionsFailed", {
|
||||
browser: browserName,
|
||||
}),
|
||||
{
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: i18n.t("common.errors.unknown"),
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
@@ -146,10 +154,16 @@ export function useBrowserDownload() {
|
||||
// Show notification about new versions if any were found
|
||||
if (result.new_versions_count && result.new_versions_count > 0) {
|
||||
showSuccessToast(
|
||||
`Found ${result.new_versions_count} new ${browserName} versions!`,
|
||||
i18n.t("browserDownload.toast.foundNewVersions", {
|
||||
count: result.new_versions_count,
|
||||
browser: browserName,
|
||||
}),
|
||||
{
|
||||
duration: 3000,
|
||||
description: `Total available: ${result.total_versions_count} versions`,
|
||||
description: i18n.t(
|
||||
"browserDownload.toast.totalAvailableVersions",
|
||||
{ count: result.total_versions_count },
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -157,11 +171,18 @@ export function useBrowserDownload() {
|
||||
return githubReleases;
|
||||
} catch (error) {
|
||||
console.error("Failed to load versions:", error);
|
||||
showErrorToast(`Failed to fetch ${browserName} versions`, {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
duration: 4000,
|
||||
});
|
||||
showErrorToast(
|
||||
i18n.t("browserDownload.toast.fetchVersionsFailed", {
|
||||
browser: browserName,
|
||||
}),
|
||||
{
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: i18n.t("common.errors.unknown"),
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
@@ -215,7 +236,7 @@ export function useBrowserDownload() {
|
||||
// Dismiss any existing download toast and show error
|
||||
dismissToast(`download-${browserStr}-${version}`);
|
||||
|
||||
let errorMessage = "Unknown error occurred";
|
||||
let errorMessage = i18n.t("common.errors.unknown");
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
@@ -226,10 +247,16 @@ export function useBrowserDownload() {
|
||||
|
||||
// Ensure the long-running download toast is dismissed, and show a finite error toast
|
||||
dismissToast(`download-${browserStr}-${version}`);
|
||||
showErrorToast(`Failed to download ${browserName} ${version}`, {
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
});
|
||||
showErrorToast(
|
||||
i18n.t("browserDownload.toast.downloadFailed", {
|
||||
browser: browserName,
|
||||
version,
|
||||
}),
|
||||
{
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -297,7 +324,7 @@ export function useBrowserDownload() {
|
||||
).toFixed(1);
|
||||
const etaText = progress.eta_seconds
|
||||
? formatTime(progress.eta_seconds)
|
||||
: "calculating...";
|
||||
: i18n.t("browserDownload.toast.calculating");
|
||||
|
||||
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
|
||||
showDownloadToast(
|
||||
@@ -346,10 +373,14 @@ export function useBrowserDownload() {
|
||||
);
|
||||
setDownloadProgress(null);
|
||||
showErrorToast(
|
||||
`${browserName} ${progress.version}: extraction failed`,
|
||||
i18n.t("browserDownload.toast.extractionFailed", {
|
||||
browser: browserName,
|
||||
version: progress.version,
|
||||
}),
|
||||
{
|
||||
description:
|
||||
"The corrupt file was deleted. It will be re-downloaded on next attempt.",
|
||||
description: i18n.t(
|
||||
"browserDownload.toast.extractionFailedDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
} else if (progress.stage === "completed") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export function useBrowserSupport() {
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
@@ -18,7 +19,7 @@ export function useBrowserSupport() {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load supported browsers",
|
||||
: i18n.t("errors.loadSupportedBrowsersFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { Extension, ExtensionGroup } from "@/types";
|
||||
|
||||
export function useExtensionEvents() {
|
||||
@@ -47,7 +48,9 @@ export function useExtensionEvents() {
|
||||
} catch (err) {
|
||||
console.error("Failed to setup extension event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup extension event listeners: ${JSON.stringify(err)}`,
|
||||
i18n.t("errors.setupExtensionListenersFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,9 @@ export function useGroupEvents() {
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(`Failed to load groups: ${JSON.stringify(err)}`);
|
||||
setError(
|
||||
i18n.t("errors.loadGroupsFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -65,7 +68,9 @@ export function useGroupEvents() {
|
||||
} catch (err) {
|
||||
console.error("Failed to setup group event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup group event listeners: ${JSON.stringify(err)}`,
|
||||
i18n.t("errors.setupGroupListenersFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { BrowserProfile, GroupWithCount } from "@/types";
|
||||
|
||||
interface UseProfileEventsReturn {
|
||||
@@ -38,7 +39,9 @@ export function useProfileEvents(): UseProfileEventsReturn {
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
setError(
|
||||
i18n.t("errors.loadProfilesFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -101,7 +104,9 @@ export function useProfileEvents(): UseProfileEventsReturn {
|
||||
} catch (err) {
|
||||
console.error("Failed to setup profile event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup profile event listeners: ${JSON.stringify(err)}`,
|
||||
i18n.t("errors.setupProfileListenersFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,9 @@ export function useProxyEvents() {
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load proxies:", err);
|
||||
setError(`Failed to load proxies: ${JSON.stringify(err)}`);
|
||||
setError(
|
||||
i18n.t("errors.loadProxiesFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
}
|
||||
}, [loadProxyUsage]);
|
||||
|
||||
@@ -84,7 +87,9 @@ export function useProxyEvents() {
|
||||
} catch (err) {
|
||||
console.error("Failed to setup proxy event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup proxy event listeners: ${JSON.stringify(err)}`,
|
||||
i18n.t("errors.setupProxyListenersFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import { dismissToast, showToast } from "@/lib/toast-utils";
|
||||
|
||||
@@ -147,7 +148,10 @@ export function useUpdateNotifications(
|
||||
showToast({
|
||||
id: `auto-update-error-${browser}-${newVersion}`,
|
||||
type: "error",
|
||||
title: `Failed to download ${browserDisplayName} ${newVersion}`,
|
||||
title: i18n.t("browserDownload.toast.downloadFailed", {
|
||||
browser: browserDisplayName,
|
||||
version: newVersion,
|
||||
}),
|
||||
description: String(downloadError),
|
||||
duration: 8000,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import {
|
||||
showAutoUpdateToast,
|
||||
@@ -162,9 +163,14 @@ export function useVersionUpdater() {
|
||||
);
|
||||
|
||||
showSuccessToast(
|
||||
`${browserDisplayName} ${new_version} already available`,
|
||||
i18n.t("versionUpdater.toast.alreadyAvailable", {
|
||||
browser: browserDisplayName,
|
||||
version: new_version,
|
||||
}),
|
||||
{
|
||||
description: "Updating profile configurations...",
|
||||
description: i18n.t(
|
||||
"versionUpdater.toast.updatingProfiles",
|
||||
),
|
||||
duration: 3000,
|
||||
},
|
||||
);
|
||||
@@ -187,25 +193,44 @@ export function useVersionUpdater() {
|
||||
|
||||
// Show success message based on whether profiles were updated
|
||||
if (updatedProfiles.length > 0) {
|
||||
const profileText =
|
||||
const description =
|
||||
updatedProfiles.length === 1
|
||||
? `Profile "${updatedProfiles[0]}" has been updated`
|
||||
: `${updatedProfiles.length} profiles have been updated`;
|
||||
? i18n.t("versionUpdater.toast.singleProfileUpdated", {
|
||||
name: updatedProfiles[0],
|
||||
version: new_version,
|
||||
})
|
||||
: i18n.t("versionUpdater.toast.multipleProfilesUpdated", {
|
||||
count: updatedProfiles.length,
|
||||
version: new_version,
|
||||
});
|
||||
|
||||
showSuccessToast(`${browserDisplayName} update completed`, {
|
||||
description: `${profileText} to version ${new_version}. You can now launch your browsers with the latest version.`,
|
||||
duration: 6000,
|
||||
});
|
||||
showSuccessToast(
|
||||
i18n.t("versionUpdater.toast.updateCompleted", {
|
||||
browser: browserDisplayName,
|
||||
}),
|
||||
{
|
||||
description,
|
||||
duration: 6000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(`${browserDisplayName} update completed`, {
|
||||
description: `Version ${new_version} is now available. Running profiles will use the new version when restarted.`,
|
||||
duration: 6000,
|
||||
});
|
||||
showSuccessToast(
|
||||
i18n.t("versionUpdater.toast.updateCompleted", {
|
||||
browser: browserDisplayName,
|
||||
}),
|
||||
{
|
||||
description: i18n.t(
|
||||
"versionUpdater.toast.versionAvailable",
|
||||
{ version: new_version },
|
||||
),
|
||||
duration: 6000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle browser auto-update:", error);
|
||||
|
||||
let errorMessage = "Unknown error occurred";
|
||||
let errorMessage = i18n.t("common.errors.unknown");
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
@@ -218,10 +243,15 @@ export function useVersionUpdater() {
|
||||
errorMessage = String(error.message);
|
||||
}
|
||||
|
||||
showErrorToast(`Failed to auto-update ${browserDisplayName}`, {
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
});
|
||||
showErrorToast(
|
||||
i18n.t("versionUpdater.toast.autoUpdateFailed", {
|
||||
browser: browserDisplayName,
|
||||
}),
|
||||
{
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
// Remove from active downloads
|
||||
activeDownloads.current.delete(downloadKey);
|
||||
@@ -286,18 +316,27 @@ export function useVersionUpdater() {
|
||||
).length;
|
||||
|
||||
if (failedUpdates > 0) {
|
||||
showErrorToast("Update completed with some errors", {
|
||||
description: `${totalNewVersions} new versions found, ${failedUpdates} browsers failed to update`,
|
||||
showErrorToast(i18n.t("versionUpdater.toast.updateWithErrors"), {
|
||||
description: i18n.t(
|
||||
"versionUpdater.toast.updateWithErrorsDescription",
|
||||
{
|
||||
newVersions: totalNewVersions,
|
||||
failedUpdates,
|
||||
},
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
} else if (totalNewVersions > 0) {
|
||||
showSuccessToast("Browser versions updated successfully", {
|
||||
description: `Found ${totalNewVersions} new versions across ${successfulUpdates} browsers. Auto-downloads will start shortly.`,
|
||||
showSuccessToast(i18n.t("versionUpdater.toast.updateSuccess"), {
|
||||
description: i18n.t("versionUpdater.toast.updateSuccessDescription", {
|
||||
newVersions: totalNewVersions,
|
||||
successfulUpdates,
|
||||
}),
|
||||
duration: 4000,
|
||||
});
|
||||
} else {
|
||||
showSuccessToast("No new browser versions found", {
|
||||
description: "All browser versions are up to date",
|
||||
showSuccessToast(i18n.t("versionUpdater.toast.upToDate"), {
|
||||
description: i18n.t("versionUpdater.toast.upToDateDescription"),
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
@@ -306,7 +345,7 @@ export function useVersionUpdater() {
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger manual update:", error);
|
||||
let errorMessage = "Unknown error occurred";
|
||||
let errorMessage = i18n.t("common.errors.unknown");
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
@@ -315,7 +354,7 @@ export function useVersionUpdater() {
|
||||
errorMessage = String(error.message);
|
||||
}
|
||||
|
||||
showErrorToast("Failed to update browser versions", {
|
||||
showErrorToast(i18n.t("versionUpdater.toast.updateAllFailed"), {
|
||||
description: errorMessage,
|
||||
duration: 4000,
|
||||
});
|
||||
@@ -337,10 +376,16 @@ export function useVersionUpdater() {
|
||||
if (result.new_versions_count && result.new_versions_count > 0) {
|
||||
const browserName = getBrowserDisplayName(browserStr);
|
||||
showSuccessToast(
|
||||
`Found ${result.new_versions_count} new ${browserName} versions!`,
|
||||
i18n.t("browserDownload.toast.foundNewVersions", {
|
||||
count: result.new_versions_count,
|
||||
browser: browserName,
|
||||
}),
|
||||
{
|
||||
duration: 3000,
|
||||
description: `Total available: ${result.total_versions_count} versions`,
|
||||
description: i18n.t(
|
||||
"browserDownload.toast.totalAvailableVersions",
|
||||
{ count: result.total_versions_count },
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { VpnConfig } from "@/types";
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,9 @@ export function useVpnEvents() {
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load VPN configs:", err);
|
||||
setError(`Failed to load VPN configs: ${JSON.stringify(err)}`);
|
||||
setError(
|
||||
i18n.t("errors.loadVpnConfigsFailed", { error: JSON.stringify(err) }),
|
||||
);
|
||||
}
|
||||
}, [loadVpnUsage]);
|
||||
|
||||
@@ -62,7 +65,11 @@ export function useVpnEvents() {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to setup VPN event listeners:", err);
|
||||
setError(`Failed to setup VPN event listeners: ${JSON.stringify(err)}`);
|
||||
setError(
|
||||
i18n.t("errors.setupVpnListenersFailed", {
|
||||
error: JSON.stringify(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
+711
-31
@@ -28,7 +28,9 @@
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading...",
|
||||
"saveSettings": "Save Settings",
|
||||
"moreInfo": "More info"
|
||||
"moreInfo": "More info",
|
||||
"downloading": "Downloading...",
|
||||
"minimize": "Minimize"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
@@ -56,7 +58,10 @@
|
||||
"default": "Default",
|
||||
"custom": "Custom",
|
||||
"optional": "Optional",
|
||||
"required": "Required"
|
||||
"required": "Required",
|
||||
"unknownProfile": "Unknown",
|
||||
"mode": "Mode",
|
||||
"never": "Never"
|
||||
},
|
||||
"time": {
|
||||
"days": "days",
|
||||
@@ -64,6 +69,33 @@
|
||||
"minutes": "minutes",
|
||||
"seconds": "seconds",
|
||||
"remaining": "remaining"
|
||||
},
|
||||
"aria": {
|
||||
"selectAll": "Select all",
|
||||
"selectRow": "Select row",
|
||||
"selectProfile": "Select profile",
|
||||
"copy": "Copy to clipboard",
|
||||
"copied": "Copied",
|
||||
"showToken": "Show token",
|
||||
"hideToken": "Hide token"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Escape"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": "Unknown error occurred"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Minimize"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Command Palette",
|
||||
"description": "Search for a command to run..."
|
||||
},
|
||||
"noResults": "No results found.",
|
||||
"srOnly": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -85,7 +117,8 @@
|
||||
"title": "Language",
|
||||
"description": "Choose your preferred language for the application interface.",
|
||||
"systemDefault": "System Default",
|
||||
"selectLanguage": "Select language"
|
||||
"selectLanguage": "Select language",
|
||||
"interface": "Interface Language"
|
||||
},
|
||||
"defaultBrowser": {
|
||||
"title": "Default Browser",
|
||||
@@ -100,7 +133,8 @@
|
||||
"microphone": "Microphone",
|
||||
"microphoneDescription": "Access to microphone for browser applications",
|
||||
"camera": "Camera",
|
||||
"cameraDescription": "Access to camera for browser applications"
|
||||
"cameraDescription": "Access to camera for browser applications",
|
||||
"accessRequested": "{{permission}} access requested"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
@@ -134,7 +168,8 @@
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"clearCache": "Clear All Version Cache",
|
||||
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers."
|
||||
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers.",
|
||||
"clearCacheFailed": "Failed to clear cache"
|
||||
},
|
||||
"disableAutoUpdates": "Disable App Auto Updates",
|
||||
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected."
|
||||
@@ -169,7 +204,9 @@
|
||||
"note": "Note",
|
||||
"group": "Group",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Last Launch"
|
||||
"lastLaunch": "Last Launch",
|
||||
"empty": "No profiles found.",
|
||||
"notSelected": "Not Selected"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Launch",
|
||||
@@ -205,7 +242,30 @@
|
||||
"ephemeral": "Ephemeral",
|
||||
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
|
||||
"ephemeralBadge": "Ephemeral",
|
||||
"ephemeralAlpha": "Alpha"
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Delete Selected Profiles",
|
||||
"description": "This action cannot be undone. This will permanently delete {{count}} profile(s) and all associated data.",
|
||||
"confirmButton": "Delete {{count}} Profile(s)"
|
||||
},
|
||||
"note": {
|
||||
"empty": "No Note",
|
||||
"placeholder": "Add a note..."
|
||||
},
|
||||
"aria": {
|
||||
"profileInfo": "Profile info"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete Profile",
|
||||
"description": "This action cannot be undone. This will permanently delete the profile \"{{profileName}}\" and all its associated data.",
|
||||
"confirmButton": "Delete Profile"
|
||||
},
|
||||
"actionBar": {
|
||||
"assignToGroup": "Assign to Group",
|
||||
"assignProxy": "Assign Proxy",
|
||||
"assignExtensionGroup": "Assign Extension Group",
|
||||
"copyCookies": "Copy Cookies"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Create New Profile",
|
||||
@@ -228,7 +288,10 @@
|
||||
"title": "Proxy / VPN",
|
||||
"addProxy": "Add Proxy",
|
||||
"noProxy": "No proxy / VPN",
|
||||
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic."
|
||||
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic.",
|
||||
"search": "Search proxies or VPNs...",
|
||||
"notFound": "No proxies or VPNs found.",
|
||||
"searchWithCountries": "Search proxies, VPNs, or countries..."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "Launch Hook URL",
|
||||
@@ -248,7 +311,8 @@
|
||||
"chromiumSubtitle": "Powered by Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Powered by Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium."
|
||||
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium.",
|
||||
"platformUnavailable": "{{browser}} is not available on your platform yet."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Profile",
|
||||
@@ -259,7 +323,31 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxies",
|
||||
"management": "Proxies & VPNs",
|
||||
"management": {
|
||||
"description": "Manage your proxy and VPN configurations for reuse across profiles",
|
||||
"tabProxies": "Proxies",
|
||||
"tabVpns": "VPNs",
|
||||
"create": "Create",
|
||||
"loading": "Loading proxies...",
|
||||
"noneCreated": "No proxies created yet. Create your first proxy using the button above.",
|
||||
"usage": "Usage",
|
||||
"syncCol": "Sync",
|
||||
"syncCannotDisable": "Sync cannot be disabled while this proxy is used by synced profiles",
|
||||
"enableSync": "Enable sync",
|
||||
"disableSync": "Disable sync",
|
||||
"editProxy": "Edit proxy",
|
||||
"deleteProxy": "Delete proxy",
|
||||
"cannotDelete_one": "Cannot delete: in use by {{count}} profile",
|
||||
"cannotDelete_other": "Cannot delete: in use by {{count}} profiles",
|
||||
"syncEnabled": "Sync enabled",
|
||||
"syncDisabled": "Sync disabled",
|
||||
"updateSyncFailed": "Failed to update sync",
|
||||
"deleteSuccess": "Proxy deleted successfully",
|
||||
"deleteFailed": "Failed to delete proxy",
|
||||
"deleteTitle": "Delete Proxy",
|
||||
"deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".",
|
||||
"title": "Proxies & VPNs"
|
||||
},
|
||||
"add": "Add Proxy",
|
||||
"edit": "Edit Proxy",
|
||||
"delete": "Delete Proxy",
|
||||
@@ -280,7 +368,12 @@
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Optional",
|
||||
"cipher": "Cipher",
|
||||
"cipherPlaceholder": "aes-256-gcm"
|
||||
"cipherPlaceholder": "aes-256-gcm",
|
||||
"nameRequired": "Proxy name is required",
|
||||
"hostPortRequired": "Host and port are required",
|
||||
"ssCipherRequired": "Cipher and password are required for Shadowsocks",
|
||||
"selectType": "Select proxy type",
|
||||
"saveFailed": "Failed to save proxy: {{error}}"
|
||||
},
|
||||
"types": {
|
||||
"http": "HTTP",
|
||||
@@ -318,6 +411,45 @@
|
||||
"sync": {
|
||||
"enabled": "Sync Enabled",
|
||||
"disabled": "Sync Disabled"
|
||||
},
|
||||
"exportDialog": {
|
||||
"title": "Export Proxies",
|
||||
"description": "Export your proxy configurations to a file",
|
||||
"format": "Export Format",
|
||||
"json": "JSON",
|
||||
"txt": "TXT (URL format)",
|
||||
"preview": "Preview",
|
||||
"noProxies": "No proxies to export",
|
||||
"downloaded": "Downloaded {{filename}}",
|
||||
"failed": "Failed to export proxies",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"importDialog": {
|
||||
"title": "Import Proxies",
|
||||
"descDropzone": "Import proxies from a JSON or TXT file",
|
||||
"descPreview": "Review the proxies to import",
|
||||
"descAmbiguous": "Some proxies have ambiguous formats. Please select the correct format.",
|
||||
"descResult": "Import completed",
|
||||
"dropzonePrompt": "Drop a proxy config file",
|
||||
"dropzoneFormats": "(.json, .txt)",
|
||||
"pasteHint": "Paste from clipboard with {{modKey}}+V",
|
||||
"wrongFileType": "Please drop a .json or .txt file",
|
||||
"fileReadError": "Failed to read file",
|
||||
"fileProcessError": "Failed to process file",
|
||||
"noValidProxies": "No valid proxies found in the file",
|
||||
"namePrefix": "Name Prefix",
|
||||
"namePrefixDefault": "Imported",
|
||||
"namePrefixHint": "Proxies will be named \"{{prefix}} Proxy 1\", \"{{prefix}} Proxy 2\", etc.",
|
||||
"proxiesToImport": "Proxies to import ({{count}})",
|
||||
"invalidCount": "({{count}} invalid)",
|
||||
"ambiguousIntro": "The following proxies have an ambiguous format. Please select the correct interpretation for each.",
|
||||
"imported": "Imported:",
|
||||
"skippedDuplicates": "Skipped (duplicates):",
|
||||
"errors": "Errors",
|
||||
"importButton": "Import {{count}} Proxies",
|
||||
"continueButton": "Continue",
|
||||
"doneButton": "Done",
|
||||
"failed": "Failed to import proxies"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -343,7 +475,31 @@
|
||||
"sync": {
|
||||
"enabled": "Sync Enabled",
|
||||
"disabled": "Sync Disabled"
|
||||
}
|
||||
},
|
||||
"createTitle": "Create New Group",
|
||||
"createDescription": "Create a new group to organize your browser profiles.",
|
||||
"editTitle": "Edit Group",
|
||||
"editDescription": "Update the name of your group.",
|
||||
"createSuccess": "Group created successfully",
|
||||
"createFailed": "Failed to create group",
|
||||
"updateSuccess": "Group updated successfully",
|
||||
"updateFailed": "Failed to update group",
|
||||
"deleteTitle": "Delete Group",
|
||||
"deleteDescription": "This action cannot be undone. This will permanently delete the group.",
|
||||
"deleteSuccess": "Group deleted successfully",
|
||||
"deleteFailed": "Failed to delete group",
|
||||
"loadingProfiles": "Loading associated profiles...",
|
||||
"associatedProfiles": "Associated Profiles ({{count}})",
|
||||
"whatToDoWithProfiles": "What should happen to these profiles?",
|
||||
"moveToDefaultOption": "Move profiles to Default group",
|
||||
"deleteAlongWithGroup": "Delete profiles along with the group",
|
||||
"noAssociatedProfiles": "This group has no associated profiles.",
|
||||
"deleteGroup": "Delete Group",
|
||||
"deleteGroupAndProfiles": "Delete Group & Profiles",
|
||||
"loadProfilesFailed": "Failed to load profiles",
|
||||
"unknownGroup": "Unknown Group",
|
||||
"profileGroupsAriaLabel": "Profile groups",
|
||||
"loading": "Loading groups..."
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -366,7 +522,16 @@
|
||||
"configureService": "Configure Sync Service"
|
||||
},
|
||||
"title": "Account",
|
||||
"config": "Sync Configuration",
|
||||
"config": {
|
||||
"serverUrlRequired": "Please enter a server URL",
|
||||
"connectionSuccess": "Connection successful!",
|
||||
"serverError": "Server responded with an error",
|
||||
"connectFailed": "Failed to connect to server",
|
||||
"settingsSaved": "Sync settings saved",
|
||||
"saveFailed": "Failed to save settings",
|
||||
"disconnected": "Sync disconnected",
|
||||
"disconnectFailed": "Failed to disconnect"
|
||||
},
|
||||
"serverUrl": "Server URL",
|
||||
"serverUrlPlaceholder": "https://sync.example.com",
|
||||
"token": "Sync Token",
|
||||
@@ -410,6 +575,12 @@
|
||||
"profileLockedShort": "In use",
|
||||
"cannotLaunchLocked": "Cannot launch — profile is in use by {{email}}",
|
||||
"createdBy": "Created by {{email}}"
|
||||
},
|
||||
"disabled": "Disabled",
|
||||
"toast": {
|
||||
"profileSynced": "Profile '{{name}}' synced successfully",
|
||||
"profileSyncFailed": "Failed to sync profile '{{name}}'",
|
||||
"profileSyncFailedWithError": "Failed to sync profile '{{name}}': {{error}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -447,7 +618,32 @@
|
||||
"removedFromClaudeCode": "Removed from Claude Code",
|
||||
"config": "MCP Configuration",
|
||||
"copyConfig": "Copy Configuration"
|
||||
}
|
||||
},
|
||||
"tabApi": "Local API",
|
||||
"tabMcp": "MCP (AI Assistants)",
|
||||
"apiEnableLabel": "Enable Local API Server",
|
||||
"apiEnableDescription": "Allow managing profiles, groups, and proxies via REST API.",
|
||||
"apiPortLabel": "Port",
|
||||
"apiTokenLabel": "Authentication Token",
|
||||
"apiTokenHint": "Include in Authorization header: Bearer {{tokenSlot}}",
|
||||
"apiInvalidPort": "Invalid port",
|
||||
"apiInvalidPortDescription": "Port must be between 1 and 65535",
|
||||
"apiPortInUse": "Port {{port}} is already in use",
|
||||
"apiFallbackPort": "Server started on fallback port {{port}}",
|
||||
"apiStarted": "API server started on port {{port}}",
|
||||
"apiRunning": "API server running on port {{port}}",
|
||||
"apiStopped": "API server stopped",
|
||||
"apiToggleFailed": "Failed to toggle API server",
|
||||
"apiStartFailed": "Failed to start API server",
|
||||
"apiUnknownError": "Unknown error",
|
||||
"tokenCopied": "Token copied",
|
||||
"mcpEnableLabel": "Enable MCP Server (Model Context Protocol)",
|
||||
"mcpEnableDescription": "Allow AI assistants like Claude Desktop to control browsers.",
|
||||
"mcpAcceptTermsFirst": "(Accept Wayfern terms in Settings first)",
|
||||
"mcpStarted": "MCP server started on port {{port}}",
|
||||
"mcpStopped": "MCP server stopped",
|
||||
"mcpToggleFailed": "Failed to toggle MCP server",
|
||||
"openSettings": "Open Integrations Settings"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Profile",
|
||||
@@ -465,7 +661,9 @@
|
||||
"fingerprint": {
|
||||
"title": "Fingerprint",
|
||||
"randomize": "Randomize on Launch",
|
||||
"randomizeDescription": "Generate a new fingerprint each time the browser is launched."
|
||||
"randomizeDescription": "Generate a new fingerprint each time the browser is launched.",
|
||||
"osCpuPlaceholder": "e.g., Intel Mac OS X 10.15",
|
||||
"webglRendererPlaceholder": "e.g., llvmpipe, or similar"
|
||||
},
|
||||
"os": {
|
||||
"title": "Operating System",
|
||||
@@ -499,7 +697,10 @@
|
||||
"fingerprint": {
|
||||
"title": "Fingerprint",
|
||||
"randomize": "Randomize on Launch",
|
||||
"randomizeDescription": "Generate a new fingerprint each time the browser is launched."
|
||||
"randomizeDescription": "Generate a new fingerprint each time the browser is launched.",
|
||||
"platformPlaceholder": "e.g., Win32, MacIntel, Linux x86_64",
|
||||
"timezoneOffsetPlaceholder": "e.g., 300 for EST (UTC-5)",
|
||||
"webglRendererPlaceholder": "e.g., Intel(R) HD Graphics"
|
||||
},
|
||||
"os": {
|
||||
"title": "Operating System",
|
||||
@@ -522,6 +723,10 @@
|
||||
"webrtc": "Block WebRTC",
|
||||
"webgl": "Block WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Browser Behavior",
|
||||
"allowAddonsOpenTabs": "Allow browser addons to open new tabs automatically"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -534,13 +739,53 @@
|
||||
"selectCookies": "Select Cookies",
|
||||
"allDomains": "All Domains",
|
||||
"selectedCount": "{{count}} cookie selected",
|
||||
"selectedCount_plural": "{{count}} cookies selected"
|
||||
"selectedCount_plural": "{{count}} cookies selected",
|
||||
"dialogDescription_one": "Copy cookies from a source profile to {{count}} selected profile.",
|
||||
"dialogDescription_other": "Copy cookies from a source profile to {{count}} selected profiles.",
|
||||
"sourceProfile": "Source Profile",
|
||||
"sourcePlaceholder": "Select a profile to copy cookies from",
|
||||
"running": "(running)",
|
||||
"targetProfiles": "Target Profiles ({{count}})",
|
||||
"noOtherTargets": "No other Wayfern/Camoufox profiles selected",
|
||||
"selectSourceFirst": "Select a source profile first",
|
||||
"selectionStatus": "({{selected}} of {{total}} selected)",
|
||||
"searchPlaceholder": "Search domains or cookies...",
|
||||
"noMatching": "No matching cookies found",
|
||||
"noFound": "No cookies found",
|
||||
"replaceNote": "Existing cookies with the same name and domain will be replaced. Other cookies will be kept.",
|
||||
"cannotCopyRunningOne": "Cannot copy cookies: {{names}} is still running",
|
||||
"cannotCopyRunningMany": "Cannot copy cookies: {{names}} are still running",
|
||||
"someErrors": "Some errors occurred: {{errors}}",
|
||||
"successMessage": "Successfully copied {{copied}} cookies ({{replaced}} replaced)",
|
||||
"failedMessage": "Failed to copy cookies: {{error}}",
|
||||
"copyButton_one": "Copy {{count}} Cookie",
|
||||
"copyButton_other": "Copy {{count}} Cookies",
|
||||
"copyButtonEmpty": "Copy Cookies"
|
||||
},
|
||||
"success": "Cookies copied successfully",
|
||||
"error": "Failed to copy cookies",
|
||||
"management": {
|
||||
"title": "Cookie Management",
|
||||
"menuItem": "Cookie Management"
|
||||
"menuItem": "Cookie Management",
|
||||
"tabImport": "Import",
|
||||
"tabExport": "Export",
|
||||
"importDescription": "Import cookies from a Netscape or JSON format file.",
|
||||
"dropPrompt": "Click to choose a cookie file",
|
||||
"fileFormats": "(.txt, .cookies, or .json)",
|
||||
"cookiesFound": "{{count}} cookies found",
|
||||
"importedSuccess": "Successfully imported {{imported}} cookies ({{replaced}} replaced)",
|
||||
"linesSkipped": "{{count}} line(s) skipped",
|
||||
"fileReadError": "Failed to read file",
|
||||
"loadFailed": "Failed to load cookies: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
"selectionStatus": "({{selected}} of {{total}} selected)",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"noCookies": "No cookies found in this profile",
|
||||
"doneButton": "Done",
|
||||
"importButton": "Import",
|
||||
"exportButton": "Export",
|
||||
"backButton": "Back"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Cookies",
|
||||
@@ -623,7 +868,31 @@
|
||||
"maxLength": "Must be at most {{max}} characters",
|
||||
"networkError": "Network error. Please check your connection.",
|
||||
"serverError": "Server error. Please try again later.",
|
||||
"unknownError": "An unknown error occurred. Please try again."
|
||||
"unknownError": "An unknown error occurred. Please try again.",
|
||||
"noProfilesForUrl": "No profiles available. Please create a profile first before opening URLs.",
|
||||
"updateCamoufoxConfigFailed": "Failed to update camoufox config: {{error}}",
|
||||
"updateWayfernConfigFailed": "Failed to update wayfern config: {{error}}",
|
||||
"createProfileFailed": "Failed to create profile: {{error}}",
|
||||
"launchBrowserFailed": "Failed to launch browser: {{error}}",
|
||||
"cannotDeleteRunningProfile": "Cannot delete profile while browser is running. Please stop the browser first.",
|
||||
"deleteProfileFailed": "Failed to delete profile: {{error}}",
|
||||
"renameProfileFailed": "Failed to rename profile: {{error}}",
|
||||
"killBrowserFailed": "Failed to kill browser: {{error}}",
|
||||
"deleteSelectedProfilesFailed": "Failed to delete selected profiles: {{error}}",
|
||||
"cookieCopyUnsupportedBrowser": "Cookie copy only works with Wayfern and Camoufox profiles",
|
||||
"updateSyncSettingsFailed": "Failed to update sync settings",
|
||||
"cloneProfileFailed": "Failed to clone profile: {{error}}",
|
||||
"loadSupportedBrowsersFailed": "Failed to load supported browsers",
|
||||
"setupExtensionListenersFailed": "Failed to setup extension event listeners: {{error}}",
|
||||
"loadGroupsFailed": "Failed to load groups: {{error}}",
|
||||
"setupGroupListenersFailed": "Failed to setup group event listeners: {{error}}",
|
||||
"loadProfilesFailed": "Failed to load profiles: {{error}}",
|
||||
"setupProfileListenersFailed": "Failed to setup profile event listeners: {{error}}",
|
||||
"loadProxiesFailed": "Failed to load proxies: {{error}}",
|
||||
"setupProxyListenersFailed": "Failed to setup proxy event listeners: {{error}}",
|
||||
"loadVpnConfigsFailed": "Failed to load VPN configs: {{error}}",
|
||||
"setupVpnListenersFailed": "Failed to setup VPN event listeners: {{error}}",
|
||||
"themeNotFound": "Tokyo Night theme not found"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -729,7 +998,10 @@
|
||||
"brandVersion": "Brand Version",
|
||||
"proFeature": "This is a Pro feature",
|
||||
"generateFingerprint": "Generate Fingerprint",
|
||||
"refreshFingerprint": "Refresh Fingerprint"
|
||||
"refreshFingerprint": "Refresh Fingerprint",
|
||||
"canvasNoiseSeedPlaceholder": "Enter a seed string for canvas fingerprint",
|
||||
"addFontsPlaceholder": "Add fonts...",
|
||||
"enterAsJson": "Enter {{title}} as JSON"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Custom Window Dimensions",
|
||||
@@ -812,16 +1084,6 @@
|
||||
"button": "Clone"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN is not installed",
|
||||
"openVpnMissingDescription": "You can save this configuration, but Donut Browser cannot connect it until OpenVPN is installed on this device.",
|
||||
"openVpnAdapterMissingTitle": "OpenVPN adapter is missing",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN is installed, but no TAP/Wintun/ovpn-dco adapter was found. Repair or reinstall OpenVPN before connecting on Windows.",
|
||||
"openVpnCheckFailedTitle": "OpenVPN install could not be verified",
|
||||
"openVpnCheckFailedDescription": "Donut Browser could not inspect the local OpenVPN installation. Repair or reinstall OpenVPN before connecting on Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"description": "Manage browser extensions and extension groups for your profiles.",
|
||||
@@ -879,7 +1141,9 @@
|
||||
"syncEnabled": "Sync enabled",
|
||||
"syncDisabled": "Sync disabled",
|
||||
"syncEnableTooltip": "Enable sync",
|
||||
"syncDisableTooltip": "Disable sync"
|
||||
"syncDisableTooltip": "Disable sync",
|
||||
"loadGroupsFailed": "Failed to load extension groups",
|
||||
"assignGroupFailed": "Failed to assign extension group"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
@@ -905,5 +1169,421 @@
|
||||
"fresh": "Fresh",
|
||||
"stale": "Stale",
|
||||
"notCached": "Not cached"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Edit VPN",
|
||||
"titleCreate": "Create WireGuard VPN",
|
||||
"descEdit": "Update the name of your VPN configuration.",
|
||||
"descCreate": "Enter your WireGuard interface and peer details.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. Home WireGuard",
|
||||
"privateKey": "Private Key",
|
||||
"privateKeyPlaceholder": "Base64-encoded private key",
|
||||
"address": "Address",
|
||||
"addressPlaceholder": "e.g. 10.0.0.2/24",
|
||||
"dnsOptional": "DNS (optional)",
|
||||
"dnsPlaceholder": "e.g. 1.1.1.1",
|
||||
"mtuOptional": "MTU (optional)",
|
||||
"mtuPlaceholder": "e.g. 1420",
|
||||
"peerPublicKey": "Peer Public Key",
|
||||
"peerPublicKeyPlaceholder": "Base64-encoded peer public key",
|
||||
"peerEndpoint": "Peer Endpoint",
|
||||
"peerEndpointPlaceholder": "e.g. vpn.example.com:51820",
|
||||
"allowedIps": "Allowed IPs",
|
||||
"allowedIpsPlaceholder": "e.g. 0.0.0.0/0, ::/0",
|
||||
"keepaliveOptional": "Persistent Keepalive (optional)",
|
||||
"keepalivePlaceholder": "e.g. 25",
|
||||
"presharedKeyOptional": "Preshared Key (optional)",
|
||||
"presharedKeyPlaceholder": "Base64-encoded preshared key",
|
||||
"updateButton": "Update VPN",
|
||||
"createButton": "Create VPN",
|
||||
"nameRequired": "VPN name is required",
|
||||
"privateKeyRequired": "Private key is required",
|
||||
"addressRequired": "Address is required",
|
||||
"peerPublicKeyRequired": "Peer public key is required",
|
||||
"peerEndpointRequired": "Peer endpoint is required",
|
||||
"updated": "VPN updated successfully",
|
||||
"created": "WireGuard VPN created successfully",
|
||||
"updateFailed": "Failed to update VPN: {{error}}",
|
||||
"createFailed": "Failed to create VPN: {{error}}"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import VPN Config",
|
||||
"descDropzone": "Import a WireGuard (.conf) configuration file",
|
||||
"descPreview": "Review the VPN configuration to import",
|
||||
"descResult": "VPN import completed",
|
||||
"dropzonePrompt": "Drop a WireGuard .conf file here or click to browse",
|
||||
"pasteHint": "Paste from clipboard with {{modKey}}+V",
|
||||
"invalidContent": "Content does not appear to be a valid VPN configuration",
|
||||
"fileReadError": "Failed to read file",
|
||||
"wrongFileType": "Please drop a WireGuard .conf file",
|
||||
"configurationLabel": "{{type}} Configuration",
|
||||
"endpointLabel": "Endpoint: {{endpoint}}",
|
||||
"vpnNameLabel": "VPN Name",
|
||||
"vpnNamePlaceholder": "My VPN",
|
||||
"configPreview": "Config Preview",
|
||||
"importedSuccess": "VPN Imported Successfully",
|
||||
"importFailed": "Import Failed",
|
||||
"importButton": "Import VPN",
|
||||
"doneButton": "Done",
|
||||
"failedGeneric": "Failed to import VPN config",
|
||||
"defaultName": "{{type}} VPN"
|
||||
},
|
||||
"management": {
|
||||
"loading": "Loading VPNs...",
|
||||
"noneCreated": "No VPN configs created yet. Import or create one using the buttons above.",
|
||||
"editVpn": "Edit VPN",
|
||||
"deleteVpn": "Delete VPN",
|
||||
"cannotDelete_one": "Cannot delete: in use by {{count}} profile",
|
||||
"cannotDelete_other": "Cannot delete: in use by {{count}} profiles",
|
||||
"syncCannotDisable": "Sync cannot be disabled while this VPN is used by synced profiles",
|
||||
"deleteSuccess": "VPN deleted successfully",
|
||||
"deleteFailed": "Failed to delete VPN",
|
||||
"deleteTitle": "Delete VPN",
|
||||
"deleteDescription": "This action cannot be undone. This will permanently delete the VPN \"{{name}}\"."
|
||||
}
|
||||
},
|
||||
"importProfile": {
|
||||
"title": "Import Browser Profile",
|
||||
"autoDetect": "Auto-Detect",
|
||||
"manualImport": "Manual Import",
|
||||
"detectedProfilesTitle": "Detected Browser Profiles",
|
||||
"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",
|
||||
"examplePaths": "Example paths:",
|
||||
"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."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Syncing...",
|
||||
"syncedAt": "Synced {{time}}",
|
||||
"synced": "Synced",
|
||||
"waiting": "Waiting to sync",
|
||||
"errorWith": "Sync error: {{error}}",
|
||||
"error": "Sync error",
|
||||
"notSynced": "Not synced"
|
||||
},
|
||||
"groupManagement": {
|
||||
"description": "Manage your profile groups",
|
||||
"createGroup": "Create Group",
|
||||
"noGroups": "No groups created yet. Create your first group using the button above.",
|
||||
"loading": "Loading groups...",
|
||||
"profileCount_one": "{{count}} profile",
|
||||
"profileCount_other": "{{count}} profiles",
|
||||
"groupsLabel": "Groups",
|
||||
"profilesCol": "Profiles",
|
||||
"syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles",
|
||||
"editGroupTooltip": "Edit group",
|
||||
"deleteGroupTooltip": "Delete group",
|
||||
"loadFailed": "Failed to load groups"
|
||||
},
|
||||
"proxyAssignment": {
|
||||
"title": "Assign Proxy / VPN",
|
||||
"description_one": "Assign a proxy or VPN to {{count}} selected profile.",
|
||||
"description_other": "Assign a proxy or VPN to {{count}} selected profiles.",
|
||||
"selectLabel": "Proxy / VPN",
|
||||
"placeholder": "Select a proxy or VPN",
|
||||
"noProxy": "No proxy / VPN",
|
||||
"searchPlaceholder": "Search proxies or VPNs...",
|
||||
"notFound": "No proxies or VPNs found.",
|
||||
"assignButton": "Assign",
|
||||
"success": "Successfully assigned proxy/VPN to {{count}} profile(s)",
|
||||
"failed": "Failed to assign proxy/VPN",
|
||||
"selectedProfilesLabel": "Selected Profiles:",
|
||||
"assignProxyVpnLabel": "Assign Proxy / VPN:",
|
||||
"noneOption": "None",
|
||||
"noValidProfiles": "No valid profiles selected.",
|
||||
"vpnGroupHeading": "VPNs",
|
||||
"failedFallback": "Failed to assign proxy/VPN to profiles"
|
||||
},
|
||||
"groupAssignment": {
|
||||
"title": "Assign Group",
|
||||
"description_one": "Assign a group to {{count}} selected profile.",
|
||||
"description_other": "Assign a group to {{count}} selected profiles.",
|
||||
"selectLabel": "Group",
|
||||
"placeholder": "Select a group",
|
||||
"noGroup": "No Group (Default)",
|
||||
"assignButton": "Assign",
|
||||
"success": "Successfully assigned group to {{count}} profile(s)",
|
||||
"failed": "Failed to assign group",
|
||||
"selectedProfilesLabel": "Selected Profiles:",
|
||||
"assignGroupLabel": "Assign to Group:",
|
||||
"noValidProfiles": "No valid profiles selected.",
|
||||
"failedFallback": "Failed to assign group to profiles"
|
||||
},
|
||||
"profileSelector": {
|
||||
"title": "Select Profile",
|
||||
"description": "Choose a profile to launch with this URL",
|
||||
"searchPlaceholder": "Search profiles...",
|
||||
"noProfiles": "No profiles available",
|
||||
"noResults": "No profiles match your search",
|
||||
"selectButton": "Select",
|
||||
"launching": "Launching...",
|
||||
"chooseProfileTitle": "Choose Profile",
|
||||
"openingUrl": "Opening URL:",
|
||||
"urlCopied": "URL copied to clipboard!",
|
||||
"selectProfileLabel": "Select Profile:",
|
||||
"noneAvailableShort": "No profiles available. Please create a profile first.",
|
||||
"noneAvailableLong": "Close this dialog and create a profile from the main window to get started.",
|
||||
"chooseAProfile": "Choose a profile",
|
||||
"badgeProxy": "Proxy",
|
||||
"badgeRunning": "Running",
|
||||
"badgeUnavailable": "Unavailable",
|
||||
"openButton": "Open"
|
||||
},
|
||||
"locationProxy": {
|
||||
"title": "Quick Location Proxy",
|
||||
"description": "Choose a country to route this profile through. A proxy will be created automatically.",
|
||||
"country": "Country",
|
||||
"selectCountry": "Select a country",
|
||||
"searchCountry": "Search country...",
|
||||
"noCountriesFound": "No countries found.",
|
||||
"apply": "Apply",
|
||||
"creating": "Creating proxy...",
|
||||
"success": "Location proxy applied",
|
||||
"failed": "Failed to apply location proxy",
|
||||
"titleCreate": "Create Location Proxy",
|
||||
"descriptionCreate": "Create a geo-targeted proxy with a 24-hour sticky session",
|
||||
"countryLabel": "Country (required)",
|
||||
"regionLabel": "Region (optional)",
|
||||
"cityLabel": "City (optional)",
|
||||
"ispLabel": "ISP (optional)",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Proxy name",
|
||||
"loadingCountries": "Loading countries...",
|
||||
"selectCountryPh": "Select country",
|
||||
"searchCountries": "Search countries...",
|
||||
"loadFailed": "Failed to load countries",
|
||||
"selectCountryFirst": "Select a country first",
|
||||
"loadingRegions": "Loading regions...",
|
||||
"noRegions": "No regions available",
|
||||
"selectRegion": "Select region",
|
||||
"searchRegions": "Search regions...",
|
||||
"loadingCities": "Loading cities...",
|
||||
"noCities": "No cities available",
|
||||
"selectCity": "Select city",
|
||||
"searchCities": "Search cities...",
|
||||
"loadingIsps": "Loading ISPs...",
|
||||
"noIsps": "No ISPs available",
|
||||
"selectIsp": "Select ISP",
|
||||
"searchIsps": "Search ISPs...",
|
||||
"createSuccess": "Location proxy created",
|
||||
"createFailed": "Failed to create location proxy",
|
||||
"creatingButton": "Creating...",
|
||||
"createButton": "Create"
|
||||
},
|
||||
"launchOnLogin": {
|
||||
"title": "Enable Launch on Login?",
|
||||
"description": "Running in the background helps keep your proxies and browsers alive.",
|
||||
"declineButton": "Don't Ask Again",
|
||||
"declining": "...",
|
||||
"enableButton": "Enable",
|
||||
"enableSuccess": "Launch on login enabled",
|
||||
"enableFailed": "Failed to enable launch on login",
|
||||
"declineFailed": "Failed to save preference",
|
||||
"tryAgain": "Please try again"
|
||||
},
|
||||
"wayfernTerms": {
|
||||
"title": "Wayfern Terms and Conditions",
|
||||
"description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.",
|
||||
"reviewLabel": "Please review the Terms and Conditions at:",
|
||||
"agreeNotice": "By clicking \"I Accept\", you agree to be bound by these terms.",
|
||||
"acceptButton": "I Accept",
|
||||
"acceptSuccess": "Terms accepted successfully",
|
||||
"acceptFailed": "Failed to accept terms",
|
||||
"tryAgain": "Please try again"
|
||||
},
|
||||
"commercialTrial": {
|
||||
"title": "Commercial Trial Expired",
|
||||
"description": "Your 2-week commercial trial period has ended.",
|
||||
"body": "If you are using Donut Browser for business purposes, you need to purchase a commercial license to continue. You can still use it for personal use for free.",
|
||||
"understandButton": "I Understand",
|
||||
"failed": "Failed to save acknowledgment",
|
||||
"tryAgain": "Please try again"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"titleMicrophone": "Microphone Access Required",
|
||||
"titleCamera": "Camera Access Required",
|
||||
"descMicrophone": "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually.",
|
||||
"descCamera": "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually.",
|
||||
"grantedMicrophone": "Permission granted! Browsers launched from Donut Browser can now access your microphone.",
|
||||
"grantedCamera": "Permission granted! Browsers launched from Donut Browser can now access your camera.",
|
||||
"notGrantedMicrophone": "Permission not granted. Click the button below to request access to your microphone.",
|
||||
"notGrantedCamera": "Permission not granted. Click the button below to request access to your camera.",
|
||||
"doneButton": "Done",
|
||||
"cancelButton": "Cancel",
|
||||
"grantAccessButton": "Grant Access",
|
||||
"requestSuccessMicrophone": "Microphone Access permission requested",
|
||||
"requestSuccessCamera": "Camera Access permission requested",
|
||||
"requestFailed": "Failed to request permission"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Traffic Details",
|
||||
"bandwidthOverTime": "Bandwidth Over Time",
|
||||
"timePeriodPlaceholder": "Time period",
|
||||
"last1m": "Last 1 min",
|
||||
"last5m": "Last 5 min",
|
||||
"last30m": "Last 30 min",
|
||||
"last1h": "Last 1 hour",
|
||||
"last2h": "Last 2 hours",
|
||||
"last4h": "Last 4 hours",
|
||||
"last1d": "Last 1 day",
|
||||
"last7d": "Last 7 days",
|
||||
"last30d": "Last 30 days",
|
||||
"allTime": "All time",
|
||||
"allTimeShort": "all time",
|
||||
"totalSuffix": "total",
|
||||
"sentLabel": "Sent ({{period}})",
|
||||
"receivedLabel": "Received ({{period}})",
|
||||
"requestsLabel": "Requests ({{period}})",
|
||||
"allTimeTraffic": "All-time traffic:",
|
||||
"allTimeRequests": "All-time requests:",
|
||||
"proxyDisclaimer": "Note: If you are using a proxy, VPN, or similar service, your provider may calculate traffic differently due to encryption overhead and protocol differences.",
|
||||
"topByTraffic": "Top Domains by Traffic ({{period}})",
|
||||
"topByRequests": "Top Domains by Requests ({{period}})",
|
||||
"columnDomain": "Domain",
|
||||
"columnRequests": "Requests",
|
||||
"columnSent": "Sent",
|
||||
"columnReceived": "Received",
|
||||
"columnTotal": "Total Traffic",
|
||||
"uniqueIps": "Unique IPs ({{count}})",
|
||||
"noData": "No traffic data available for this profile.",
|
||||
"noDataHint": "Traffic data will appear after you launch the profile.",
|
||||
"sentLegend": "Sent",
|
||||
"receivedLegend": "Received",
|
||||
"tooltipSent": "↑ Sent: ",
|
||||
"tooltipReceived": "↓ Received: "
|
||||
},
|
||||
"camoufoxDialog": {
|
||||
"titleView": "View Fingerprint Settings - {{name}} ({{browser}})",
|
||||
"titleConfigure": "Configure Fingerprint Settings - {{name}} ({{browser}})",
|
||||
"invalidFingerprint": "Invalid fingerprint configuration",
|
||||
"invalidFingerprintDescription": "The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"unknownError": "Unknown error occurred"
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Unknown",
|
||||
"locationToast": "Your proxy location is:",
|
||||
"failed": "Proxy check failed: {{error}}",
|
||||
"tooltipChecking": "Checking proxy...",
|
||||
"tooltipIp": "IP: {{ip}}",
|
||||
"tooltipChecked": "Checked {{time}}",
|
||||
"tooltipFailed": "Failed {{time}}",
|
||||
"tooltipFailedTitle": "Proxy check failed",
|
||||
"tooltipDefault": "Check proxy validity"
|
||||
},
|
||||
"vpnCheck": {
|
||||
"valid": "VPN \"{{name}}\" configuration is valid",
|
||||
"invalid": "VPN \"{{name}}\" configuration is invalid",
|
||||
"failed": "VPN check failed: {{error}}",
|
||||
"tooltipChecking": "Checking VPN config...",
|
||||
"tooltipValid": "Configuration valid",
|
||||
"tooltipInvalid": "Configuration invalid",
|
||||
"tooltipChecked": "Checked {{time}}",
|
||||
"tooltipDefault": "Check VPN config validity"
|
||||
},
|
||||
"profileTable": {
|
||||
"syncTooltipDisabled": "Sync disabled",
|
||||
"syncTooltipSyncing": "Syncing...",
|
||||
"syncTooltipSyncedAt": "Synced {{time}}",
|
||||
"syncTooltipSynced": "Synced",
|
||||
"syncTooltipWaiting": "Waiting to sync",
|
||||
"syncTooltipErrorWith": "Sync error: {{error}}",
|
||||
"syncTooltipError": "Sync error",
|
||||
"syncTooltipNotSynced": "Not synced",
|
||||
"noTags": "No tags",
|
||||
"syncTooltipCloseToSync": "Close the profile to sync",
|
||||
"syncTooltipDisabledWithLast": "Sync disabled, last sync {{time}}",
|
||||
"addTagsPlaceholder": "Add tags",
|
||||
"tagsHeader": "Tags",
|
||||
"noteHeader": "Note",
|
||||
"vpnsHeading": "VPNs",
|
||||
"createByCountryHeading": "Create by country"
|
||||
},
|
||||
"releaseTypeSelector": {
|
||||
"noReleaseTypes": "No release types available.",
|
||||
"placeholder": "Select release type...",
|
||||
"stable": "Stable",
|
||||
"nightly": "Nightly",
|
||||
"downloaded": "Downloaded",
|
||||
"downloadBrowser": "Download Browser",
|
||||
"downloading": "Downloading..."
|
||||
},
|
||||
"dataTableActionBar": {
|
||||
"selected": "{{count}} selected",
|
||||
"clearSelection": "Clear selection"
|
||||
},
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"updateFailed": "Failed to update Donut Browser",
|
||||
"restartFailed": "Failed to restart",
|
||||
"updateReady": "Update ready, restart to apply",
|
||||
"manualDownloadRequired": "Manual download required",
|
||||
"restartNow": "Restart Now",
|
||||
"viewRelease": "View Release",
|
||||
"later": "Later",
|
||||
"uploading": "Uploading",
|
||||
"downloading": "Downloading"
|
||||
}
|
||||
},
|
||||
"browserDownload": {
|
||||
"toast": {
|
||||
"fetchVersionsFailed": "Failed to fetch {{browser}} versions",
|
||||
"foundNewVersions": "Found {{count}} new {{browser}} versions!",
|
||||
"totalAvailableVersions": "Total available: {{count}} versions",
|
||||
"downloadFailed": "Failed to download {{browser}} {{version}}",
|
||||
"calculating": "calculating...",
|
||||
"extractionFailed": "{{browser}} {{version}}: extraction failed",
|
||||
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.",
|
||||
"extracting": "Extracting browser files... Please do not close the app.",
|
||||
"verifying": "Verifying browser files...",
|
||||
"downloadingRolling": "Downloading rolling release build..."
|
||||
}
|
||||
},
|
||||
"versionUpdater": {
|
||||
"toast": {
|
||||
"alreadyAvailable": "{{browser}} {{version}} already available",
|
||||
"updatingProfiles": "Updating profile configurations...",
|
||||
"updateCompleted": "{{browser}} update completed",
|
||||
"singleProfileUpdated": "Profile \"{{name}}\" has been updated to version {{version}}. You can now launch your browsers with the latest version.",
|
||||
"multipleProfilesUpdated": "{{count}} profiles have been updated to version {{version}}. You can now launch your browsers with the latest version.",
|
||||
"versionAvailable": "Version {{version}} is now available. Running profiles will use the new version when restarted.",
|
||||
"autoUpdateFailed": "Failed to auto-update {{browser}}",
|
||||
"updateWithErrors": "Update completed with some errors",
|
||||
"updateWithErrorsDescription": "{{newVersions}} new versions found, {{failedUpdates}} browsers failed to update",
|
||||
"updateSuccess": "Browser versions updated successfully",
|
||||
"updateSuccessDescription": "Found {{newVersions}} new versions across {{successfulUpdates}} browsers. Auto-downloads will start shortly.",
|
||||
"upToDate": "No new browser versions found",
|
||||
"upToDateDescription": "All browser versions are up to date",
|
||||
"updateAllFailed": "Failed to update browser versions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+725
-45
@@ -28,7 +28,9 @@
|
||||
"refresh": "Actualizar",
|
||||
"loading": "Cargando...",
|
||||
"saveSettings": "Guardar Configuración",
|
||||
"moreInfo": "Más información"
|
||||
"moreInfo": "Más información",
|
||||
"downloading": "Descargando...",
|
||||
"minimize": "Minimizar"
|
||||
},
|
||||
"status": {
|
||||
"active": "Activo",
|
||||
@@ -56,7 +58,10 @@
|
||||
"default": "Predeterminado",
|
||||
"custom": "Personalizado",
|
||||
"optional": "Opcional",
|
||||
"required": "Requerido"
|
||||
"required": "Requerido",
|
||||
"unknownProfile": "Desconocido",
|
||||
"mode": "Modo",
|
||||
"never": "Nunca"
|
||||
},
|
||||
"time": {
|
||||
"days": "días",
|
||||
@@ -64,6 +69,33 @@
|
||||
"minutes": "minutos",
|
||||
"seconds": "segundos",
|
||||
"remaining": "restantes"
|
||||
},
|
||||
"aria": {
|
||||
"selectAll": "Seleccionar todo",
|
||||
"selectRow": "Seleccionar fila",
|
||||
"selectProfile": "Seleccionar perfil",
|
||||
"copy": "Copiar al portapapeles",
|
||||
"copied": "Copiado",
|
||||
"showToken": "Mostrar token",
|
||||
"hideToken": "Ocultar token"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Escape"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": "Ocurrió un error desconocido"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Minimizar"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Paleta de comandos",
|
||||
"description": "Busca un comando para ejecutar..."
|
||||
},
|
||||
"noResults": "No se encontraron resultados.",
|
||||
"srOnly": {
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -85,7 +117,8 @@
|
||||
"title": "Idioma",
|
||||
"description": "Elige tu idioma preferido para la interfaz de la aplicación.",
|
||||
"systemDefault": "Predeterminado del Sistema",
|
||||
"selectLanguage": "Seleccionar idioma"
|
||||
"selectLanguage": "Seleccionar idioma",
|
||||
"interface": "Idioma de la interfaz"
|
||||
},
|
||||
"defaultBrowser": {
|
||||
"title": "Navegador Predeterminado",
|
||||
@@ -100,7 +133,8 @@
|
||||
"microphone": "Micrófono",
|
||||
"microphoneDescription": "Acceso al micrófono para aplicaciones del navegador",
|
||||
"camera": "Cámara",
|
||||
"cameraDescription": "Acceso a la cámara para aplicaciones del navegador"
|
||||
"cameraDescription": "Acceso a la cámara para aplicaciones del navegador",
|
||||
"accessRequested": "Acceso a {{permission}} solicitado"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integraciones",
|
||||
@@ -134,7 +168,8 @@
|
||||
"advanced": {
|
||||
"title": "Avanzado",
|
||||
"clearCache": "Limpiar Toda la Caché de Versiones",
|
||||
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores."
|
||||
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores.",
|
||||
"clearCacheFailed": "Error al limpiar la caché"
|
||||
},
|
||||
"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."
|
||||
@@ -169,7 +204,9 @@
|
||||
"note": "Nota",
|
||||
"group": "Grupo",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Último Inicio"
|
||||
"lastLaunch": "Último Inicio",
|
||||
"empty": "No se encontraron perfiles.",
|
||||
"notSelected": "No seleccionado"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -205,7 +242,30 @@
|
||||
"ephemeral": "Efímero",
|
||||
"ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.",
|
||||
"ephemeralBadge": "Efímero",
|
||||
"ephemeralAlpha": "Alpha"
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Eliminar perfiles seleccionados",
|
||||
"description": "Esta acción no se puede deshacer. Eliminará permanentemente {{count}} perfil(es) y todos los datos asociados.",
|
||||
"confirmButton": "Eliminar {{count}} perfil(es)"
|
||||
},
|
||||
"note": {
|
||||
"empty": "Sin nota",
|
||||
"placeholder": "Añadir una nota..."
|
||||
},
|
||||
"aria": {
|
||||
"profileInfo": "Información del perfil"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar perfil",
|
||||
"description": "Esta acción no se puede deshacer. Eliminará permanentemente el perfil \"{{profileName}}\" y todos sus datos asociados.",
|
||||
"confirmButton": "Eliminar perfil"
|
||||
},
|
||||
"actionBar": {
|
||||
"assignToGroup": "Asignar a grupo",
|
||||
"assignProxy": "Asignar proxy",
|
||||
"assignExtensionGroup": "Asignar grupo de extensiones",
|
||||
"copyCookies": "Copiar cookies"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Crear Nuevo Perfil",
|
||||
@@ -228,7 +288,10 @@
|
||||
"title": "Proxy / VPN",
|
||||
"addProxy": "Agregar Proxy",
|
||||
"noProxy": "Sin proxy / VPN",
|
||||
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil."
|
||||
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil.",
|
||||
"search": "Buscar proxies o VPN...",
|
||||
"notFound": "No se encontraron proxies o VPN.",
|
||||
"searchWithCountries": "Buscar proxies, VPN o países..."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "URL del hook de inicio",
|
||||
@@ -248,7 +311,8 @@
|
||||
"chromiumSubtitle": "Impulsado por Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Impulsado por Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium."
|
||||
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium.",
|
||||
"platformUnavailable": "{{browser}} aún no está disponible en tu plataforma."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Eliminar Perfil",
|
||||
@@ -259,7 +323,31 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxies",
|
||||
"management": "Proxies y VPNs",
|
||||
"management": {
|
||||
"description": "Administra tus configuraciones de proxy y VPN para reutilizarlas en los perfiles",
|
||||
"tabProxies": "Proxies",
|
||||
"tabVpns": "VPN",
|
||||
"create": "Crear",
|
||||
"loading": "Cargando proxies...",
|
||||
"noneCreated": "Aún no hay proxies. Crea tu primer proxy usando el botón de arriba.",
|
||||
"usage": "Uso",
|
||||
"syncCol": "Sincronizar",
|
||||
"syncCannotDisable": "No se puede desactivar la sincronización mientras este proxy esté en uso por perfiles sincronizados",
|
||||
"enableSync": "Activar sincronización",
|
||||
"disableSync": "Desactivar sincronización",
|
||||
"editProxy": "Editar proxy",
|
||||
"deleteProxy": "Eliminar proxy",
|
||||
"cannotDelete_one": "No se puede eliminar: en uso por {{count}} perfil",
|
||||
"cannotDelete_other": "No se puede eliminar: en uso por {{count}} perfiles",
|
||||
"syncEnabled": "Sincronización activada",
|
||||
"syncDisabled": "Sincronización desactivada",
|
||||
"updateSyncFailed": "Error al actualizar la sincronización",
|
||||
"deleteSuccess": "Proxy eliminado correctamente",
|
||||
"deleteFailed": "Error al eliminar el proxy",
|
||||
"deleteTitle": "Eliminar proxy",
|
||||
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente el proxy \"{{name}}\".",
|
||||
"title": "Proxies y VPN"
|
||||
},
|
||||
"add": "Agregar Proxy",
|
||||
"edit": "Editar Proxy",
|
||||
"delete": "Eliminar Proxy",
|
||||
@@ -280,7 +368,12 @@
|
||||
"password": "Contraseña",
|
||||
"passwordPlaceholder": "Opcional",
|
||||
"cipher": "Cifrado",
|
||||
"cipherPlaceholder": "aes-256-gcm"
|
||||
"cipherPlaceholder": "aes-256-gcm",
|
||||
"nameRequired": "El nombre del proxy es obligatorio",
|
||||
"hostPortRequired": "Host y puerto son obligatorios",
|
||||
"ssCipherRequired": "Para Shadowsocks se requieren cifrado y contraseña",
|
||||
"selectType": "Selecciona el tipo de proxy",
|
||||
"saveFailed": "Error al guardar el proxy: {{error}}"
|
||||
},
|
||||
"types": {
|
||||
"http": "HTTP",
|
||||
@@ -318,6 +411,45 @@
|
||||
"sync": {
|
||||
"enabled": "Sincronización Habilitada",
|
||||
"disabled": "Sincronización Deshabilitada"
|
||||
},
|
||||
"exportDialog": {
|
||||
"title": "Exportar proxies",
|
||||
"description": "Exporta tus configuraciones de proxy a un archivo",
|
||||
"format": "Formato de exportación",
|
||||
"json": "JSON",
|
||||
"txt": "TXT (formato URL)",
|
||||
"preview": "Vista previa",
|
||||
"noProxies": "No hay proxies para exportar",
|
||||
"downloaded": "{{filename}} descargado",
|
||||
"failed": "Error al exportar los proxies",
|
||||
"copied": "Copiado"
|
||||
},
|
||||
"importDialog": {
|
||||
"title": "Importar proxies",
|
||||
"descDropzone": "Importar proxies desde un archivo JSON o TXT",
|
||||
"descPreview": "Revisa los proxies a importar",
|
||||
"descAmbiguous": "Algunos proxies tienen formatos ambiguos. Selecciona el formato correcto.",
|
||||
"descResult": "Importación completada",
|
||||
"dropzonePrompt": "Suelta un archivo de configuración de proxy",
|
||||
"dropzoneFormats": "(.json, .txt)",
|
||||
"pasteHint": "Pega desde el portapapeles con {{modKey}}+V",
|
||||
"wrongFileType": "Por favor, suelta un archivo .json o .txt",
|
||||
"fileReadError": "Error al leer el archivo",
|
||||
"fileProcessError": "Error al procesar el archivo",
|
||||
"noValidProxies": "No se encontraron proxies válidos en el archivo",
|
||||
"namePrefix": "Prefijo de nombre",
|
||||
"namePrefixDefault": "Imported",
|
||||
"namePrefixHint": "Los proxies se nombrarán \"{{prefix}} Proxy 1\", \"{{prefix}} Proxy 2\", etc.",
|
||||
"proxiesToImport": "Proxies a importar ({{count}})",
|
||||
"invalidCount": "({{count}} inválidos)",
|
||||
"ambiguousIntro": "Los siguientes proxies tienen un formato ambiguo. Selecciona la interpretación correcta para cada uno.",
|
||||
"imported": "Importados:",
|
||||
"skippedDuplicates": "Omitidos (duplicados):",
|
||||
"errors": "Errores",
|
||||
"importButton": "Importar {{count}} proxies",
|
||||
"continueButton": "Continuar",
|
||||
"doneButton": "Hecho",
|
||||
"failed": "Error al importar los proxies"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -343,7 +475,31 @@
|
||||
"sync": {
|
||||
"enabled": "Sincronización Habilitada",
|
||||
"disabled": "Sincronización Deshabilitada"
|
||||
}
|
||||
},
|
||||
"createTitle": "Crear Nuevo Grupo",
|
||||
"createDescription": "Crea un nuevo grupo para organizar tus perfiles de navegador.",
|
||||
"editTitle": "Editar Grupo",
|
||||
"editDescription": "Actualiza el nombre de tu grupo.",
|
||||
"createSuccess": "Grupo creado correctamente",
|
||||
"createFailed": "Error al crear el grupo",
|
||||
"updateSuccess": "Grupo actualizado correctamente",
|
||||
"updateFailed": "Error al actualizar el grupo",
|
||||
"deleteTitle": "Eliminar Grupo",
|
||||
"deleteDescription": "Esta acción no se puede deshacer. Eliminará permanentemente el grupo.",
|
||||
"deleteSuccess": "Grupo eliminado correctamente",
|
||||
"deleteFailed": "Error al eliminar el grupo",
|
||||
"loadingProfiles": "Cargando perfiles asociados...",
|
||||
"associatedProfiles": "Perfiles Asociados ({{count}})",
|
||||
"whatToDoWithProfiles": "¿Qué hacer con estos perfiles?",
|
||||
"moveToDefaultOption": "Mover perfiles al grupo Predeterminado",
|
||||
"deleteAlongWithGroup": "Eliminar perfiles junto con el grupo",
|
||||
"noAssociatedProfiles": "Este grupo no tiene perfiles asociados.",
|
||||
"deleteGroup": "Eliminar Grupo",
|
||||
"deleteGroupAndProfiles": "Eliminar Grupo y Perfiles",
|
||||
"loadProfilesFailed": "Error al cargar los perfiles",
|
||||
"unknownGroup": "Grupo desconocido",
|
||||
"profileGroupsAriaLabel": "Grupos de perfiles",
|
||||
"loading": "Cargando grupos..."
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -366,7 +522,16 @@
|
||||
"configureService": "Configurar servicio de sincronización"
|
||||
},
|
||||
"title": "Servicio de Sincronización",
|
||||
"config": "Configuración de Sincronización",
|
||||
"config": {
|
||||
"serverUrlRequired": "Introduce la URL del servidor",
|
||||
"connectionSuccess": "¡Conexión exitosa!",
|
||||
"serverError": "El servidor respondió con un error",
|
||||
"connectFailed": "Error al conectar con el servidor",
|
||||
"settingsSaved": "Ajustes de sincronización guardados",
|
||||
"saveFailed": "Error al guardar los ajustes",
|
||||
"disconnected": "Sincronización desconectada",
|
||||
"disconnectFailed": "Error al desconectar"
|
||||
},
|
||||
"serverUrl": "URL del Servidor",
|
||||
"serverUrlPlaceholder": "https://sync.ejemplo.com",
|
||||
"token": "Token de Sincronización",
|
||||
@@ -410,6 +575,12 @@
|
||||
"profileLockedShort": "En uso",
|
||||
"cannotLaunchLocked": "No se puede iniciar — el perfil está en uso por {{email}}",
|
||||
"createdBy": "Creado por {{email}}"
|
||||
},
|
||||
"disabled": "Desactivada",
|
||||
"toast": {
|
||||
"profileSynced": "Perfil '{{name}}' sincronizado correctamente",
|
||||
"profileSyncFailed": "Error al sincronizar el perfil '{{name}}'",
|
||||
"profileSyncFailedWithError": "Error al sincronizar el perfil '{{name}}': {{error}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -447,7 +618,32 @@
|
||||
"removedFromClaudeCode": "Eliminado de Claude Code",
|
||||
"config": "Configuración MCP",
|
||||
"copyConfig": "Copiar Configuración"
|
||||
}
|
||||
},
|
||||
"tabApi": "API local",
|
||||
"tabMcp": "MCP (asistentes IA)",
|
||||
"apiEnableLabel": "Activar servidor API local",
|
||||
"apiEnableDescription": "Permite gestionar perfiles, grupos y proxies vía API REST.",
|
||||
"apiPortLabel": "Puerto",
|
||||
"apiTokenLabel": "Token de autenticación",
|
||||
"apiTokenHint": "Incluir en cabecera Authorization: Bearer {{tokenSlot}}",
|
||||
"apiInvalidPort": "Puerto inválido",
|
||||
"apiInvalidPortDescription": "El puerto debe estar entre 1 y 65535",
|
||||
"apiPortInUse": "El puerto {{port}} ya está en uso",
|
||||
"apiFallbackPort": "Servidor iniciado en puerto alternativo {{port}}",
|
||||
"apiStarted": "Servidor API iniciado en puerto {{port}}",
|
||||
"apiRunning": "Servidor API ejecutándose en puerto {{port}}",
|
||||
"apiStopped": "Servidor API detenido",
|
||||
"apiToggleFailed": "Error al alternar el servidor API",
|
||||
"apiStartFailed": "Error al iniciar el servidor API",
|
||||
"apiUnknownError": "Error desconocido",
|
||||
"tokenCopied": "Token copiado",
|
||||
"mcpEnableLabel": "Activar servidor MCP (Model Context Protocol)",
|
||||
"mcpEnableDescription": "Permite que asistentes IA como Claude Desktop controlen los navegadores.",
|
||||
"mcpAcceptTermsFirst": "(Acepta primero los términos de Wayfern en Configuración)",
|
||||
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
|
||||
"mcpStopped": "Servidor MCP detenido",
|
||||
"mcpToggleFailed": "Error al alternar el servidor MCP",
|
||||
"openSettings": "Abrir configuración de integraciones"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Perfil",
|
||||
@@ -465,7 +661,9 @@
|
||||
"fingerprint": {
|
||||
"title": "Huella Digital",
|
||||
"randomize": "Aleatorizar al Iniciar",
|
||||
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador."
|
||||
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador.",
|
||||
"osCpuPlaceholder": "p. ej., Intel Mac OS X 10.15",
|
||||
"webglRendererPlaceholder": "p. ej., llvmpipe, o similar"
|
||||
},
|
||||
"os": {
|
||||
"title": "Sistema Operativo",
|
||||
@@ -499,7 +697,10 @@
|
||||
"fingerprint": {
|
||||
"title": "Huella Digital",
|
||||
"randomize": "Aleatorizar al Iniciar",
|
||||
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador."
|
||||
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador.",
|
||||
"platformPlaceholder": "p. ej., Win32, MacIntel, Linux x86_64",
|
||||
"timezoneOffsetPlaceholder": "p. ej., 300 para EST (UTC-5)",
|
||||
"webglRendererPlaceholder": "p. ej., Intel(R) HD Graphics"
|
||||
},
|
||||
"os": {
|
||||
"title": "Sistema Operativo",
|
||||
@@ -522,6 +723,10 @@
|
||||
"webrtc": "Bloquear WebRTC",
|
||||
"webgl": "Bloquear WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Comportamiento del navegador",
|
||||
"allowAddonsOpenTabs": "Permitir que los complementos abran nuevas pestañas automáticamente"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -534,13 +739,53 @@
|
||||
"selectCookies": "Seleccionar Cookies",
|
||||
"allDomains": "Todos los Dominios",
|
||||
"selectedCount": "{{count}} cookie seleccionada",
|
||||
"selectedCount_plural": "{{count}} cookies seleccionadas"
|
||||
"selectedCount_plural": "{{count}} cookies seleccionadas",
|
||||
"dialogDescription_one": "Copiar cookies de un perfil de origen a {{count}} perfil seleccionado.",
|
||||
"dialogDescription_other": "Copiar cookies de un perfil de origen a {{count}} perfiles seleccionados.",
|
||||
"sourceProfile": "Perfil de origen",
|
||||
"sourcePlaceholder": "Selecciona un perfil del que copiar cookies",
|
||||
"running": "(en ejecución)",
|
||||
"targetProfiles": "Perfiles de destino ({{count}})",
|
||||
"noOtherTargets": "No hay otros perfiles Wayfern/Camoufox seleccionados",
|
||||
"selectSourceFirst": "Selecciona primero un perfil de origen",
|
||||
"selectionStatus": "({{selected}} de {{total}} seleccionadas)",
|
||||
"searchPlaceholder": "Buscar dominios o cookies...",
|
||||
"noMatching": "No se encontraron cookies coincidentes",
|
||||
"noFound": "No se encontraron cookies",
|
||||
"replaceNote": "Las cookies existentes con el mismo nombre y dominio serán reemplazadas. El resto se conservará.",
|
||||
"cannotCopyRunningOne": "No se pueden copiar las cookies: {{names}} aún en ejecución",
|
||||
"cannotCopyRunningMany": "No se pueden copiar las cookies: {{names}} aún en ejecución",
|
||||
"someErrors": "Ocurrieron algunos errores: {{errors}}",
|
||||
"successMessage": "Se copiaron {{copied}} cookies correctamente ({{replaced}} reemplazadas)",
|
||||
"failedMessage": "Error al copiar las cookies: {{error}}",
|
||||
"copyButton_one": "Copiar {{count}} cookie",
|
||||
"copyButton_other": "Copiar {{count}} cookies",
|
||||
"copyButtonEmpty": "Copiar cookies"
|
||||
},
|
||||
"success": "Cookies copiadas exitosamente",
|
||||
"error": "Error al copiar cookies",
|
||||
"management": {
|
||||
"title": "Gestión de Cookies",
|
||||
"menuItem": "Gestión de Cookies"
|
||||
"menuItem": "Gestión de Cookies",
|
||||
"tabImport": "Importar",
|
||||
"tabExport": "Exportar",
|
||||
"importDescription": "Importa cookies desde un archivo en formato Netscape o JSON.",
|
||||
"dropPrompt": "Haz clic para elegir un archivo de cookies",
|
||||
"fileFormats": "(.txt, .cookies o .json)",
|
||||
"cookiesFound": "{{count}} cookies encontradas",
|
||||
"importedSuccess": "{{imported}} cookies importadas correctamente ({{replaced}} reemplazadas)",
|
||||
"linesSkipped": "{{count}} línea(s) omitidas",
|
||||
"fileReadError": "Error al leer el archivo",
|
||||
"loadFailed": "Error al cargar las cookies: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
"selectionStatus": "({{selected}} de {{total}} seleccionadas)",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"deselectAll": "Deseleccionar todo",
|
||||
"noCookies": "No se encontraron cookies en este perfil",
|
||||
"doneButton": "Hecho",
|
||||
"importButton": "Importar",
|
||||
"exportButton": "Exportar",
|
||||
"backButton": "Atrás"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Cookies",
|
||||
@@ -623,7 +868,31 @@
|
||||
"maxLength": "Debe tener como máximo {{max}} caracteres",
|
||||
"networkError": "Error de red. Por favor verifica tu conexión.",
|
||||
"serverError": "Error del servidor. Por favor intenta de nuevo más tarde.",
|
||||
"unknownError": "Ocurrió un error desconocido. Por favor intenta de nuevo."
|
||||
"unknownError": "Ocurrió un error desconocido. Por favor intenta de nuevo.",
|
||||
"noProfilesForUrl": "No hay perfiles disponibles. Crea un perfil antes de abrir URLs.",
|
||||
"updateCamoufoxConfigFailed": "Error al actualizar la configuración de camoufox: {{error}}",
|
||||
"updateWayfernConfigFailed": "Error al actualizar la configuración de wayfern: {{error}}",
|
||||
"createProfileFailed": "Error al crear el perfil: {{error}}",
|
||||
"launchBrowserFailed": "Error al iniciar el navegador: {{error}}",
|
||||
"cannotDeleteRunningProfile": "No se puede eliminar el perfil mientras el navegador esté en ejecución. Detén el navegador primero.",
|
||||
"deleteProfileFailed": "Error al eliminar el perfil: {{error}}",
|
||||
"renameProfileFailed": "Error al renombrar el perfil: {{error}}",
|
||||
"killBrowserFailed": "Error al detener el navegador: {{error}}",
|
||||
"deleteSelectedProfilesFailed": "Error al eliminar los perfiles seleccionados: {{error}}",
|
||||
"cookieCopyUnsupportedBrowser": "La copia de cookies sólo funciona con perfiles Wayfern y Camoufox",
|
||||
"updateSyncSettingsFailed": "Error al actualizar los ajustes de sincronización",
|
||||
"cloneProfileFailed": "Error al clonar el perfil: {{error}}",
|
||||
"loadSupportedBrowsersFailed": "Error al cargar los navegadores compatibles",
|
||||
"setupExtensionListenersFailed": "Error al configurar los listeners de eventos de extensiones: {{error}}",
|
||||
"loadGroupsFailed": "Error al cargar los grupos: {{error}}",
|
||||
"setupGroupListenersFailed": "Error al configurar los listeners de eventos de grupos: {{error}}",
|
||||
"loadProfilesFailed": "Error al cargar los perfiles: {{error}}",
|
||||
"setupProfileListenersFailed": "Error al configurar los listeners de eventos de perfiles: {{error}}",
|
||||
"loadProxiesFailed": "Error al cargar los proxies: {{error}}",
|
||||
"setupProxyListenersFailed": "Error al configurar los listeners de eventos de proxies: {{error}}",
|
||||
"loadVpnConfigsFailed": "Error al cargar las configuraciones de VPN: {{error}}",
|
||||
"setupVpnListenersFailed": "Error al configurar los listeners de eventos de VPN: {{error}}",
|
||||
"themeNotFound": "Tema Tokyo Night no encontrado"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -649,15 +918,15 @@
|
||||
"blockWebRTC": "Bloquear WebRTC",
|
||||
"blockWebGL": "Bloquear WebGL",
|
||||
"navigatorProperties": "Propiedades del navegador",
|
||||
"userAgent": "User Agent",
|
||||
"userAgent": "Agente de usuario",
|
||||
"userAgentAndPlatform": "User Agent y plataforma",
|
||||
"platform": "Plataforma",
|
||||
"platformVersion": "Versión de plataforma",
|
||||
"appVersion": "Versión de la aplicación",
|
||||
"osCpu": "OS CPU",
|
||||
"osCpu": "CPU del SO",
|
||||
"hardwareConcurrency": "Concurrencia de hardware",
|
||||
"maxTouchPoints": "Puntos táctiles máximos",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "No rastrear",
|
||||
"selectDntPlaceholder": "Seleccionar valor DNT",
|
||||
"dntAllowed": "0 (rastreo permitido)",
|
||||
"dntNotAllowed": "1 (rastreo no permitido)",
|
||||
@@ -679,8 +948,8 @@
|
||||
"outerHeight": "Alto exterior",
|
||||
"innerWidth": "Ancho interior",
|
||||
"innerHeight": "Alto interior",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "Pantalla X",
|
||||
"screenY": "Pantalla Y",
|
||||
"geolocation": "Geolocalización",
|
||||
"timezoneAndGeolocation": "Zona horaria y geolocalización",
|
||||
"timezoneGeolocationDescription": "Estos valores anulan las APIs de zona horaria y geolocalización del navegador.",
|
||||
@@ -694,15 +963,15 @@
|
||||
"region": "Región",
|
||||
"script": "Script",
|
||||
"webglProperties": "Propiedades de WebGL",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "Proveedor WebGL",
|
||||
"webglRenderer": "Renderizador WebGL",
|
||||
"webglParameters": "Parámetros de WebGL",
|
||||
"webglParametersJson": "Parámetros de WebGL (JSON)",
|
||||
"webgl2Parameters": "Parámetros de WebGL2",
|
||||
"webglShaderPrecisionFormats": "Formatos de precisión de WebGL Shader",
|
||||
"webgl2ShaderPrecisionFormats": "Formatos de precisión de WebGL2 Shader",
|
||||
"webglShaderPrecisionFormats": "Formatos de precisión de shader WebGL",
|
||||
"webgl2ShaderPrecisionFormats": "Formatos de precisión de shader WebGL2",
|
||||
"canvasFingerprint": "Canvas Fingerprint",
|
||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
||||
"canvasNoiseSeed": "Semilla de ruido de Canvas",
|
||||
"canvasNoiseSeedDescription": "Esta semilla se usa para generar una huella digital de Canvas consistente pero única. Cada perfil debe tener una semilla diferente.",
|
||||
"fonts": "Fuentes",
|
||||
"fontsJson": "Fuentes (JSON array)",
|
||||
@@ -723,13 +992,16 @@
|
||||
"maxChannelCount": "Número máximo de canales",
|
||||
"vendorInfo": "Información del proveedor",
|
||||
"vendor": "Proveedor",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "Proveedor Sub",
|
||||
"productSub": "Producto Sub",
|
||||
"brand": "Marca",
|
||||
"brandVersion": "Versión de marca",
|
||||
"proFeature": "Esta es una función Pro",
|
||||
"generateFingerprint": "Generar Huella Digital",
|
||||
"refreshFingerprint": "Actualizar Huella Digital"
|
||||
"refreshFingerprint": "Actualizar Huella Digital",
|
||||
"canvasNoiseSeedPlaceholder": "Introduce una semilla para la huella digital del canvas",
|
||||
"addFontsPlaceholder": "Agregar fuentes...",
|
||||
"enterAsJson": "Ingresa {{title}} como JSON"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Dimensiones de ventana personalizadas",
|
||||
@@ -812,16 +1084,6 @@
|
||||
"button": "Clonar"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN no está instalado",
|
||||
"openVpnMissingDescription": "Puedes guardar esta configuración, pero Donut Browser no podrá conectarse hasta que OpenVPN esté instalado en este dispositivo.",
|
||||
"openVpnAdapterMissingTitle": "Falta el adaptador de OpenVPN",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN está instalado, pero no se encontró ningún adaptador TAP/Wintun/ovpn-dco. Repara o reinstala OpenVPN antes de conectarte en Windows.",
|
||||
"openVpnCheckFailedTitle": "No se pudo verificar la instalación de OpenVPN",
|
||||
"openVpnCheckFailedDescription": "Donut Browser no pudo inspeccionar la instalación local de OpenVPN. Repara o reinstala OpenVPN antes de conectarte en Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensiones",
|
||||
"description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.",
|
||||
@@ -879,7 +1141,9 @@
|
||||
"syncEnabled": "Sincronización habilitada",
|
||||
"syncDisabled": "Sincronización deshabilitada",
|
||||
"syncEnableTooltip": "Habilitar sincronización",
|
||||
"syncDisableTooltip": "Deshabilitar sincronización"
|
||||
"syncDisableTooltip": "Deshabilitar sincronización",
|
||||
"loadGroupsFailed": "Error al cargar grupos de extensiones",
|
||||
"assignGroupFailed": "Error al asignar grupo de extensiones"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
@@ -892,11 +1156,11 @@
|
||||
"dnsBlocklist": {
|
||||
"title": "Lista de bloqueo DNS",
|
||||
"none": "Ninguno",
|
||||
"light": "Light",
|
||||
"light": "Ligero",
|
||||
"normal": "Normal",
|
||||
"pro": "Pro",
|
||||
"proPlus": "Pro++",
|
||||
"ultimate": "Ultimate",
|
||||
"ultimate": "Definitivo",
|
||||
"settingsDescription": "Las listas de bloqueo DNS bloquean anuncios, rastreadores y dominios de malware a nivel de proxy. Las listas se actualizan automáticamente cada 12 horas.",
|
||||
"manageLists": "Gestionar listas de bloqueo DNS",
|
||||
"refreshAll": "Actualizar todas las listas",
|
||||
@@ -905,5 +1169,421 @@
|
||||
"fresh": "Actualizado",
|
||||
"stale": "Desactualizado",
|
||||
"notCached": "Sin caché"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Editar VPN",
|
||||
"titleCreate": "Crear VPN WireGuard",
|
||||
"descEdit": "Actualiza el nombre de tu configuración VPN.",
|
||||
"descCreate": "Introduce los detalles de la interfaz y el par de WireGuard.",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "p. ej. WireGuard Casa",
|
||||
"privateKey": "Clave Privada",
|
||||
"privateKeyPlaceholder": "Clave privada codificada en Base64",
|
||||
"address": "Dirección",
|
||||
"addressPlaceholder": "p. ej. 10.0.0.2/24",
|
||||
"dnsOptional": "DNS (opcional)",
|
||||
"dnsPlaceholder": "p. ej. 1.1.1.1",
|
||||
"mtuOptional": "MTU (opcional)",
|
||||
"mtuPlaceholder": "p. ej. 1420",
|
||||
"peerPublicKey": "Clave Pública del Par",
|
||||
"peerPublicKeyPlaceholder": "Clave pública del par codificada en Base64",
|
||||
"peerEndpoint": "Endpoint del Par",
|
||||
"peerEndpointPlaceholder": "p. ej. vpn.example.com:51820",
|
||||
"allowedIps": "IPs Permitidas",
|
||||
"allowedIpsPlaceholder": "p. ej. 0.0.0.0/0, ::/0",
|
||||
"keepaliveOptional": "Keepalive Persistente (opcional)",
|
||||
"keepalivePlaceholder": "p. ej. 25",
|
||||
"presharedKeyOptional": "Clave Precompartida (opcional)",
|
||||
"presharedKeyPlaceholder": "Clave precompartida codificada en Base64",
|
||||
"updateButton": "Actualizar VPN",
|
||||
"createButton": "Crear VPN",
|
||||
"nameRequired": "El nombre de la VPN es obligatorio",
|
||||
"privateKeyRequired": "La clave privada es obligatoria",
|
||||
"addressRequired": "La dirección es obligatoria",
|
||||
"peerPublicKeyRequired": "La clave pública del par es obligatoria",
|
||||
"peerEndpointRequired": "El endpoint del par es obligatorio",
|
||||
"updated": "VPN actualizada correctamente",
|
||||
"created": "VPN WireGuard creada correctamente",
|
||||
"updateFailed": "Error al actualizar la VPN: {{error}}",
|
||||
"createFailed": "Error al crear la VPN: {{error}}"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Configuración VPN",
|
||||
"descDropzone": "Importa un archivo de configuración WireGuard (.conf)",
|
||||
"descPreview": "Revisa la configuración VPN a importar",
|
||||
"descResult": "Importación VPN completada",
|
||||
"dropzonePrompt": "Suelta un archivo .conf de WireGuard aquí o haz clic para buscar",
|
||||
"pasteHint": "Pegar desde el portapapeles con {{modKey}}+V",
|
||||
"invalidContent": "El contenido no parece ser una configuración VPN válida",
|
||||
"fileReadError": "Error al leer el archivo",
|
||||
"wrongFileType": "Suelta un archivo .conf de WireGuard",
|
||||
"configurationLabel": "Configuración {{type}}",
|
||||
"endpointLabel": "Endpoint: {{endpoint}}",
|
||||
"vpnNameLabel": "Nombre de la VPN",
|
||||
"vpnNamePlaceholder": "Mi VPN",
|
||||
"configPreview": "Vista Previa de la Configuración",
|
||||
"importedSuccess": "VPN Importada Correctamente",
|
||||
"importFailed": "Importación Fallida",
|
||||
"importButton": "Importar VPN",
|
||||
"doneButton": "Listo",
|
||||
"failedGeneric": "Error al importar la configuración de VPN",
|
||||
"defaultName": "VPN {{type}}"
|
||||
},
|
||||
"management": {
|
||||
"loading": "Cargando VPN...",
|
||||
"noneCreated": "Aún no hay configuraciones de VPN. Importa o crea una usando los botones de arriba.",
|
||||
"editVpn": "Editar VPN",
|
||||
"deleteVpn": "Eliminar VPN",
|
||||
"cannotDelete_one": "No se puede eliminar: en uso por {{count}} perfil",
|
||||
"cannotDelete_other": "No se puede eliminar: en uso por {{count}} perfiles",
|
||||
"syncCannotDisable": "No se puede desactivar la sincronización mientras esta VPN esté en uso por perfiles sincronizados",
|
||||
"deleteSuccess": "VPN eliminada correctamente",
|
||||
"deleteFailed": "Error al eliminar la VPN",
|
||||
"deleteTitle": "Eliminar VPN",
|
||||
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente la VPN \"{{name}}\"."
|
||||
}
|
||||
},
|
||||
"importProfile": {
|
||||
"title": "Importar perfil de navegador",
|
||||
"autoDetect": "Detección automática",
|
||||
"manualImport": "Importación manual",
|
||||
"detectedProfilesTitle": "Perfiles de navegador detectados",
|
||||
"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",
|
||||
"examplePaths": "Rutas de ejemplo:",
|
||||
"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}}."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
"syncedAt": "Sincronizado {{time}}",
|
||||
"synced": "Sincronizado",
|
||||
"waiting": "En espera de sincronización",
|
||||
"errorWith": "Error de sincronización: {{error}}",
|
||||
"error": "Error de sincronización",
|
||||
"notSynced": "Sin sincronizar"
|
||||
},
|
||||
"groupManagement": {
|
||||
"description": "Administra tus grupos de perfiles",
|
||||
"createGroup": "Crear grupo",
|
||||
"noGroups": "Aún no hay grupos. Crea tu primer grupo usando el botón de arriba.",
|
||||
"loading": "Cargando grupos...",
|
||||
"profileCount_one": "{{count}} perfil",
|
||||
"profileCount_other": "{{count}} perfiles",
|
||||
"groupsLabel": "Grupos",
|
||||
"profilesCol": "Perfiles",
|
||||
"syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados",
|
||||
"editGroupTooltip": "Editar grupo",
|
||||
"deleteGroupTooltip": "Eliminar grupo",
|
||||
"loadFailed": "Error al cargar los grupos"
|
||||
},
|
||||
"proxyAssignment": {
|
||||
"title": "Asignar proxy / VPN",
|
||||
"description_one": "Asigna un proxy o VPN a {{count}} perfil seleccionado.",
|
||||
"description_other": "Asigna un proxy o VPN a {{count}} perfiles seleccionados.",
|
||||
"selectLabel": "Proxy / VPN",
|
||||
"placeholder": "Selecciona un proxy o VPN",
|
||||
"noProxy": "Sin proxy / VPN",
|
||||
"searchPlaceholder": "Buscar proxies o VPN...",
|
||||
"notFound": "No se encontraron proxies ni VPN.",
|
||||
"assignButton": "Asignar",
|
||||
"success": "Proxy/VPN asignado correctamente a {{count}} perfil(es)",
|
||||
"failed": "Error al asignar proxy/VPN",
|
||||
"selectedProfilesLabel": "Perfiles seleccionados:",
|
||||
"assignProxyVpnLabel": "Asignar proxy / VPN:",
|
||||
"noneOption": "Ninguno",
|
||||
"noValidProfiles": "No hay perfiles válidos seleccionados.",
|
||||
"vpnGroupHeading": "VPN",
|
||||
"failedFallback": "Error al asignar proxy/VPN a los perfiles"
|
||||
},
|
||||
"groupAssignment": {
|
||||
"title": "Asignar grupo",
|
||||
"description_one": "Asigna un grupo a {{count}} perfil seleccionado.",
|
||||
"description_other": "Asigna un grupo a {{count}} perfiles seleccionados.",
|
||||
"selectLabel": "Grupo",
|
||||
"placeholder": "Selecciona un grupo",
|
||||
"noGroup": "Sin grupo (Predeterminado)",
|
||||
"assignButton": "Asignar",
|
||||
"success": "Grupo asignado correctamente a {{count}} perfil(es)",
|
||||
"failed": "Error al asignar grupo",
|
||||
"selectedProfilesLabel": "Perfiles seleccionados:",
|
||||
"assignGroupLabel": "Asignar a grupo:",
|
||||
"noValidProfiles": "No hay perfiles válidos seleccionados.",
|
||||
"failedFallback": "Error al asignar grupo a los perfiles"
|
||||
},
|
||||
"profileSelector": {
|
||||
"title": "Seleccionar perfil",
|
||||
"description": "Elige un perfil para abrir con esta URL",
|
||||
"searchPlaceholder": "Buscar perfiles...",
|
||||
"noProfiles": "No hay perfiles disponibles",
|
||||
"noResults": "Ningún perfil coincide con tu búsqueda",
|
||||
"selectButton": "Seleccionar",
|
||||
"launching": "Abriendo...",
|
||||
"chooseProfileTitle": "Elegir perfil",
|
||||
"openingUrl": "Abriendo URL:",
|
||||
"urlCopied": "¡URL copiada al portapapeles!",
|
||||
"selectProfileLabel": "Seleccionar perfil:",
|
||||
"noneAvailableShort": "No hay perfiles disponibles. Crea un perfil primero.",
|
||||
"noneAvailableLong": "Cierra este diálogo y crea un perfil desde la ventana principal para empezar.",
|
||||
"chooseAProfile": "Elige un perfil",
|
||||
"badgeProxy": "Proxy",
|
||||
"badgeRunning": "En ejecución",
|
||||
"badgeUnavailable": "No disponible",
|
||||
"openButton": "Abrir"
|
||||
},
|
||||
"locationProxy": {
|
||||
"title": "Proxy rápido por ubicación",
|
||||
"description": "Elige un país por el que enrutar este perfil. Se creará un proxy automáticamente.",
|
||||
"country": "País",
|
||||
"selectCountry": "Selecciona un país",
|
||||
"searchCountry": "Buscar país...",
|
||||
"noCountriesFound": "No se encontraron países.",
|
||||
"apply": "Aplicar",
|
||||
"creating": "Creando proxy...",
|
||||
"success": "Proxy de ubicación aplicado",
|
||||
"failed": "Error al aplicar el proxy de ubicación",
|
||||
"titleCreate": "Crear proxy por ubicación",
|
||||
"descriptionCreate": "Crea un proxy geolocalizado con una sesión persistente de 24 horas",
|
||||
"countryLabel": "País (obligatorio)",
|
||||
"regionLabel": "Región (opcional)",
|
||||
"cityLabel": "Ciudad (opcional)",
|
||||
"ispLabel": "ISP (opcional)",
|
||||
"nameLabel": "Nombre",
|
||||
"namePlaceholder": "Nombre del proxy",
|
||||
"loadingCountries": "Cargando países...",
|
||||
"selectCountryPh": "Selecciona país",
|
||||
"searchCountries": "Buscar países...",
|
||||
"loadFailed": "Error al cargar los países",
|
||||
"selectCountryFirst": "Selecciona primero un país",
|
||||
"loadingRegions": "Cargando regiones...",
|
||||
"noRegions": "No hay regiones disponibles",
|
||||
"selectRegion": "Selecciona región",
|
||||
"searchRegions": "Buscar regiones...",
|
||||
"loadingCities": "Cargando ciudades...",
|
||||
"noCities": "No hay ciudades disponibles",
|
||||
"selectCity": "Selecciona ciudad",
|
||||
"searchCities": "Buscar ciudades...",
|
||||
"loadingIsps": "Cargando ISP...",
|
||||
"noIsps": "No hay ISP disponibles",
|
||||
"selectIsp": "Selecciona ISP",
|
||||
"searchIsps": "Buscar ISP...",
|
||||
"createSuccess": "Proxy de ubicación creado",
|
||||
"createFailed": "Error al crear el proxy de ubicación",
|
||||
"creatingButton": "Creando...",
|
||||
"createButton": "Crear"
|
||||
},
|
||||
"launchOnLogin": {
|
||||
"title": "¿Activar inicio al iniciar sesión?",
|
||||
"description": "Ejecutarse en segundo plano ayuda a mantener vivos los proxies y navegadores.",
|
||||
"declineButton": "No volver a preguntar",
|
||||
"declining": "...",
|
||||
"enableButton": "Activar",
|
||||
"enableSuccess": "Inicio al iniciar sesión activado",
|
||||
"enableFailed": "Error al activar el inicio al iniciar sesión",
|
||||
"declineFailed": "Error al guardar la preferencia",
|
||||
"tryAgain": "Por favor, inténtalo de nuevo"
|
||||
},
|
||||
"wayfernTerms": {
|
||||
"title": "Términos y condiciones de Wayfern",
|
||||
"description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.",
|
||||
"reviewLabel": "Por favor, revisa los Términos y Condiciones en:",
|
||||
"agreeNotice": "Al hacer clic en \"Acepto\", aceptas estos términos.",
|
||||
"acceptButton": "Acepto",
|
||||
"acceptSuccess": "Términos aceptados correctamente",
|
||||
"acceptFailed": "Error al aceptar los términos",
|
||||
"tryAgain": "Por favor, inténtalo de nuevo"
|
||||
},
|
||||
"commercialTrial": {
|
||||
"title": "Periodo de prueba comercial expirado",
|
||||
"description": "Tu periodo de prueba comercial de 2 semanas ha terminado.",
|
||||
"body": "Si usas Donut Browser con fines comerciales, debes adquirir una licencia comercial para continuar. Puedes seguir usándolo de forma personal gratis.",
|
||||
"understandButton": "Entendido",
|
||||
"failed": "Error al guardar el reconocimiento",
|
||||
"tryAgain": "Por favor, inténtalo de nuevo"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"titleMicrophone": "Se requiere acceso al micrófono",
|
||||
"titleCamera": "Se requiere acceso a la cámara",
|
||||
"descMicrophone": "Donut Browser necesita acceso a tu micrófono para activar la funcionalidad de micrófono en los navegadores. Cada sitio web que quiera usar tu micrófono te lo pedirá individualmente.",
|
||||
"descCamera": "Donut Browser necesita acceso a tu cámara para activar la funcionalidad de cámara en los navegadores. Cada sitio web que quiera usar tu cámara te lo pedirá individualmente.",
|
||||
"grantedMicrophone": "¡Permiso concedido! Los navegadores lanzados desde Donut Browser ya pueden acceder a tu micrófono.",
|
||||
"grantedCamera": "¡Permiso concedido! Los navegadores lanzados desde Donut Browser ya pueden acceder a tu cámara.",
|
||||
"notGrantedMicrophone": "Permiso no concedido. Haz clic en el botón para solicitar acceso a tu micrófono.",
|
||||
"notGrantedCamera": "Permiso no concedido. Haz clic en el botón para solicitar acceso a tu cámara.",
|
||||
"doneButton": "Hecho",
|
||||
"cancelButton": "Cancelar",
|
||||
"grantAccessButton": "Conceder acceso",
|
||||
"requestSuccessMicrophone": "Acceso al micrófono solicitado",
|
||||
"requestSuccessCamera": "Acceso a la cámara solicitado",
|
||||
"requestFailed": "Error al solicitar el permiso"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Detalles de tráfico",
|
||||
"bandwidthOverTime": "Ancho de banda en el tiempo",
|
||||
"timePeriodPlaceholder": "Periodo",
|
||||
"last1m": "Último 1 min",
|
||||
"last5m": "Últimos 5 min",
|
||||
"last30m": "Últimos 30 min",
|
||||
"last1h": "Última 1 hora",
|
||||
"last2h": "Últimas 2 horas",
|
||||
"last4h": "Últimas 4 horas",
|
||||
"last1d": "Último día",
|
||||
"last7d": "Últimos 7 días",
|
||||
"last30d": "Últimos 30 días",
|
||||
"allTime": "Todo el tiempo",
|
||||
"allTimeShort": "todo el tiempo",
|
||||
"totalSuffix": "total",
|
||||
"sentLabel": "Enviado ({{period}})",
|
||||
"receivedLabel": "Recibido ({{period}})",
|
||||
"requestsLabel": "Solicitudes ({{period}})",
|
||||
"allTimeTraffic": "Tráfico total:",
|
||||
"allTimeRequests": "Solicitudes totales:",
|
||||
"proxyDisclaimer": "Nota: Si usas un proxy, VPN o servicio similar, tu proveedor podría calcular el tráfico de forma diferente debido a la sobrecarga de cifrado y diferencias de protocolo.",
|
||||
"topByTraffic": "Principales dominios por tráfico ({{period}})",
|
||||
"topByRequests": "Principales dominios por solicitudes ({{period}})",
|
||||
"columnDomain": "Dominio",
|
||||
"columnRequests": "Solicitudes",
|
||||
"columnSent": "Enviado",
|
||||
"columnReceived": "Recibido",
|
||||
"columnTotal": "Tráfico total",
|
||||
"uniqueIps": "IPs únicas ({{count}})",
|
||||
"noData": "No hay datos de tráfico disponibles para este perfil.",
|
||||
"noDataHint": "Los datos de tráfico aparecerán cuando lances el perfil.",
|
||||
"sentLegend": "Enviado",
|
||||
"receivedLegend": "Recibido",
|
||||
"tooltipSent": "↑ Enviado: ",
|
||||
"tooltipReceived": "↓ Recibido: "
|
||||
},
|
||||
"camoufoxDialog": {
|
||||
"titleView": "Ver configuración de huella - {{name}} ({{browser}})",
|
||||
"titleConfigure": "Configurar huella - {{name}} ({{browser}})",
|
||||
"invalidFingerprint": "Configuración de huella inválida",
|
||||
"invalidFingerprintDescription": "La configuración de huella contiene JSON inválido. Revisa la configuración avanzada.",
|
||||
"saveFailed": "Error al guardar la configuración",
|
||||
"unknownError": "Ocurrió un error desconocido"
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Desconocido",
|
||||
"locationToast": "La ubicación de tu proxy es:",
|
||||
"failed": "Falló la verificación del proxy: {{error}}",
|
||||
"tooltipChecking": "Comprobando proxy...",
|
||||
"tooltipIp": "IP: {{ip}}",
|
||||
"tooltipChecked": "Comprobado {{time}}",
|
||||
"tooltipFailed": "Fallo {{time}}",
|
||||
"tooltipFailedTitle": "Falló la verificación del proxy",
|
||||
"tooltipDefault": "Comprobar validez del proxy"
|
||||
},
|
||||
"vpnCheck": {
|
||||
"valid": "La configuración de VPN \"{{name}}\" es válida",
|
||||
"invalid": "La configuración de VPN \"{{name}}\" no es válida",
|
||||
"failed": "Falló la verificación de la VPN: {{error}}",
|
||||
"tooltipChecking": "Comprobando configuración de VPN...",
|
||||
"tooltipValid": "Configuración válida",
|
||||
"tooltipInvalid": "Configuración inválida",
|
||||
"tooltipChecked": "Comprobado {{time}}",
|
||||
"tooltipDefault": "Comprobar validez de la configuración de VPN"
|
||||
},
|
||||
"profileTable": {
|
||||
"syncTooltipDisabled": "Sincronización desactivada",
|
||||
"syncTooltipSyncing": "Sincronizando...",
|
||||
"syncTooltipSyncedAt": "Sincronizado {{time}}",
|
||||
"syncTooltipSynced": "Sincronizado",
|
||||
"syncTooltipWaiting": "Esperando para sincronizar",
|
||||
"syncTooltipErrorWith": "Error de sincronización: {{error}}",
|
||||
"syncTooltipError": "Error de sincronización",
|
||||
"syncTooltipNotSynced": "No sincronizado",
|
||||
"noTags": "Sin etiquetas",
|
||||
"syncTooltipCloseToSync": "Cierra el perfil para sincronizar",
|
||||
"syncTooltipDisabledWithLast": "Sincronización desactivada, última sincronización {{time}}",
|
||||
"addTagsPlaceholder": "Añadir etiquetas",
|
||||
"tagsHeader": "Etiquetas",
|
||||
"noteHeader": "Nota",
|
||||
"vpnsHeading": "VPN",
|
||||
"createByCountryHeading": "Crear por país"
|
||||
},
|
||||
"releaseTypeSelector": {
|
||||
"noReleaseTypes": "No hay tipos de versión disponibles.",
|
||||
"placeholder": "Selecciona el tipo de versión...",
|
||||
"stable": "Estable",
|
||||
"nightly": "Nightly",
|
||||
"downloaded": "Descargado",
|
||||
"downloadBrowser": "Descargar navegador",
|
||||
"downloading": "Descargando..."
|
||||
},
|
||||
"dataTableActionBar": {
|
||||
"selected": "{{count}} seleccionados",
|
||||
"clearSelection": "Limpiar selección"
|
||||
},
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"updateFailed": "Error al actualizar Donut Browser",
|
||||
"restartFailed": "Error al reiniciar",
|
||||
"updateReady": "Actualización lista, reinicia para aplicar",
|
||||
"manualDownloadRequired": "Descarga manual requerida",
|
||||
"restartNow": "Reiniciar ahora",
|
||||
"viewRelease": "Ver lanzamiento",
|
||||
"later": "Más tarde",
|
||||
"uploading": "Subiendo",
|
||||
"downloading": "Descargando"
|
||||
}
|
||||
},
|
||||
"browserDownload": {
|
||||
"toast": {
|
||||
"fetchVersionsFailed": "Error al obtener las versiones de {{browser}}",
|
||||
"foundNewVersions": "¡Se encontraron {{count}} nuevas versiones de {{browser}}!",
|
||||
"totalAvailableVersions": "Total disponible: {{count}} versiones",
|
||||
"downloadFailed": "Error al descargar {{browser}} {{version}}",
|
||||
"calculating": "calculando...",
|
||||
"extractionFailed": "{{browser}} {{version}}: error de extracción",
|
||||
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.",
|
||||
"extracting": "Extrayendo archivos del navegador... No cierre la aplicación.",
|
||||
"verifying": "Verificando archivos del navegador...",
|
||||
"downloadingRolling": "Descargando compilación rolling release..."
|
||||
}
|
||||
},
|
||||
"versionUpdater": {
|
||||
"toast": {
|
||||
"alreadyAvailable": "{{browser}} {{version}} ya disponible",
|
||||
"updatingProfiles": "Actualizando configuraciones de perfil...",
|
||||
"updateCompleted": "Actualización de {{browser}} completada",
|
||||
"singleProfileUpdated": "El perfil \"{{name}}\" se ha actualizado a la versión {{version}}. Ya puedes iniciar tus navegadores con la última versión.",
|
||||
"multipleProfilesUpdated": "Se han actualizado {{count}} perfiles a la versión {{version}}. Ya puedes iniciar tus navegadores con la última versión.",
|
||||
"versionAvailable": "La versión {{version}} ya está disponible. Los perfiles en ejecución usarán la nueva versión al reiniciarse.",
|
||||
"autoUpdateFailed": "Error al auto-actualizar {{browser}}",
|
||||
"updateWithErrors": "Actualización completada con algunos errores",
|
||||
"updateWithErrorsDescription": "Se encontraron {{newVersions}} nuevas versiones, {{failedUpdates}} navegadores no se pudieron actualizar",
|
||||
"updateSuccess": "Versiones del navegador actualizadas correctamente",
|
||||
"updateSuccessDescription": "Se encontraron {{newVersions}} nuevas versiones en {{successfulUpdates}} navegadores. Las descargas automáticas comenzarán pronto.",
|
||||
"upToDate": "No se encontraron nuevas versiones del navegador",
|
||||
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
|
||||
"updateAllFailed": "Error al actualizar las versiones del navegador"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user