mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-02 16:45:13 +02:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
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@da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65 #v1.14.24
|
||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
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;
|
||||
|
||||
+5
-4
@@ -2,14 +2,13 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.5",
|
||||
"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",
|
||||
@@ -92,7 +91,9 @@
|
||||
"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",
|
||||
|
||||
Generated
+31
-43
@@ -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:
|
||||
|
||||
@@ -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'}
|
||||
@@ -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)':
|
||||
@@ -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:
|
||||
@@ -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
+103
-113
@@ -473,9 +473,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
@@ -500,7 +500,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -588,11 +588,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "4.9.0"
|
||||
version = "4.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
|
||||
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
|
||||
dependencies = [
|
||||
"core2",
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -618,9 +618,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.4"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
@@ -946,9 +946,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -1088,9 +1088,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -1110,9 +1110,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -1267,15 +1267,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core2"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
@@ -1320,9 +1311,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
@@ -1514,9 +1505,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
@@ -1526,13 +1517,13 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.10"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
|
||||
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1789,7 +1780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.22.0"
|
||||
version = "0.22.5"
|
||||
dependencies = [
|
||||
"aes 0.9.0",
|
||||
"aes-gcm",
|
||||
@@ -1824,7 +1815,6 @@ dependencies = [
|
||||
"maxminddb",
|
||||
"mime_guess",
|
||||
"msi-extract",
|
||||
"muda",
|
||||
"nix 0.31.2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -1861,11 +1851,11 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tray-icon 0.22.0",
|
||||
"tray-icon 0.22.1",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa",
|
||||
@@ -1951,14 +1941,14 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.8"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
||||
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
@@ -3001,9 +2991,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.10"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
|
||||
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
@@ -3032,15 +3022,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
@@ -3100,7 +3089,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3454,9 +3443,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
@@ -3467,9 +3456,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3642,9 +3631,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
@@ -4086,6 +4075,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -4461,9 +4459,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
version = "5.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
@@ -4473,9 +4471,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
version = "0.10.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
@@ -4505,9 +4503,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
version = "0.9.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -4910,9 +4908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
@@ -5027,9 +5025,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
@@ -5205,9 +5203,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
@@ -5455,9 +5453,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
@@ -5853,9 +5851,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
@@ -5866,9 +5864,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
@@ -6622,9 +6620,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b"
|
||||
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
@@ -7521,9 +7519,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.1"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -7596,18 +7594,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
@@ -7619,7 +7605,7 @@ dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite 0.29.0",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7662,6 +7648,21 @@ dependencies = [
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
@@ -7863,9 +7864,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e1484378c343c5a9b291188fa58917c7184967683f8cfe4a05461986970553"
|
||||
checksum = "7f9eb1da86bd0ab8931fad00650d2ba7473260c5bab06d6f24d04339edb88faa"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
@@ -7897,23 +7898,6 @@ dependencies = [
|
||||
"core_maths",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
@@ -7945,9 +7929,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
@@ -8205,9 +8189,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -8303,11 +8287,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8316,7 +8300,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8433,9 +8417,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
|
||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
@@ -9104,6 +9088,12 @@ dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.22.0"
|
||||
version = "0.22.5"
|
||||
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"
|
||||
@@ -111,7 +111,6 @@ smoltcp = { version = "0.13", default-features = false, features = ["std", "medi
|
||||
|
||||
# Daemon dependencies (tray icon)
|
||||
tray-icon = "0.22"
|
||||
muda = "0.17"
|
||||
tao = "0.35"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
|
||||
+264
-2
@@ -130,6 +130,39 @@ 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, 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 +224,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 +246,10 @@ struct OpenUrlRequest {
|
||||
ApiProxyResponse,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
ApiVpnResponse,
|
||||
ImportVpnRequest,
|
||||
CreateVpnRequest,
|
||||
UpdateVpnRequest,
|
||||
DownloadBrowserRequest,
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
@@ -219,6 +262,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 +355,9 @@ 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!(get_vpn, update_vpn, delete_vpn))
|
||||
.routes(routes!(get_extensions))
|
||||
.routes(routes!(delete_extension_api))
|
||||
.routes(routes!(get_extension_groups))
|
||||
@@ -1189,6 +1236,212 @@ 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(
|
||||
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 +1584,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 {
|
||||
|
||||
+1
-38
@@ -769,42 +769,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 +1232,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 +2039,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.5",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+724
-44
@@ -28,7 +28,9 @@
|
||||
"refresh": "Actualiser",
|
||||
"loading": "Chargement...",
|
||||
"saveSettings": "Enregistrer les paramètres",
|
||||
"moreInfo": "En savoir plus"
|
||||
"moreInfo": "En savoir plus",
|
||||
"downloading": "Téléchargement...",
|
||||
"minimize": "Réduire"
|
||||
},
|
||||
"status": {
|
||||
"active": "Actif",
|
||||
@@ -56,7 +58,10 @@
|
||||
"default": "Par défaut",
|
||||
"custom": "Personnalisé",
|
||||
"optional": "Optionnel",
|
||||
"required": "Requis"
|
||||
"required": "Requis",
|
||||
"unknownProfile": "Inconnu",
|
||||
"mode": "Mode",
|
||||
"never": "Jamais"
|
||||
},
|
||||
"time": {
|
||||
"days": "jours",
|
||||
@@ -64,6 +69,33 @@
|
||||
"minutes": "minutes",
|
||||
"seconds": "secondes",
|
||||
"remaining": "restants"
|
||||
},
|
||||
"aria": {
|
||||
"selectAll": "Tout sélectionner",
|
||||
"selectRow": "Sélectionner la ligne",
|
||||
"selectProfile": "Sélectionner le profil",
|
||||
"copy": "Copier dans le presse-papiers",
|
||||
"copied": "Copié",
|
||||
"showToken": "Afficher le jeton",
|
||||
"hideToken": "Masquer le jeton"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Échap"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": "Une erreur inconnue est survenue"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Réduire"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Palette de commandes",
|
||||
"description": "Rechercher une commande à exécuter..."
|
||||
},
|
||||
"noResults": "Aucun résultat trouvé.",
|
||||
"srOnly": {
|
||||
"copy": "Copier",
|
||||
"copied": "Copié"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -85,7 +117,8 @@
|
||||
"title": "Langue",
|
||||
"description": "Choisissez votre langue préférée pour l'interface de l'application.",
|
||||
"systemDefault": "Par défaut du système",
|
||||
"selectLanguage": "Sélectionner la langue"
|
||||
"selectLanguage": "Sélectionner la langue",
|
||||
"interface": "Langue de l'interface"
|
||||
},
|
||||
"defaultBrowser": {
|
||||
"title": "Navigateur par défaut",
|
||||
@@ -100,7 +133,8 @@
|
||||
"microphone": "Microphone",
|
||||
"microphoneDescription": "Accès au microphone pour les applications du navigateur",
|
||||
"camera": "Caméra",
|
||||
"cameraDescription": "Accès à la caméra pour les applications du navigateur"
|
||||
"cameraDescription": "Accès à la caméra pour les applications du navigateur",
|
||||
"accessRequested": "Accès {{permission}} demandé"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Intégrations",
|
||||
@@ -134,7 +168,8 @@
|
||||
"advanced": {
|
||||
"title": "Avancé",
|
||||
"clearCache": "Effacer tout le cache des versions",
|
||||
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs."
|
||||
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs.",
|
||||
"clearCacheFailed": "Échec de la suppression du cache"
|
||||
},
|
||||
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
|
||||
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées."
|
||||
@@ -169,7 +204,9 @@
|
||||
"note": "Note",
|
||||
"group": "Groupe",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Dernier lancement"
|
||||
"lastLaunch": "Dernier lancement",
|
||||
"empty": "Aucun profil trouvé.",
|
||||
"notSelected": "Non sélectionné"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Lancer",
|
||||
@@ -205,7 +242,30 @@
|
||||
"ephemeral": "Éphémère",
|
||||
"ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.",
|
||||
"ephemeralBadge": "Éphémère",
|
||||
"ephemeralAlpha": "Alpha"
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Supprimer les profils sélectionnés",
|
||||
"description": "Cette action est irréversible. Elle supprimera définitivement {{count}} profil(s) et toutes les données associées.",
|
||||
"confirmButton": "Supprimer {{count}} profil(s)"
|
||||
},
|
||||
"note": {
|
||||
"empty": "Pas de note",
|
||||
"placeholder": "Ajouter une note..."
|
||||
},
|
||||
"aria": {
|
||||
"profileInfo": "Informations sur le profil"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer le profil",
|
||||
"description": "Cette action est irréversible. Elle supprimera définitivement le profil « {{profileName}} » et toutes ses données associées.",
|
||||
"confirmButton": "Supprimer le profil"
|
||||
},
|
||||
"actionBar": {
|
||||
"assignToGroup": "Assigner à un groupe",
|
||||
"assignProxy": "Assigner un proxy",
|
||||
"assignExtensionGroup": "Assigner un groupe d’extensions",
|
||||
"copyCookies": "Copier les cookies"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Créer un nouveau profil",
|
||||
@@ -228,7 +288,10 @@
|
||||
"title": "Proxy / VPN",
|
||||
"addProxy": "Ajouter un proxy",
|
||||
"noProxy": "Pas de proxy / VPN",
|
||||
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil."
|
||||
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil.",
|
||||
"search": "Rechercher proxies ou VPN...",
|
||||
"notFound": "Aucun proxy ou VPN trouvé.",
|
||||
"searchWithCountries": "Rechercher des proxies, VPN ou pays..."
|
||||
},
|
||||
"launchHook": {
|
||||
"label": "URL du hook de lancement",
|
||||
@@ -248,7 +311,8 @@
|
||||
"chromiumSubtitle": "Propulsé par Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Propulsé par Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium."
|
||||
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium.",
|
||||
"platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Supprimer le profil",
|
||||
@@ -259,7 +323,31 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxies",
|
||||
"management": "Proxys et VPNs",
|
||||
"management": {
|
||||
"description": "Gérez vos configurations de proxys et VPN pour les réutiliser sur les profils",
|
||||
"tabProxies": "Proxys",
|
||||
"tabVpns": "VPN",
|
||||
"create": "Créer",
|
||||
"loading": "Chargement des proxys...",
|
||||
"noneCreated": "Aucun proxy créé. Créez votre premier proxy avec le bouton ci-dessus.",
|
||||
"usage": "Utilisation",
|
||||
"syncCol": "Sync",
|
||||
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce proxy est utilisé par des profils synchronisés",
|
||||
"enableSync": "Activer la sync",
|
||||
"disableSync": "Désactiver la sync",
|
||||
"editProxy": "Modifier le proxy",
|
||||
"deleteProxy": "Supprimer le proxy",
|
||||
"cannotDelete_one": "Suppression impossible : utilisé par {{count}} profil",
|
||||
"cannotDelete_other": "Suppression impossible : utilisé par {{count}} profils",
|
||||
"syncEnabled": "Sync activée",
|
||||
"syncDisabled": "Sync désactivée",
|
||||
"updateSyncFailed": "Échec de la mise à jour de la sync",
|
||||
"deleteSuccess": "Proxy supprimé avec succès",
|
||||
"deleteFailed": "Échec de la suppression du proxy",
|
||||
"deleteTitle": "Supprimer le proxy",
|
||||
"deleteDescription": "Cette action est irréversible. Le proxy « {{name}} » sera supprimé définitivement.",
|
||||
"title": "Proxys et VPN"
|
||||
},
|
||||
"add": "Ajouter un proxy",
|
||||
"edit": "Modifier le proxy",
|
||||
"delete": "Supprimer le proxy",
|
||||
@@ -280,7 +368,12 @@
|
||||
"password": "Mot de passe",
|
||||
"passwordPlaceholder": "Optionnel",
|
||||
"cipher": "Chiffrement",
|
||||
"cipherPlaceholder": "aes-256-gcm"
|
||||
"cipherPlaceholder": "aes-256-gcm",
|
||||
"nameRequired": "Le nom du proxy est requis",
|
||||
"hostPortRequired": "Hôte et port sont requis",
|
||||
"ssCipherRequired": "Le chiffrement et le mot de passe sont requis pour Shadowsocks",
|
||||
"selectType": "Sélectionnez le type de proxy",
|
||||
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}"
|
||||
},
|
||||
"types": {
|
||||
"http": "HTTP",
|
||||
@@ -318,6 +411,45 @@
|
||||
"sync": {
|
||||
"enabled": "Synchronisation activée",
|
||||
"disabled": "Synchronisation désactivée"
|
||||
},
|
||||
"exportDialog": {
|
||||
"title": "Exporter les proxys",
|
||||
"description": "Exportez vos configurations de proxy dans un fichier",
|
||||
"format": "Format d'export",
|
||||
"json": "JSON",
|
||||
"txt": "TXT (format URL)",
|
||||
"preview": "Aperçu",
|
||||
"noProxies": "Aucun proxy à exporter",
|
||||
"downloaded": "{{filename}} téléchargé",
|
||||
"failed": "Échec de l'export des proxys",
|
||||
"copied": "Copié"
|
||||
},
|
||||
"importDialog": {
|
||||
"title": "Importer des proxys",
|
||||
"descDropzone": "Importer des proxys depuis un fichier JSON ou TXT",
|
||||
"descPreview": "Vérifiez les proxys à importer",
|
||||
"descAmbiguous": "Certains proxys ont des formats ambigus. Sélectionnez le bon format.",
|
||||
"descResult": "Import terminé",
|
||||
"dropzonePrompt": "Déposez un fichier de configuration de proxy",
|
||||
"dropzoneFormats": "(.json, .txt)",
|
||||
"pasteHint": "Coller depuis le presse-papiers avec {{modKey}}+V",
|
||||
"wrongFileType": "Veuillez déposer un fichier .json ou .txt",
|
||||
"fileReadError": "Échec de la lecture du fichier",
|
||||
"fileProcessError": "Échec du traitement du fichier",
|
||||
"noValidProxies": "Aucun proxy valide trouvé dans le fichier",
|
||||
"namePrefix": "Préfixe du nom",
|
||||
"namePrefixDefault": "Importé",
|
||||
"namePrefixHint": "Les proxys seront nommés « {{prefix}} Proxy 1 », « {{prefix}} Proxy 2 », etc.",
|
||||
"proxiesToImport": "Proxys à importer ({{count}})",
|
||||
"invalidCount": "({{count}} invalides)",
|
||||
"ambiguousIntro": "Les proxys suivants ont un format ambigu. Sélectionnez la bonne interprétation pour chacun.",
|
||||
"imported": "Importés :",
|
||||
"skippedDuplicates": "Ignorés (doublons) :",
|
||||
"errors": "Erreurs",
|
||||
"importButton": "Importer {{count}} proxys",
|
||||
"continueButton": "Continuer",
|
||||
"doneButton": "Terminé",
|
||||
"failed": "Échec de l'import des proxys"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -343,7 +475,31 @@
|
||||
"sync": {
|
||||
"enabled": "Synchronisation activée",
|
||||
"disabled": "Synchronisation désactivée"
|
||||
}
|
||||
},
|
||||
"createTitle": "Créer un Nouveau Groupe",
|
||||
"createDescription": "Créez un nouveau groupe pour organiser vos profils de navigateur.",
|
||||
"editTitle": "Modifier le Groupe",
|
||||
"editDescription": "Mettez à jour le nom de votre groupe.",
|
||||
"createSuccess": "Groupe créé avec succès",
|
||||
"createFailed": "Échec de la création du groupe",
|
||||
"updateSuccess": "Groupe mis à jour avec succès",
|
||||
"updateFailed": "Échec de la mise à jour du groupe",
|
||||
"deleteTitle": "Supprimer le Groupe",
|
||||
"deleteDescription": "Cette action est irréversible. Cela supprimera définitivement le groupe.",
|
||||
"deleteSuccess": "Groupe supprimé avec succès",
|
||||
"deleteFailed": "Échec de la suppression du groupe",
|
||||
"loadingProfiles": "Chargement des profils associés...",
|
||||
"associatedProfiles": "Profils Associés ({{count}})",
|
||||
"whatToDoWithProfiles": "Que faire de ces profils ?",
|
||||
"moveToDefaultOption": "Déplacer les profils vers le groupe Par défaut",
|
||||
"deleteAlongWithGroup": "Supprimer les profils avec le groupe",
|
||||
"noAssociatedProfiles": "Ce groupe n'a pas de profils associés.",
|
||||
"deleteGroup": "Supprimer le Groupe",
|
||||
"deleteGroupAndProfiles": "Supprimer le Groupe et les Profils",
|
||||
"loadProfilesFailed": "Échec du chargement des profils",
|
||||
"unknownGroup": "Groupe inconnu",
|
||||
"profileGroupsAriaLabel": "Groupes de profils",
|
||||
"loading": "Chargement des groupes..."
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -366,7 +522,16 @@
|
||||
"configureService": "Configurer le service de synchronisation"
|
||||
},
|
||||
"title": "Service de synchronisation",
|
||||
"config": "Configuration de la synchronisation",
|
||||
"config": {
|
||||
"serverUrlRequired": "Veuillez saisir une URL de serveur",
|
||||
"connectionSuccess": "Connexion réussie !",
|
||||
"serverError": "Le serveur a répondu avec une erreur",
|
||||
"connectFailed": "Échec de la connexion au serveur",
|
||||
"settingsSaved": "Paramètres de synchronisation enregistrés",
|
||||
"saveFailed": "Échec de l’enregistrement des paramètres",
|
||||
"disconnected": "Synchronisation déconnectée",
|
||||
"disconnectFailed": "Échec de la déconnexion"
|
||||
},
|
||||
"serverUrl": "URL du serveur",
|
||||
"serverUrlPlaceholder": "https://sync.exemple.com",
|
||||
"token": "Jeton de synchronisation",
|
||||
@@ -410,6 +575,12 @@
|
||||
"profileLockedShort": "En cours d'utilisation",
|
||||
"cannotLaunchLocked": "Impossible de lancer — le profil est utilisé par {{email}}",
|
||||
"createdBy": "Créé par {{email}}"
|
||||
},
|
||||
"disabled": "Désactivée",
|
||||
"toast": {
|
||||
"profileSynced": "Profil '{{name}}' synchronisé avec succès",
|
||||
"profileSyncFailed": "Échec de la synchronisation du profil '{{name}}'",
|
||||
"profileSyncFailedWithError": "Échec de la synchronisation du profil '{{name}}' : {{error}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -447,7 +618,32 @@
|
||||
"removedFromClaudeCode": "Supprimé de Claude Code",
|
||||
"config": "Configuration MCP",
|
||||
"copyConfig": "Copier la configuration"
|
||||
}
|
||||
},
|
||||
"tabApi": "API locale",
|
||||
"tabMcp": "MCP (Assistants IA)",
|
||||
"apiEnableLabel": "Activer le serveur API local",
|
||||
"apiEnableDescription": "Permet de gérer les profils, groupes et proxys via l'API REST.",
|
||||
"apiPortLabel": "Port",
|
||||
"apiTokenLabel": "Jeton d'authentification",
|
||||
"apiTokenHint": "Inclure dans l'en-tête Authorization : Bearer {{tokenSlot}}",
|
||||
"apiInvalidPort": "Port invalide",
|
||||
"apiInvalidPortDescription": "Le port doit être entre 1 et 65535",
|
||||
"apiPortInUse": "Le port {{port}} est déjà utilisé",
|
||||
"apiFallbackPort": "Serveur démarré sur le port alternatif {{port}}",
|
||||
"apiStarted": "Serveur API démarré sur le port {{port}}",
|
||||
"apiRunning": "Serveur API en cours sur le port {{port}}",
|
||||
"apiStopped": "Serveur API arrêté",
|
||||
"apiToggleFailed": "Échec du basculement du serveur API",
|
||||
"apiStartFailed": "Échec du démarrage du serveur API",
|
||||
"apiUnknownError": "Erreur inconnue",
|
||||
"tokenCopied": "Jeton copié",
|
||||
"mcpEnableLabel": "Activer le serveur MCP (Model Context Protocol)",
|
||||
"mcpEnableDescription": "Permet aux assistants IA comme Claude Desktop de contrôler les navigateurs.",
|
||||
"mcpAcceptTermsFirst": "(Acceptez d'abord les conditions Wayfern dans les Paramètres)",
|
||||
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
|
||||
"mcpStopped": "Serveur MCP arrêté",
|
||||
"mcpToggleFailed": "Échec du basculement du serveur MCP",
|
||||
"openSettings": "Ouvrir les paramètres d'intégrations"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer un profil",
|
||||
@@ -465,7 +661,9 @@
|
||||
"fingerprint": {
|
||||
"title": "Empreinte digitale",
|
||||
"randomize": "Randomiser au lancement",
|
||||
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur."
|
||||
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur.",
|
||||
"osCpuPlaceholder": "p. ex., Intel Mac OS X 10.15",
|
||||
"webglRendererPlaceholder": "p. ex., llvmpipe, ou similaire"
|
||||
},
|
||||
"os": {
|
||||
"title": "Système d'exploitation",
|
||||
@@ -499,7 +697,10 @@
|
||||
"fingerprint": {
|
||||
"title": "Empreinte digitale",
|
||||
"randomize": "Randomiser au lancement",
|
||||
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur."
|
||||
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur.",
|
||||
"platformPlaceholder": "p. ex., Win32, MacIntel, Linux x86_64",
|
||||
"timezoneOffsetPlaceholder": "p. ex., 300 pour EST (UTC-5)",
|
||||
"webglRendererPlaceholder": "p. ex., Intel(R) HD Graphics"
|
||||
},
|
||||
"os": {
|
||||
"title": "Système d'exploitation",
|
||||
@@ -522,6 +723,10 @@
|
||||
"webrtc": "Bloquer WebRTC",
|
||||
"webgl": "Bloquer WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Comportement du navigateur",
|
||||
"allowAddonsOpenTabs": "Autoriser les modules complémentaires à ouvrir automatiquement de nouveaux onglets"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -534,13 +739,53 @@
|
||||
"selectCookies": "Sélectionner les cookies",
|
||||
"allDomains": "Tous les domaines",
|
||||
"selectedCount": "{{count}} cookie sélectionné",
|
||||
"selectedCount_plural": "{{count}} cookies sélectionnés"
|
||||
"selectedCount_plural": "{{count}} cookies sélectionnés",
|
||||
"dialogDescription_one": "Copier les cookies d'un profil source vers {{count}} profil sélectionné.",
|
||||
"dialogDescription_other": "Copier les cookies d'un profil source vers {{count}} profils sélectionnés.",
|
||||
"sourceProfile": "Profil source",
|
||||
"sourcePlaceholder": "Sélectionnez un profil pour copier les cookies",
|
||||
"running": "(en cours)",
|
||||
"targetProfiles": "Profils cibles ({{count}})",
|
||||
"noOtherTargets": "Aucun autre profil Wayfern/Camoufox sélectionné",
|
||||
"selectSourceFirst": "Sélectionnez d'abord un profil source",
|
||||
"selectionStatus": "({{selected}} sur {{total}} sélectionnés)",
|
||||
"searchPlaceholder": "Rechercher des domaines ou cookies...",
|
||||
"noMatching": "Aucun cookie correspondant trouvé",
|
||||
"noFound": "Aucun cookie trouvé",
|
||||
"replaceNote": "Les cookies existants avec le même nom et domaine seront remplacés. Les autres cookies seront conservés.",
|
||||
"cannotCopyRunningOne": "Impossible de copier les cookies : {{names}} est encore en cours.",
|
||||
"cannotCopyRunningMany": "Impossible de copier les cookies : {{names}} sont encore en cours.",
|
||||
"someErrors": "Des erreurs sont survenues : {{errors}}",
|
||||
"successMessage": "{{copied}} cookies copiés avec succès ({{replaced}} remplacés)",
|
||||
"failedMessage": "Échec de la copie des cookies : {{error}}",
|
||||
"copyButton_one": "Copier {{count}} cookie",
|
||||
"copyButton_other": "Copier {{count}} cookies",
|
||||
"copyButtonEmpty": "Copier les cookies"
|
||||
},
|
||||
"success": "Cookies copiés avec succès",
|
||||
"error": "Échec de la copie des cookies",
|
||||
"management": {
|
||||
"title": "Gestion des Cookies",
|
||||
"menuItem": "Gestion des Cookies"
|
||||
"menuItem": "Gestion des Cookies",
|
||||
"tabImport": "Importer",
|
||||
"tabExport": "Exporter",
|
||||
"importDescription": "Importer des cookies depuis un fichier au format Netscape ou JSON.",
|
||||
"dropPrompt": "Cliquez pour choisir un fichier de cookies",
|
||||
"fileFormats": "(.txt, .cookies ou .json)",
|
||||
"cookiesFound": "{{count}} cookies trouvés",
|
||||
"importedSuccess": "{{imported}} cookies importés avec succès ({{replaced}} remplacés)",
|
||||
"linesSkipped": "{{count}} ligne(s) ignorée(s)",
|
||||
"fileReadError": "Échec de la lecture du fichier",
|
||||
"loadFailed": "Échec du chargement des cookies : {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
"selectionStatus": "({{selected}} sur {{total}} sélectionnés)",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"deselectAll": "Tout désélectionner",
|
||||
"noCookies": "Aucun cookie trouvé dans ce profil",
|
||||
"doneButton": "Terminé",
|
||||
"importButton": "Importer",
|
||||
"exportButton": "Exporter",
|
||||
"backButton": "Retour"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer des Cookies",
|
||||
@@ -623,7 +868,31 @@
|
||||
"maxLength": "Doit contenir au maximum {{max}} caractères",
|
||||
"networkError": "Erreur réseau. Veuillez vérifier votre connexion.",
|
||||
"serverError": "Erreur serveur. Veuillez réessayer plus tard.",
|
||||
"unknownError": "Une erreur inconnue s'est produite. Veuillez réessayer."
|
||||
"unknownError": "Une erreur inconnue s'est produite. Veuillez réessayer.",
|
||||
"noProfilesForUrl": "Aucun profil disponible. Veuillez créer un profil avant d’ouvrir des URL.",
|
||||
"updateCamoufoxConfigFailed": "Échec de la mise à jour de la configuration camoufox : {{error}}",
|
||||
"updateWayfernConfigFailed": "Échec de la mise à jour de la configuration wayfern : {{error}}",
|
||||
"createProfileFailed": "Échec de la création du profil : {{error}}",
|
||||
"launchBrowserFailed": "Échec du lancement du navigateur : {{error}}",
|
||||
"cannotDeleteRunningProfile": "Impossible de supprimer le profil pendant que le navigateur est en cours d’exécution. Arrêtez d’abord le navigateur.",
|
||||
"deleteProfileFailed": "Échec de la suppression du profil : {{error}}",
|
||||
"renameProfileFailed": "Échec du renommage du profil : {{error}}",
|
||||
"killBrowserFailed": "Échec de l’arrêt du navigateur : {{error}}",
|
||||
"deleteSelectedProfilesFailed": "Échec de la suppression des profils sélectionnés : {{error}}",
|
||||
"cookieCopyUnsupportedBrowser": "La copie de cookies ne fonctionne qu’avec les profils Wayfern et Camoufox",
|
||||
"updateSyncSettingsFailed": "Échec de la mise à jour des paramètres de synchronisation",
|
||||
"cloneProfileFailed": "Échec du clonage du profil : {{error}}",
|
||||
"loadSupportedBrowsersFailed": "Échec du chargement des navigateurs pris en charge",
|
||||
"setupExtensionListenersFailed": "Échec de la configuration des écouteurs d’événements d’extensions : {{error}}",
|
||||
"loadGroupsFailed": "Échec du chargement des groupes : {{error}}",
|
||||
"setupGroupListenersFailed": "Échec de la configuration des écouteurs d’événements de groupes : {{error}}",
|
||||
"loadProfilesFailed": "Échec du chargement des profils : {{error}}",
|
||||
"setupProfileListenersFailed": "Échec de la configuration des écouteurs d’événements de profils : {{error}}",
|
||||
"loadProxiesFailed": "Échec du chargement des proxies : {{error}}",
|
||||
"setupProxyListenersFailed": "Échec de la configuration des écouteurs d’événements de proxies : {{error}}",
|
||||
"loadVpnConfigsFailed": "Échec du chargement des configurations VPN : {{error}}",
|
||||
"setupVpnListenersFailed": "Échec de la configuration des écouteurs d’événements VPN : {{error}}",
|
||||
"themeNotFound": "Thème Tokyo Night introuvable"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -654,10 +923,10 @@
|
||||
"platform": "Plateforme",
|
||||
"platformVersion": "Version de la plateforme",
|
||||
"appVersion": "Version de l'application",
|
||||
"osCpu": "OS CPU",
|
||||
"osCpu": "CPU OS",
|
||||
"hardwareConcurrency": "Concurrence matérielle",
|
||||
"maxTouchPoints": "Points tactiles maximum",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "Ne pas suivre",
|
||||
"selectDntPlaceholder": "Sélectionner la valeur DNT",
|
||||
"dntAllowed": "0 (suivi autorisé)",
|
||||
"dntNotAllowed": "1 (suivi non autorisé)",
|
||||
@@ -679,8 +948,8 @@
|
||||
"outerHeight": "Hauteur extérieure",
|
||||
"innerWidth": "Largeur intérieure",
|
||||
"innerHeight": "Hauteur intérieure",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "Écran X",
|
||||
"screenY": "Écran Y",
|
||||
"geolocation": "Géolocalisation",
|
||||
"timezoneAndGeolocation": "Fuseau horaire et géolocalisation",
|
||||
"timezoneGeolocationDescription": "Ces valeurs remplacent les APIs de fuseau horaire et de géolocalisation du navigateur.",
|
||||
@@ -694,15 +963,15 @@
|
||||
"region": "Région",
|
||||
"script": "Script",
|
||||
"webglProperties": "Propriétés WebGL",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "Fournisseur WebGL",
|
||||
"webglRenderer": "Moteur de rendu WebGL",
|
||||
"webglParameters": "Paramètres WebGL",
|
||||
"webglParametersJson": "Paramètres WebGL (JSON)",
|
||||
"webgl2Parameters": "Paramètres WebGL2",
|
||||
"webglShaderPrecisionFormats": "Formats de précision WebGL Shader",
|
||||
"webgl2ShaderPrecisionFormats": "Formats de précision WebGL2 Shader",
|
||||
"webglShaderPrecisionFormats": "Formats de précision shader WebGL",
|
||||
"webgl2ShaderPrecisionFormats": "Formats de précision shader WebGL2",
|
||||
"canvasFingerprint": "Canvas Fingerprint",
|
||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
||||
"canvasNoiseSeed": "Graine de bruit Canvas",
|
||||
"canvasNoiseSeedDescription": "Cette graine est utilisée pour générer une empreinte Canvas cohérente mais unique. Chaque profil doit avoir une graine différente.",
|
||||
"fonts": "Polices",
|
||||
"fontsJson": "Polices (JSON array)",
|
||||
@@ -723,13 +992,16 @@
|
||||
"maxChannelCount": "Nombre maximum de canaux",
|
||||
"vendorInfo": "Informations du fournisseur",
|
||||
"vendor": "Fournisseur",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "Fournisseur Sub",
|
||||
"productSub": "Produit Sub",
|
||||
"brand": "Marque",
|
||||
"brandVersion": "Version de la marque",
|
||||
"proFeature": "Ceci est une fonctionnalité Pro",
|
||||
"generateFingerprint": "Générer l'empreinte",
|
||||
"refreshFingerprint": "Actualiser l'empreinte"
|
||||
"refreshFingerprint": "Actualiser l'empreinte",
|
||||
"canvasNoiseSeedPlaceholder": "Entrez une graine pour l'empreinte canvas",
|
||||
"addFontsPlaceholder": "Ajouter des polices...",
|
||||
"enterAsJson": "Entrez {{title}} en JSON"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Dimensions de fenêtre personnalisées",
|
||||
@@ -812,16 +1084,6 @@
|
||||
"button": "Cloner"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN n'est pas installé",
|
||||
"openVpnMissingDescription": "Vous pouvez enregistrer cette configuration, mais Donut Browser ne pourra pas s'y connecter tant qu'OpenVPN n'est pas installé sur cet appareil.",
|
||||
"openVpnAdapterMissingTitle": "L'adaptateur OpenVPN est manquant",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN est installé, mais aucun adaptateur TAP/Wintun/ovpn-dco n'a été trouvé. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows.",
|
||||
"openVpnCheckFailedTitle": "L'installation d'OpenVPN n'a pas pu être vérifiée",
|
||||
"openVpnCheckFailedDescription": "Donut Browser n'a pas pu inspecter l'installation locale d'OpenVPN. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.",
|
||||
@@ -879,7 +1141,9 @@
|
||||
"syncEnabled": "Synchronisation activée",
|
||||
"syncDisabled": "Synchronisation désactivée",
|
||||
"syncEnableTooltip": "Activer la synchronisation",
|
||||
"syncDisableTooltip": "Désactiver la synchronisation"
|
||||
"syncDisableTooltip": "Désactiver la synchronisation",
|
||||
"loadGroupsFailed": "Échec du chargement des groupes d'extensions",
|
||||
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
@@ -892,11 +1156,11 @@
|
||||
"dnsBlocklist": {
|
||||
"title": "Liste de blocage DNS",
|
||||
"none": "Aucun",
|
||||
"light": "Light",
|
||||
"light": "Léger",
|
||||
"normal": "Normal",
|
||||
"pro": "Pro",
|
||||
"proPlus": "Pro++",
|
||||
"ultimate": "Ultimate",
|
||||
"ultimate": "Ultime",
|
||||
"settingsDescription": "Les listes de blocage DNS bloquent les publicités, les traqueurs et les domaines malveillants au niveau du proxy. Les listes sont automatiquement rafraîchies toutes les 12 heures.",
|
||||
"manageLists": "Gérer les listes de blocage DNS",
|
||||
"refreshAll": "Rafraîchir toutes les listes",
|
||||
@@ -905,5 +1169,421 @@
|
||||
"fresh": "À jour",
|
||||
"stale": "Obsolète",
|
||||
"notCached": "Non mis en cache"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Modifier le VPN",
|
||||
"titleCreate": "Créer un VPN WireGuard",
|
||||
"descEdit": "Mettez à jour le nom de votre configuration VPN.",
|
||||
"descCreate": "Saisissez les détails de l'interface et du pair WireGuard.",
|
||||
"name": "Nom",
|
||||
"namePlaceholder": "ex. WireGuard Maison",
|
||||
"privateKey": "Clé Privée",
|
||||
"privateKeyPlaceholder": "Clé privée encodée en Base64",
|
||||
"address": "Adresse",
|
||||
"addressPlaceholder": "ex. 10.0.0.2/24",
|
||||
"dnsOptional": "DNS (optionnel)",
|
||||
"dnsPlaceholder": "ex. 1.1.1.1",
|
||||
"mtuOptional": "MTU (optionnel)",
|
||||
"mtuPlaceholder": "ex. 1420",
|
||||
"peerPublicKey": "Clé Publique du Pair",
|
||||
"peerPublicKeyPlaceholder": "Clé publique du pair encodée en Base64",
|
||||
"peerEndpoint": "Endpoint du Pair",
|
||||
"peerEndpointPlaceholder": "ex. vpn.example.com:51820",
|
||||
"allowedIps": "IPs Autorisées",
|
||||
"allowedIpsPlaceholder": "ex. 0.0.0.0/0, ::/0",
|
||||
"keepaliveOptional": "Keepalive Persistant (optionnel)",
|
||||
"keepalivePlaceholder": "ex. 25",
|
||||
"presharedKeyOptional": "Clé Pré-Partagée (optionnel)",
|
||||
"presharedKeyPlaceholder": "Clé pré-partagée encodée en Base64",
|
||||
"updateButton": "Mettre à Jour le VPN",
|
||||
"createButton": "Créer le VPN",
|
||||
"nameRequired": "Le nom du VPN est requis",
|
||||
"privateKeyRequired": "La clé privée est requise",
|
||||
"addressRequired": "L'adresse est requise",
|
||||
"peerPublicKeyRequired": "La clé publique du pair est requise",
|
||||
"peerEndpointRequired": "L'endpoint du pair est requis",
|
||||
"updated": "VPN mis à jour avec succès",
|
||||
"created": "VPN WireGuard créé avec succès",
|
||||
"updateFailed": "Échec de la mise à jour du VPN : {{error}}",
|
||||
"createFailed": "Échec de la création du VPN : {{error}}"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer la Configuration VPN",
|
||||
"descDropzone": "Importez un fichier de configuration WireGuard (.conf)",
|
||||
"descPreview": "Vérifiez la configuration VPN à importer",
|
||||
"descResult": "Importation VPN terminée",
|
||||
"dropzonePrompt": "Déposez un fichier .conf WireGuard ici ou cliquez pour parcourir",
|
||||
"pasteHint": "Coller depuis le presse-papiers avec {{modKey}}+V",
|
||||
"invalidContent": "Le contenu ne semble pas être une configuration VPN valide",
|
||||
"fileReadError": "Échec de la lecture du fichier",
|
||||
"wrongFileType": "Veuillez déposer un fichier .conf WireGuard",
|
||||
"configurationLabel": "Configuration {{type}}",
|
||||
"endpointLabel": "Endpoint : {{endpoint}}",
|
||||
"vpnNameLabel": "Nom du VPN",
|
||||
"vpnNamePlaceholder": "Mon VPN",
|
||||
"configPreview": "Aperçu de la Configuration",
|
||||
"importedSuccess": "VPN Importé avec Succès",
|
||||
"importFailed": "Échec de l'Importation",
|
||||
"importButton": "Importer le VPN",
|
||||
"doneButton": "Terminé",
|
||||
"failedGeneric": "Échec de l'import de la configuration VPN",
|
||||
"defaultName": "VPN {{type}}"
|
||||
},
|
||||
"management": {
|
||||
"loading": "Chargement des VPN...",
|
||||
"noneCreated": "Aucune configuration VPN. Importez ou créez-en une avec les boutons ci-dessus.",
|
||||
"editVpn": "Modifier le VPN",
|
||||
"deleteVpn": "Supprimer le VPN",
|
||||
"cannotDelete_one": "Suppression impossible : utilisé par {{count}} profil",
|
||||
"cannotDelete_other": "Suppression impossible : utilisé par {{count}} profils",
|
||||
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce VPN est utilisé par des profils synchronisés",
|
||||
"deleteSuccess": "VPN supprimé avec succès",
|
||||
"deleteFailed": "Échec de la suppression du VPN",
|
||||
"deleteTitle": "Supprimer le VPN",
|
||||
"deleteDescription": "Cette action est irréversible. Le VPN « {{name}} » sera supprimé définitivement."
|
||||
}
|
||||
},
|
||||
"importProfile": {
|
||||
"title": "Importer un profil de navigateur",
|
||||
"autoDetect": "Détection automatique",
|
||||
"manualImport": "Import manuel",
|
||||
"detectedProfilesTitle": "Profils de navigateur détectés",
|
||||
"scanning": "Recherche de profils de navigateur...",
|
||||
"noneFound": "Aucun profil de navigateur trouvé sur votre système.",
|
||||
"noneFoundHint": "Essayez l'import manuel si vos profils sont dans des emplacements personnalisés.",
|
||||
"selectProfile": "Sélectionner un profil :",
|
||||
"selectProfilePlaceholder": "Choisissez un profil détecté",
|
||||
"pathLabel": "Chemin :",
|
||||
"browserLabel": "Navigateur :",
|
||||
"newProfileName": "Nom du nouveau profil :",
|
||||
"newProfileNamePlaceholder": "Entrez un nom pour le profil importé",
|
||||
"manualTitle": "Import manuel de profil",
|
||||
"browserType": "Type de navigateur :",
|
||||
"loadingBrowsers": "Chargement des navigateurs...",
|
||||
"selectBrowserType": "Sélectionnez le type de navigateur",
|
||||
"profileFolderPath": "Chemin du dossier du profil :",
|
||||
"profileFolderPlaceholder": "Entrez le chemin complet vers le dossier du profil",
|
||||
"browseFolderTitle": "Parcourir le dossier",
|
||||
"examplePaths": "Exemples de chemins :",
|
||||
"selectFolderTitle": "Sélectionnez un dossier de profil de navigateur",
|
||||
"folderDialogFailed": "Échec de l'ouverture de la boîte de dialogue de dossier",
|
||||
"detectFailed": "Échec de la détection des profils de navigateur existants",
|
||||
"fillFields": "Veuillez remplir tous les champs",
|
||||
"selectAndName": "Sélectionnez un profil et fournissez un nom",
|
||||
"profileNotFound": "Profil sélectionné introuvable",
|
||||
"importedSuccess": "Profil « {{name}} » importé avec succès",
|
||||
"notInstalled": "{{browser}} n'est pas installé. Veuillez télécharger {{browser}} depuis la fenêtre principale puis réessayer.",
|
||||
"importFailed": "Échec de l'import du profil : {{error}}",
|
||||
"proxyOptional": "Proxy (optionnel)",
|
||||
"noProxy": "Aucun proxy",
|
||||
"nextButton": "Suivant",
|
||||
"importButton": "Importer",
|
||||
"importedAs": "Ce profil sera importé en tant que profil {{browser}}."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Synchronisation...",
|
||||
"syncedAt": "Synchronisé {{time}}",
|
||||
"synced": "Synchronisé",
|
||||
"waiting": "En attente de synchronisation",
|
||||
"errorWith": "Erreur de synchronisation : {{error}}",
|
||||
"error": "Erreur de synchronisation",
|
||||
"notSynced": "Non synchronisé"
|
||||
},
|
||||
"groupManagement": {
|
||||
"description": "Gérez vos groupes de profils",
|
||||
"createGroup": "Créer un groupe",
|
||||
"noGroups": "Aucun groupe créé. Créez votre premier groupe avec le bouton ci-dessus.",
|
||||
"loading": "Chargement des groupes...",
|
||||
"profileCount_one": "{{count}} profil",
|
||||
"profileCount_other": "{{count}} profils",
|
||||
"groupsLabel": "Groupes",
|
||||
"profilesCol": "Profils",
|
||||
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce groupe est utilisé par des profils synchronisés",
|
||||
"editGroupTooltip": "Modifier le groupe",
|
||||
"deleteGroupTooltip": "Supprimer le groupe",
|
||||
"loadFailed": "Échec du chargement des groupes"
|
||||
},
|
||||
"proxyAssignment": {
|
||||
"title": "Assigner un proxy / VPN",
|
||||
"description_one": "Assigner un proxy ou VPN à {{count}} profil sélectionné.",
|
||||
"description_other": "Assigner un proxy ou VPN à {{count}} profils sélectionnés.",
|
||||
"selectLabel": "Proxy / VPN",
|
||||
"placeholder": "Sélectionnez un proxy ou VPN",
|
||||
"noProxy": "Aucun proxy / VPN",
|
||||
"searchPlaceholder": "Rechercher des proxys ou VPN...",
|
||||
"notFound": "Aucun proxy ou VPN trouvé.",
|
||||
"assignButton": "Assigner",
|
||||
"success": "Proxy/VPN assigné à {{count}} profil(s)",
|
||||
"failed": "Échec de l'assignation du proxy/VPN",
|
||||
"selectedProfilesLabel": "Profils sélectionnés :",
|
||||
"assignProxyVpnLabel": "Assigner un proxy / VPN :",
|
||||
"noneOption": "Aucun",
|
||||
"noValidProfiles": "Aucun profil valide sélectionné.",
|
||||
"vpnGroupHeading": "VPN",
|
||||
"failedFallback": "Échec de l'assignation du proxy/VPN aux profils"
|
||||
},
|
||||
"groupAssignment": {
|
||||
"title": "Assigner un groupe",
|
||||
"description_one": "Assigner un groupe à {{count}} profil sélectionné.",
|
||||
"description_other": "Assigner un groupe à {{count}} profils sélectionnés.",
|
||||
"selectLabel": "Groupe",
|
||||
"placeholder": "Sélectionnez un groupe",
|
||||
"noGroup": "Aucun groupe (par défaut)",
|
||||
"assignButton": "Assigner",
|
||||
"success": "Groupe assigné à {{count}} profil(s)",
|
||||
"failed": "Échec de l'assignation du groupe",
|
||||
"selectedProfilesLabel": "Profils sélectionnés :",
|
||||
"assignGroupLabel": "Assigner à un groupe :",
|
||||
"noValidProfiles": "Aucun profil valide sélectionné.",
|
||||
"failedFallback": "Échec de l'assignation du groupe aux profils"
|
||||
},
|
||||
"profileSelector": {
|
||||
"title": "Sélectionner un profil",
|
||||
"description": "Choisissez un profil pour ouvrir cette URL",
|
||||
"searchPlaceholder": "Rechercher des profils...",
|
||||
"noProfiles": "Aucun profil disponible",
|
||||
"noResults": "Aucun profil ne correspond à votre recherche",
|
||||
"selectButton": "Sélectionner",
|
||||
"launching": "Lancement...",
|
||||
"chooseProfileTitle": "Choisir un profil",
|
||||
"openingUrl": "Ouverture de l'URL :",
|
||||
"urlCopied": "URL copiée dans le presse-papiers !",
|
||||
"selectProfileLabel": "Sélectionner un profil :",
|
||||
"noneAvailableShort": "Aucun profil disponible. Veuillez d'abord créer un profil.",
|
||||
"noneAvailableLong": "Fermez cette boîte de dialogue et créez un profil depuis la fenêtre principale pour commencer.",
|
||||
"chooseAProfile": "Choisissez un profil",
|
||||
"badgeProxy": "Proxy",
|
||||
"badgeRunning": "En cours",
|
||||
"badgeUnavailable": "Indisponible",
|
||||
"openButton": "Ouvrir"
|
||||
},
|
||||
"locationProxy": {
|
||||
"title": "Proxy rapide par lieu",
|
||||
"description": "Choisissez un pays pour router ce profil. Un proxy sera créé automatiquement.",
|
||||
"country": "Pays",
|
||||
"selectCountry": "Sélectionnez un pays",
|
||||
"searchCountry": "Rechercher un pays...",
|
||||
"noCountriesFound": "Aucun pays trouvé.",
|
||||
"apply": "Appliquer",
|
||||
"creating": "Création du proxy...",
|
||||
"success": "Proxy de localisation appliqué",
|
||||
"failed": "Échec de l'application du proxy de localisation",
|
||||
"titleCreate": "Créer un proxy de localisation",
|
||||
"descriptionCreate": "Créez un proxy géolocalisé avec une session persistante de 24 heures",
|
||||
"countryLabel": "Pays (obligatoire)",
|
||||
"regionLabel": "Région (optionnel)",
|
||||
"cityLabel": "Ville (optionnel)",
|
||||
"ispLabel": "FAI (optionnel)",
|
||||
"nameLabel": "Nom",
|
||||
"namePlaceholder": "Nom du proxy",
|
||||
"loadingCountries": "Chargement des pays...",
|
||||
"selectCountryPh": "Sélectionnez un pays",
|
||||
"searchCountries": "Rechercher des pays...",
|
||||
"loadFailed": "Échec du chargement des pays",
|
||||
"selectCountryFirst": "Sélectionnez d'abord un pays",
|
||||
"loadingRegions": "Chargement des régions...",
|
||||
"noRegions": "Aucune région disponible",
|
||||
"selectRegion": "Sélectionnez une région",
|
||||
"searchRegions": "Rechercher des régions...",
|
||||
"loadingCities": "Chargement des villes...",
|
||||
"noCities": "Aucune ville disponible",
|
||||
"selectCity": "Sélectionnez une ville",
|
||||
"searchCities": "Rechercher des villes...",
|
||||
"loadingIsps": "Chargement des FAI...",
|
||||
"noIsps": "Aucun FAI disponible",
|
||||
"selectIsp": "Sélectionnez un FAI",
|
||||
"searchIsps": "Rechercher des FAI...",
|
||||
"createSuccess": "Proxy de localisation créé",
|
||||
"createFailed": "Échec de la création du proxy de localisation",
|
||||
"creatingButton": "Création...",
|
||||
"createButton": "Créer"
|
||||
},
|
||||
"launchOnLogin": {
|
||||
"title": "Activer le démarrage à la connexion ?",
|
||||
"description": "Tourner en arrière-plan permet de garder vos proxys et navigateurs actifs.",
|
||||
"declineButton": "Ne plus demander",
|
||||
"declining": "...",
|
||||
"enableButton": "Activer",
|
||||
"enableSuccess": "Démarrage à la connexion activé",
|
||||
"enableFailed": "Échec de l'activation du démarrage à la connexion",
|
||||
"declineFailed": "Échec de l'enregistrement de la préférence",
|
||||
"tryAgain": "Veuillez réessayer"
|
||||
},
|
||||
"wayfernTerms": {
|
||||
"title": "Conditions générales de Wayfern",
|
||||
"description": "Avant d'utiliser Donut Browser, vous devez lire et accepter les Conditions Générales de Wayfern.",
|
||||
"reviewLabel": "Veuillez consulter les Conditions Générales sur :",
|
||||
"agreeNotice": "En cliquant sur « J'accepte », vous acceptez ces conditions.",
|
||||
"acceptButton": "J'accepte",
|
||||
"acceptSuccess": "Conditions acceptées avec succès",
|
||||
"acceptFailed": "Échec de l'acceptation des conditions",
|
||||
"tryAgain": "Veuillez réessayer"
|
||||
},
|
||||
"commercialTrial": {
|
||||
"title": "Période d'essai commerciale expirée",
|
||||
"description": "Votre période d'essai commercial de 2 semaines est terminée.",
|
||||
"body": "Si vous utilisez Donut Browser à des fins professionnelles, vous devez acheter une licence commerciale pour continuer. Vous pouvez toujours l'utiliser gratuitement à des fins personnelles.",
|
||||
"understandButton": "J'ai compris",
|
||||
"failed": "Échec de l'enregistrement de la confirmation",
|
||||
"tryAgain": "Veuillez réessayer"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"titleMicrophone": "Accès au microphone requis",
|
||||
"titleCamera": "Accès à la caméra requis",
|
||||
"descMicrophone": "Donut Browser a besoin d'accéder à votre microphone pour activer la fonctionnalité microphone dans les navigateurs. Chaque site web qui voudra utiliser votre microphone vous demandera quand même votre permission individuellement.",
|
||||
"descCamera": "Donut Browser a besoin d'accéder à votre caméra pour activer la fonctionnalité caméra dans les navigateurs. Chaque site web qui voudra utiliser votre caméra vous demandera quand même votre permission individuellement.",
|
||||
"grantedMicrophone": "Permission accordée ! Les navigateurs lancés depuis Donut Browser peuvent maintenant accéder à votre microphone.",
|
||||
"grantedCamera": "Permission accordée ! Les navigateurs lancés depuis Donut Browser peuvent maintenant accéder à votre caméra.",
|
||||
"notGrantedMicrophone": "Permission non accordée. Cliquez sur le bouton pour demander l'accès à votre microphone.",
|
||||
"notGrantedCamera": "Permission non accordée. Cliquez sur le bouton pour demander l'accès à votre caméra.",
|
||||
"doneButton": "Terminé",
|
||||
"cancelButton": "Annuler",
|
||||
"grantAccessButton": "Accorder l'accès",
|
||||
"requestSuccessMicrophone": "Accès au microphone demandé",
|
||||
"requestSuccessCamera": "Accès à la caméra demandé",
|
||||
"requestFailed": "Échec de la demande de permission"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Détails du trafic",
|
||||
"bandwidthOverTime": "Bande passante au fil du temps",
|
||||
"timePeriodPlaceholder": "Période",
|
||||
"last1m": "Dernière 1 min",
|
||||
"last5m": "Dernières 5 min",
|
||||
"last30m": "Dernières 30 min",
|
||||
"last1h": "Dernière 1 h",
|
||||
"last2h": "Dernières 2 h",
|
||||
"last4h": "Dernières 4 h",
|
||||
"last1d": "Dernier jour",
|
||||
"last7d": "Derniers 7 jours",
|
||||
"last30d": "Derniers 30 jours",
|
||||
"allTime": "Tout",
|
||||
"allTimeShort": "tout le temps",
|
||||
"totalSuffix": "total",
|
||||
"sentLabel": "Envoyé ({{period}})",
|
||||
"receivedLabel": "Reçu ({{period}})",
|
||||
"requestsLabel": "Requêtes ({{period}})",
|
||||
"allTimeTraffic": "Trafic total :",
|
||||
"allTimeRequests": "Requêtes totales :",
|
||||
"proxyDisclaimer": "Note : Si vous utilisez un proxy, VPN ou service similaire, votre fournisseur peut calculer le trafic différemment en raison du surcoût de chiffrement et des différences de protocole.",
|
||||
"topByTraffic": "Principaux domaines par trafic ({{period}})",
|
||||
"topByRequests": "Principaux domaines par requêtes ({{period}})",
|
||||
"columnDomain": "Domaine",
|
||||
"columnRequests": "Requêtes",
|
||||
"columnSent": "Envoyé",
|
||||
"columnReceived": "Reçu",
|
||||
"columnTotal": "Trafic total",
|
||||
"uniqueIps": "IPs uniques ({{count}})",
|
||||
"noData": "Aucune donnée de trafic disponible pour ce profil.",
|
||||
"noDataHint": "Les données de trafic apparaîtront après le lancement du profil.",
|
||||
"sentLegend": "Envoyé",
|
||||
"receivedLegend": "Reçu",
|
||||
"tooltipSent": "↑ Envoyé : ",
|
||||
"tooltipReceived": "↓ Reçu : "
|
||||
},
|
||||
"camoufoxDialog": {
|
||||
"titleView": "Voir les paramètres d'empreinte - {{name}} ({{browser}})",
|
||||
"titleConfigure": "Configurer les paramètres d'empreinte - {{name}} ({{browser}})",
|
||||
"invalidFingerprint": "Configuration d'empreinte invalide",
|
||||
"invalidFingerprintDescription": "La configuration d'empreinte contient du JSON invalide. Vérifiez vos paramètres avancés.",
|
||||
"saveFailed": "Échec de la sauvegarde de la configuration",
|
||||
"unknownError": "Une erreur inconnue s'est produite"
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Inconnu",
|
||||
"locationToast": "L'emplacement de votre proxy est :",
|
||||
"failed": "Échec de la vérification du proxy : {{error}}",
|
||||
"tooltipChecking": "Vérification du proxy...",
|
||||
"tooltipIp": "IP : {{ip}}",
|
||||
"tooltipChecked": "Vérifié {{time}}",
|
||||
"tooltipFailed": "Échec {{time}}",
|
||||
"tooltipFailedTitle": "Échec de la vérification du proxy",
|
||||
"tooltipDefault": "Vérifier la validité du proxy"
|
||||
},
|
||||
"vpnCheck": {
|
||||
"valid": "La configuration VPN « {{name}} » est valide",
|
||||
"invalid": "La configuration VPN « {{name}} » est invalide",
|
||||
"failed": "Échec de la vérification du VPN : {{error}}",
|
||||
"tooltipChecking": "Vérification de la configuration VPN...",
|
||||
"tooltipValid": "Configuration valide",
|
||||
"tooltipInvalid": "Configuration invalide",
|
||||
"tooltipChecked": "Vérifié {{time}}",
|
||||
"tooltipDefault": "Vérifier la validité de la configuration VPN"
|
||||
},
|
||||
"profileTable": {
|
||||
"syncTooltipDisabled": "Sync désactivée",
|
||||
"syncTooltipSyncing": "Synchronisation...",
|
||||
"syncTooltipSyncedAt": "Synchronisé {{time}}",
|
||||
"syncTooltipSynced": "Synchronisé",
|
||||
"syncTooltipWaiting": "En attente de synchronisation",
|
||||
"syncTooltipErrorWith": "Erreur de sync : {{error}}",
|
||||
"syncTooltipError": "Erreur de synchronisation",
|
||||
"syncTooltipNotSynced": "Non synchronisé",
|
||||
"noTags": "Aucune étiquette",
|
||||
"syncTooltipCloseToSync": "Fermez le profil pour synchroniser",
|
||||
"syncTooltipDisabledWithLast": "Sync désactivée, dernière sync {{time}}",
|
||||
"addTagsPlaceholder": "Ajouter des étiquettes",
|
||||
"tagsHeader": "Étiquettes",
|
||||
"noteHeader": "Note",
|
||||
"vpnsHeading": "VPN",
|
||||
"createByCountryHeading": "Créer par pays"
|
||||
},
|
||||
"releaseTypeSelector": {
|
||||
"noReleaseTypes": "Aucun type de version disponible.",
|
||||
"placeholder": "Sélectionnez le type de version...",
|
||||
"stable": "Stable",
|
||||
"nightly": "Nightly",
|
||||
"downloaded": "Téléchargé",
|
||||
"downloadBrowser": "Télécharger le navigateur",
|
||||
"downloading": "Téléchargement..."
|
||||
},
|
||||
"dataTableActionBar": {
|
||||
"selected": "{{count}} sélectionné(s)",
|
||||
"clearSelection": "Effacer la sélection"
|
||||
},
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"updateFailed": "Échec de la mise à jour de Donut Browser",
|
||||
"restartFailed": "Échec du redémarrage",
|
||||
"updateReady": "Mise à jour prête, redémarrer pour appliquer",
|
||||
"manualDownloadRequired": "Téléchargement manuel requis",
|
||||
"restartNow": "Redémarrer maintenant",
|
||||
"viewRelease": "Voir la version",
|
||||
"later": "Plus tard",
|
||||
"uploading": "Envoi",
|
||||
"downloading": "Téléchargement"
|
||||
}
|
||||
},
|
||||
"browserDownload": {
|
||||
"toast": {
|
||||
"fetchVersionsFailed": "Échec de la récupération des versions de {{browser}}",
|
||||
"foundNewVersions": "{{count}} nouvelles versions de {{browser}} trouvées !",
|
||||
"totalAvailableVersions": "Total disponible : {{count}} versions",
|
||||
"downloadFailed": "Échec du téléchargement de {{browser}} {{version}}",
|
||||
"calculating": "calcul en cours...",
|
||||
"extractionFailed": "{{browser}} {{version}} : échec de l’extraction",
|
||||
"extractionFailedDescription": "Le fichier corrompu a été supprimé. Il sera retéléchargé lors de la prochaine tentative.",
|
||||
"extracting": "Extraction des fichiers du navigateur... Ne fermez pas l'application.",
|
||||
"verifying": "Vérification des fichiers du navigateur...",
|
||||
"downloadingRolling": "Téléchargement de la version rolling release..."
|
||||
}
|
||||
},
|
||||
"versionUpdater": {
|
||||
"toast": {
|
||||
"alreadyAvailable": "{{browser}} {{version}} déjà disponible",
|
||||
"updatingProfiles": "Mise à jour des configurations de profil...",
|
||||
"updateCompleted": "Mise à jour de {{browser}} terminée",
|
||||
"singleProfileUpdated": "Le profil « {{name}} » a été mis à jour vers la version {{version}}. Vous pouvez maintenant lancer vos navigateurs avec la dernière version.",
|
||||
"multipleProfilesUpdated": "{{count}} profils ont été mis à jour vers la version {{version}}. Vous pouvez maintenant lancer vos navigateurs avec la dernière version.",
|
||||
"versionAvailable": "La version {{version}} est désormais disponible. Les profils en cours d’exécution utiliseront la nouvelle version au redémarrage.",
|
||||
"autoUpdateFailed": "Échec de la mise à jour automatique de {{browser}}",
|
||||
"updateWithErrors": "Mise à jour terminée avec des erreurs",
|
||||
"updateWithErrorsDescription": "{{newVersions}} nouvelles versions trouvées, {{failedUpdates}} navigateurs n’ont pas pu être mis à jour",
|
||||
"updateSuccess": "Versions de navigateur mises à jour avec succès",
|
||||
"updateSuccessDescription": "{{newVersions}} nouvelles versions trouvées sur {{successfulUpdates}} navigateurs. Les téléchargements automatiques commenceront sous peu.",
|
||||
"upToDate": "Aucune nouvelle version de navigateur trouvée",
|
||||
"upToDateDescription": "Toutes les versions des navigateurs sont à jour",
|
||||
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user