feat: daemon support, general improvement, and preparation for Windows release

This commit is contained in:
zhom
2026-02-01 20:55:09 +04:00
parent e9f4edd120
commit 4a59459eb2
58 changed files with 9763 additions and 296 deletions
+2 -2
View File
@@ -100,7 +100,7 @@ jobs:
- Each array item must be under 80 characters
- overall_assessment must be under 100 characters
- Output ONLY the JSON object, nothing else
model: gpt-5
model: gpt-5-mini
- name: Check if first-time contributor
id: check-first-time
@@ -292,7 +292,7 @@ jobs:
- summary must be under 200 characters
- Be constructive and helpful, not harsh
- Output ONLY the JSON object, nothing else
model: gpt-5
model: gpt-5-mini
- name: Post PR feedback comment
env:
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
working-directory: src-tauri
- name: Run Rust unit tests
run: cargo test --lib && cargo test --test donut_proxy_integration
run: cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration
working-directory: src-tauri
- name: Run Rust sync e2e tests
@@ -122,7 +122,7 @@ jobs:
- If commits are unclear, infer the purpose from the context
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
model: gpt-5
model: gpt-5-mini
- name: Update release with generated notes
if: steps.get-release.outputs.is-prerelease == 'false'
+1 -1
View File
@@ -4,6 +4,6 @@
- Don't leave comments that don't add value.
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times.
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
- Anytime you change nodecar's code and try to test, recompile it with "cd nodecar && pnpm build".
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
- If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
+1 -4
View File
@@ -9,10 +9,7 @@
- Don't leave comments that don't add value
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
## Nodecar
- After changing nodecar's code, recompile it with `cd nodecar && pnpm build` before testing
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Singletons
+4
View File
@@ -63,6 +63,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
If you face any problems while using the application, please [open an issue](https://github.com/zhom/donutbrowser/issues).
## Self-Hosting Sync
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server. See the [Self-Hosting Guide](docs/self-hosting-donut-sync.md) for Docker-based setup instructions.
## Community
Have questions or want to contribute? We'd love to hear from you!
+177
View File
@@ -0,0 +1,177 @@
# Self-Hosting Donut Sync
Donut Sync is the synchronization server for Donut Browser. It allows you to sync your profiles, proxies, and groups across multiple devices. This guide covers how to self-host it using Docker.
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
- An S3-compatible object storage (MinIO included by default, or use AWS S3, Cloudflare R2, etc.)
## Quick Start
### 1. Create a `docker-compose.yml`
```yaml
services:
donut-sync:
image: donutbrowser/donut-sync:latest
ports:
- "3929:3929"
environment:
- SYNC_TOKEN=your-secret-token-here
- PORT=3929
- S3_ENDPOINT=http://minio:9000
- S3_REGION=us-east-1
- S3_ACCESS_KEY_ID=minioadmin
- S3_SECRET_ACCESS_KEY=minioadmin
- S3_BUCKET=donut-sync
- S3_FORCE_PATH_STYLE=true
depends_on:
minio:
condition: service_healthy
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- minio_data:/data
volumes:
minio_data:
```
### 2. Start the services
```bash
docker compose up -d
```
### 3. Verify the server is running
```bash
# Health check
curl http://localhost:3929/health
# Expected: {"status":"ok"}
# Readiness check (verifies S3 connectivity)
curl http://localhost:3929/readyz
# Expected: {"status":"ready","s3":true}
```
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `SYNC_TOKEN` | Yes | - | Bearer token used to authenticate requests from Donut Browser clients |
| `PORT` | No | `3929` | Port the sync server listens on |
| `S3_ENDPOINT` | No | - | S3-compatible endpoint URL (e.g., `http://minio:9000` or `https://s3.amazonaws.com`) |
| `S3_REGION` | No | `us-east-1` | S3 region |
| `S3_ACCESS_KEY_ID` | Yes | - | S3 access key |
| `S3_SECRET_ACCESS_KEY` | Yes | - | S3 secret key |
| `S3_BUCKET` | No | `donut-sync` | S3 bucket name for storing sync data |
| `S3_FORCE_PATH_STYLE` | No | `false` | Set to `true` for MinIO and other S3-compatible services that use path-style URLs |
## Using External S3 Storage
Instead of running MinIO, you can use any S3-compatible storage service. Remove the `minio` service from `docker-compose.yml` and update the environment variables:
### AWS S3
```yaml
services:
donut-sync:
image: donutbrowser/donut-sync:latest
ports:
- "3929:3929"
environment:
- SYNC_TOKEN=your-secret-token-here
- S3_REGION=us-east-1
- S3_ACCESS_KEY_ID=your-aws-access-key
- S3_SECRET_ACCESS_KEY=your-aws-secret-key
- S3_BUCKET=your-bucket-name
```
### Cloudflare R2
```yaml
services:
donut-sync:
image: donutbrowser/donut-sync:latest
ports:
- "3929:3929"
environment:
- SYNC_TOKEN=your-secret-token-here
- S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
- S3_REGION=auto
- S3_ACCESS_KEY_ID=your-r2-access-key
- S3_SECRET_ACCESS_KEY=your-r2-secret-key
- S3_BUCKET=your-bucket-name
- S3_FORCE_PATH_STYLE=true
```
### Other S3-Compatible Services
Any service that implements the S3 API (e.g., Backblaze B2, DigitalOcean Spaces, Wasabi) can be used. Set `S3_ENDPOINT` to the service's endpoint URL and `S3_FORCE_PATH_STYLE=true` if required by the provider.
## Configuring the Donut Browser Client
1. Open Donut Browser
2. Click the sync icon in the header to open the Sync Configuration dialog
3. Enter the **Server URL** (e.g., `http://your-server:3929`)
4. Enter the **Sync Token** (the value you set for `SYNC_TOKEN`)
5. Click **Save**
Once configured, you can enable sync on individual profiles, proxies, and groups.
## Health Check Endpoints
| Endpoint | Description |
|---|---|
| `GET /health` | Basic health check. Returns `{"status":"ok"}` if the server is running. |
| `GET /readyz` | Readiness check. Verifies S3 connectivity. Returns `{"status":"ready","s3":true}` or HTTP 503 if S3 is unreachable. |
## Security Considerations
- **Use a strong `SYNC_TOKEN`**: Generate a random token (e.g., `openssl rand -hex 32`) and keep it secret.
- **HTTPS**: In production, place a reverse proxy (e.g., Nginx, Caddy, Traefik) in front of Donut Sync to terminate TLS. The sync token is sent as a Bearer token in the `Authorization` header and should not be transmitted over plain HTTP.
- **Network isolation**: If running on a VPS, consider restricting access to the sync port using firewall rules or binding only to localhost behind a reverse proxy.
- **S3 credentials**: Use dedicated IAM credentials with minimal permissions (read/write to the sync bucket only).
### Example: Caddy Reverse Proxy
```
sync.yourdomain.com {
reverse_proxy localhost:3929
}
```
### Example: Nginx Reverse Proxy
```nginx
server {
listen 443 ssl;
server_name sync.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3929;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./dist/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2
View File
@@ -56,6 +56,7 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^25.7.4",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"next": "^16.1.3",
@@ -63,6 +64,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.3",
"react-icons": "^5.5.0",
"recharts": "3.6.0",
"sonner": "^2.0.7",
+164 -104
View File
@@ -83,6 +83,9 @@ importers:
flag-icons:
specifier: ^7.5.0
version: 7.5.0
i18next:
specifier: ^25.7.4
version: 25.7.4(typescript@5.9.3)
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
@@ -104,6 +107,9 @@ importers:
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
react-i18next:
specifier: ^16.5.3
version: 16.5.3(i18next@25.7.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.2.3)
@@ -2190,128 +2196,128 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
'@rollup/rollup-android-arm-eabi@4.55.1':
resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==}
'@rollup/rollup-android-arm-eabi@4.55.2':
resolution: {integrity: sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.55.1':
resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==}
'@rollup/rollup-android-arm64@4.55.2':
resolution: {integrity: sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.55.1':
resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==}
'@rollup/rollup-darwin-arm64@4.55.2':
resolution: {integrity: sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.55.1':
resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==}
'@rollup/rollup-darwin-x64@4.55.2':
resolution: {integrity: sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.55.1':
resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==}
'@rollup/rollup-freebsd-arm64@4.55.2':
resolution: {integrity: sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.55.1':
resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==}
'@rollup/rollup-freebsd-x64@4.55.2':
resolution: {integrity: sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.55.1':
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
'@rollup/rollup-linux-arm-gnueabihf@4.55.2':
resolution: {integrity: sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
'@rollup/rollup-linux-arm-musleabihf@4.55.2':
resolution: {integrity: sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
'@rollup/rollup-linux-arm64-gnu@4.55.2':
resolution: {integrity: sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
'@rollup/rollup-linux-arm64-musl@4.55.2':
resolution: {integrity: sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
'@rollup/rollup-linux-loong64-gnu@4.55.2':
resolution: {integrity: sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
'@rollup/rollup-linux-loong64-musl@4.55.2':
resolution: {integrity: sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
'@rollup/rollup-linux-ppc64-gnu@4.55.2':
resolution: {integrity: sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
'@rollup/rollup-linux-ppc64-musl@4.55.2':
resolution: {integrity: sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
'@rollup/rollup-linux-riscv64-gnu@4.55.2':
resolution: {integrity: sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
'@rollup/rollup-linux-riscv64-musl@4.55.2':
resolution: {integrity: sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
'@rollup/rollup-linux-s390x-gnu@4.55.2':
resolution: {integrity: sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
'@rollup/rollup-linux-x64-gnu@4.55.2':
resolution: {integrity: sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
'@rollup/rollup-linux-x64-musl@4.55.2':
resolution: {integrity: sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==}
cpu: [x64]
os: [linux]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
'@rollup/rollup-openbsd-x64@4.55.2':
resolution: {integrity: sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.55.1':
resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==}
'@rollup/rollup-openharmony-arm64@4.55.2':
resolution: {integrity: sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.55.1':
resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==}
'@rollup/rollup-win32-arm64-msvc@4.55.2':
resolution: {integrity: sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.55.1':
resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==}
'@rollup/rollup-win32-ia32-msvc@4.55.2':
resolution: {integrity: sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.55.1':
resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==}
'@rollup/rollup-win32-x64-gnu@4.55.2':
resolution: {integrity: sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.55.1':
resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==}
'@rollup/rollup-win32-x64-msvc@4.55.2':
resolution: {integrity: sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==}
cpu: [x64]
os: [win32]
@@ -3887,6 +3893,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -3900,6 +3909,14 @@ packages:
engines: {node: '>=18'}
hasBin: true
i18next@25.7.4:
resolution: {integrity: sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -4692,6 +4709,22 @@ packages:
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-i18next@16.5.3:
resolution: {integrity: sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw==}
peerDependencies:
i18next: '>= 25.6.2'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
@@ -4814,8 +4847,8 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup@4.55.1:
resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
rollup@4.55.2:
resolution: {integrity: sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -5331,6 +5364,10 @@ packages:
yaml:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
@@ -7762,79 +7799,79 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.53': {}
'@rollup/rollup-android-arm-eabi@4.55.1':
'@rollup/rollup-android-arm-eabi@4.55.2':
optional: true
'@rollup/rollup-android-arm64@4.55.1':
'@rollup/rollup-android-arm64@4.55.2':
optional: true
'@rollup/rollup-darwin-arm64@4.55.1':
'@rollup/rollup-darwin-arm64@4.55.2':
optional: true
'@rollup/rollup-darwin-x64@4.55.1':
'@rollup/rollup-darwin-x64@4.55.2':
optional: true
'@rollup/rollup-freebsd-arm64@4.55.1':
'@rollup/rollup-freebsd-arm64@4.55.2':
optional: true
'@rollup/rollup-freebsd-x64@4.55.1':
'@rollup/rollup-freebsd-x64@4.55.2':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.55.1':
'@rollup/rollup-linux-arm-gnueabihf@4.55.2':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
'@rollup/rollup-linux-arm-musleabihf@4.55.2':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.55.1':
'@rollup/rollup-linux-arm64-gnu@4.55.2':
optional: true
'@rollup/rollup-linux-arm64-musl@4.55.1':
'@rollup/rollup-linux-arm64-musl@4.55.2':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.55.1':
'@rollup/rollup-linux-loong64-gnu@4.55.2':
optional: true
'@rollup/rollup-linux-loong64-musl@4.55.1':
'@rollup/rollup-linux-loong64-musl@4.55.2':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
'@rollup/rollup-linux-ppc64-gnu@4.55.2':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.55.1':
'@rollup/rollup-linux-ppc64-musl@4.55.2':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
'@rollup/rollup-linux-riscv64-gnu@4.55.2':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.55.1':
'@rollup/rollup-linux-riscv64-musl@4.55.2':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.55.1':
'@rollup/rollup-linux-s390x-gnu@4.55.2':
optional: true
'@rollup/rollup-linux-x64-gnu@4.55.1':
'@rollup/rollup-linux-x64-gnu@4.55.2':
optional: true
'@rollup/rollup-linux-x64-musl@4.55.1':
'@rollup/rollup-linux-x64-musl@4.55.2':
optional: true
'@rollup/rollup-openbsd-x64@4.55.1':
'@rollup/rollup-openbsd-x64@4.55.2':
optional: true
'@rollup/rollup-openharmony-arm64@4.55.1':
'@rollup/rollup-openharmony-arm64@4.55.2':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.55.1':
'@rollup/rollup-win32-arm64-msvc@4.55.2':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.55.1':
'@rollup/rollup-win32-ia32-msvc@4.55.2':
optional: true
'@rollup/rollup-win32-x64-gnu@4.55.1':
'@rollup/rollup-win32-x64-gnu@4.55.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.55.1':
'@rollup/rollup-win32-x64-msvc@4.55.2':
optional: true
'@sinclair/typebox@0.34.46': {}
@@ -9530,6 +9567,10 @@ snapshots:
html-escaper@2.0.2: {}
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -9542,6 +9583,12 @@ snapshots:
husky@9.1.7: {}
i18next@25.7.4(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.9.3
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -10477,6 +10524,17 @@ snapshots:
react-fast-compare@3.2.2: {}
react-i18next@16.5.3(i18next@25.7.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.4
html-parse-stringify: 3.0.1
i18next: 25.7.4(typescript@5.9.3)
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
react-dom: 19.2.3(react@19.2.3)
typescript: 5.9.3
react-icons@5.5.0(react@19.2.3):
dependencies:
react: 19.2.3
@@ -10587,35 +10645,35 @@ snapshots:
rfdc@1.4.1: {}
rollup@4.55.1:
rollup@4.55.2:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.55.1
'@rollup/rollup-android-arm64': 4.55.1
'@rollup/rollup-darwin-arm64': 4.55.1
'@rollup/rollup-darwin-x64': 4.55.1
'@rollup/rollup-freebsd-arm64': 4.55.1
'@rollup/rollup-freebsd-x64': 4.55.1
'@rollup/rollup-linux-arm-gnueabihf': 4.55.1
'@rollup/rollup-linux-arm-musleabihf': 4.55.1
'@rollup/rollup-linux-arm64-gnu': 4.55.1
'@rollup/rollup-linux-arm64-musl': 4.55.1
'@rollup/rollup-linux-loong64-gnu': 4.55.1
'@rollup/rollup-linux-loong64-musl': 4.55.1
'@rollup/rollup-linux-ppc64-gnu': 4.55.1
'@rollup/rollup-linux-ppc64-musl': 4.55.1
'@rollup/rollup-linux-riscv64-gnu': 4.55.1
'@rollup/rollup-linux-riscv64-musl': 4.55.1
'@rollup/rollup-linux-s390x-gnu': 4.55.1
'@rollup/rollup-linux-x64-gnu': 4.55.1
'@rollup/rollup-linux-x64-musl': 4.55.1
'@rollup/rollup-openbsd-x64': 4.55.1
'@rollup/rollup-openharmony-arm64': 4.55.1
'@rollup/rollup-win32-arm64-msvc': 4.55.1
'@rollup/rollup-win32-ia32-msvc': 4.55.1
'@rollup/rollup-win32-x64-gnu': 4.55.1
'@rollup/rollup-win32-x64-msvc': 4.55.1
'@rollup/rollup-android-arm-eabi': 4.55.2
'@rollup/rollup-android-arm64': 4.55.2
'@rollup/rollup-darwin-arm64': 4.55.2
'@rollup/rollup-darwin-x64': 4.55.2
'@rollup/rollup-freebsd-arm64': 4.55.2
'@rollup/rollup-freebsd-x64': 4.55.2
'@rollup/rollup-linux-arm-gnueabihf': 4.55.2
'@rollup/rollup-linux-arm-musleabihf': 4.55.2
'@rollup/rollup-linux-arm64-gnu': 4.55.2
'@rollup/rollup-linux-arm64-musl': 4.55.2
'@rollup/rollup-linux-loong64-gnu': 4.55.2
'@rollup/rollup-linux-loong64-musl': 4.55.2
'@rollup/rollup-linux-ppc64-gnu': 4.55.2
'@rollup/rollup-linux-ppc64-musl': 4.55.2
'@rollup/rollup-linux-riscv64-gnu': 4.55.2
'@rollup/rollup-linux-riscv64-musl': 4.55.2
'@rollup/rollup-linux-s390x-gnu': 4.55.2
'@rollup/rollup-linux-x64-gnu': 4.55.2
'@rollup/rollup-linux-x64-musl': 4.55.2
'@rollup/rollup-openbsd-x64': 4.55.2
'@rollup/rollup-openharmony-arm64': 4.55.2
'@rollup/rollup-win32-arm64-msvc': 4.55.2
'@rollup/rollup-win32-ia32-msvc': 4.55.2
'@rollup/rollup-win32-x64-gnu': 4.55.2
'@rollup/rollup-win32-x64-msvc': 4.55.2
fsevents: 2.3.3
router@2.2.0:
@@ -11156,7 +11214,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.55.1
rollup: 4.55.2
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.0.9
@@ -11166,6 +11224,8 @@ snapshots:
terser: 5.46.0
yaml: 2.8.2
void-elements@3.1.0: {}
walker@1.0.8:
dependencies:
makeerror: 1.0.12
+494 -7
View File
@@ -537,6 +537,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.7"
@@ -651,6 +657,30 @@ dependencies = [
"piper",
]
[[package]]
name = "boringtun"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "751787b019c674b9ac353f4eaa285e6711c21badb421cd8c199bf2c83b727f29"
dependencies = [
"aead",
"base64 0.13.1",
"blake2",
"chacha20poly1305",
"hex",
"hmac",
"ip_network",
"ip_network_table",
"libc",
"nix 0.25.1",
"parking_lot",
"rand_core 0.6.4",
"ring 0.16.20",
"tracing",
"untrusted 0.9.0",
"x25519-dalek",
]
[[package]]
name = "borsh"
version = "1.6.0"
@@ -947,6 +977,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.43"
@@ -969,6 +1023,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@@ -1158,6 +1213,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1288,6 +1352,33 @@ dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.0.0-rc.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436ace70fc06e06f7f689d2624dc4e2f0ea666efb5aa704215f7249ae6e047a7"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"fiat-crypto",
"platforms",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling"
version = "0.21.3"
@@ -1329,6 +1420,12 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -1500,6 +1597,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"blake3",
"boringtun",
"bzip2 0.6.1",
"chrono",
"clap",
@@ -1532,12 +1630,14 @@ dependencies = [
"rand 0.9.2",
"regex-lite",
"reqwest 0.13.1",
"resvg",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"single-instance",
"sys-locale",
"sysinfo",
"tao",
"tar",
@@ -1555,6 +1655,7 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tower",
"tower-http",
"tray-icon",
@@ -1737,6 +1838,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "euclid"
version = "0.22.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -1829,6 +1939,12 @@ dependencies = [
"log",
]
[[package]]
name = "fiat-crypto"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77"
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1868,6 +1984,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1880,6 +2002,29 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -2221,6 +2366,16 @@ dependencies = [
"polyval",
]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gif"
version = "0.14.1"
@@ -2785,8 +2940,8 @@ dependencies = [
"byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"gif 0.14.1",
"image-webp 0.2.4",
"moxcms",
"num-traits",
"png 0.18.0",
@@ -2799,6 +2954,16 @@ dependencies = [
"zune-jpeg 0.5.8",
]
[[package]]
name = "image-webp"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "image-webp"
version = "0.2.4"
@@ -2809,6 +2974,12 @@ dependencies = [
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "imgref"
version = "1.12.0"
@@ -2867,6 +3038,28 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "ip_network"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa2f047c0a98b2f299aa5d6d7088443570faae494e9ae1305e48be000c9e0eb1"
[[package]]
name = "ip_network_table"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4099b7cfc5c5e2fe8c5edf3f6f7adf7a714c9cc697534f63a5a5da30397cb2c0"
dependencies = [
"ip_network",
"ip_network_table-deps-treebitmap",
]
[[package]]
name = "ip_network_table-deps-treebitmap"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d"
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -3063,6 +3256,17 @@ dependencies = [
"selectors",
]
[[package]]
name = "kurbo"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
dependencies = [
"arrayvec",
"euclid",
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -3131,6 +3335,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.12"
@@ -3336,6 +3546,15 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memmap2"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.6.5"
@@ -3513,6 +3732,18 @@ dependencies = [
"memoffset 0.6.5",
]
[[package]]
name = "nix"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nix"
version = "0.30.1"
@@ -3637,7 +3868,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
@@ -4223,6 +4454,12 @@ dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -4252,6 +4489,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "platforms"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f21de1852251c849a53467e0ce8b97cca9d11fd4efa3930145c5d5f02f24447"
[[package]]
name = "playwright"
version = "0.0.23"
@@ -4328,6 +4571,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -4563,7 +4817,7 @@ dependencies = [
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"ring 0.17.14",
"rustc-hash",
"rustls",
"rustls-pki-types",
@@ -4975,6 +5229,23 @@ dependencies = [
"web-sys",
]
[[package]]
name = "resvg"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958"
dependencies = [
"gif 0.13.3",
"image-webp 0.1.3",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
"zune-jpeg 0.4.21",
]
[[package]]
name = "rfd"
version = "0.16.0"
@@ -5004,6 +5275,24 @@ name = "rgb"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.16.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
dependencies = [
"cc",
"libc",
"once_cell",
"spin",
"untrusted 0.7.1",
"web-sys",
"winapi",
]
[[package]]
name = "ring"
@@ -5015,7 +5304,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -5048,6 +5337,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rusqlite"
version = "0.38.0"
@@ -5187,9 +5482,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
"ring 0.17.14",
"rustls-pki-types",
"untrusted",
"untrusted 0.9.0",
]
[[package]]
@@ -5198,6 +5493,24 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustybuzz"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181"
dependencies = [
"bitflags 2.10.0",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
[[package]]
name = "ryu"
version = "1.0.22"
@@ -5675,6 +5988,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "single-instance"
version = "0.3.3"
@@ -5706,6 +6028,15 @@ version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -5770,6 +6101,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.1"
@@ -5789,6 +6126,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "string_cache"
version = "0.8.9"
@@ -5835,6 +6181,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
dependencies = [
"kurbo",
"siphasher 1.0.1",
]
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -5888,6 +6244,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "sysinfo"
version = "0.37.2"
@@ -6519,6 +6884,32 @@ dependencies = [
"crunchy",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png 0.17.16",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -6843,6 +7234,15 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a"
dependencies = [
"core_maths",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
@@ -6931,18 +7331,54 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f"
[[package]]
name = "unicode-ccc"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -6959,6 +7395,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -6996,6 +7438,33 @@ dependencies = [
"url",
]
[[package]]
name = "usvg"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6"
dependencies = [
"base64 0.22.1",
"data-url",
"flate2",
"fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
"rustybuzz",
"simplecss",
"siphasher 1.0.1",
"strict-num",
"svgtypes",
"tiny-skia-path",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -8001,6 +8470,18 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x25519-dalek"
version = "2.0.0-rc.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7fae07da688e17059d5886712c933bb0520f15eff2e09cfa18e30968f4e63a"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]]
name = "xattr"
version = "1.6.1"
@@ -8011,6 +8492,12 @@ dependencies = [
"rustix",
]
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xz2"
version = "0.1.7"
+10
View File
@@ -30,6 +30,7 @@ path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.44"
[dependencies]
serde_json = "1"
@@ -48,6 +49,7 @@ env_logger = "0.11"
directories = "6"
reqwest = { version = "0.13", features = ["json", "stream", "socks"] }
tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7"
sysinfo = "0.37"
lazy_static = "1.4"
base64 = "0.22"
@@ -96,6 +98,9 @@ tempfile = "3"
maxminddb = "0.27"
quick-xml = { version = "0.38", features = ["serialize"] }
# VPN support
boringtun = "0.6"
# Daemon dependencies (tray icon)
tray-icon = "0.21"
muda = "0.17"
@@ -104,6 +109,7 @@ single-instance = "0.3"
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
sys-locale = "0.3"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
@@ -151,6 +157,10 @@ path = "tests/donut_proxy_integration.rs"
name = "sync_e2e"
path = "tests/sync_e2e.rs"
[[test]]
name = "vpn_integration"
path = "tests/vpn_integration.rs"
[profile.dev]
codegen-units = 256
incremental = true
+56
View File
@@ -5,6 +5,9 @@ fn main() {
// This allows running cargo test without building the frontend first
ensure_dist_folder_exists();
// Generate tray icon PNGs from SVG (macOS template icon format)
generate_tray_icons();
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-lib=framework=CoreFoundation");
@@ -113,3 +116,56 @@ fn ensure_dist_folder_exists() {
println!("cargo:rerun-if-changed=../dist");
}
fn generate_tray_icons() {
use resvg::tiny_skia::{Pixmap, Transform};
use resvg::usvg::{Options, Tree};
use std::fs;
use std::path::PathBuf;
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let icons_dir = PathBuf::from(&manifest_dir).join("icons");
let svg_path = icons_dir.join("tray-icon.svg");
println!("cargo:rerun-if-changed=icons/tray-icon.svg");
if !svg_path.exists() {
println!("cargo:warning=tray-icon.svg not found, skipping tray icon generation");
return;
}
let svg_data = fs::read(&svg_path).expect("Failed to read tray-icon.svg");
let tree = Tree::from_data(&svg_data, &Options::default()).expect("Failed to parse SVG");
// Generate template icons at different sizes for macOS menu bar
// 22x22 is standard, 44x44 is retina (@2x)
let sizes = [(22, "tray-icon-22.png"), (44, "tray-icon-44.png")];
for (size, filename) in sizes {
let mut pixmap = Pixmap::new(size, size).expect("Failed to create pixmap");
let svg_size = tree.size();
let scale = size as f32 / svg_size.width().max(svg_size.height());
let transform = Transform::from_scale(scale, scale);
resvg::render(&tree, transform, &mut pixmap.as_mut());
// Convert to template icon format: black silhouette with alpha channel
// macOS will automatically handle light/dark mode by inverting the icon
// For template icons: RGB should be 0,0,0 (black) and alpha controls visibility
let data = pixmap.data_mut();
for pixel in data.chunks_exact_mut(4) {
// Keep the original alpha (shows where icon content is)
// but make the color black for template icon format
pixel[0] = 0; // R
pixel[1] = 0; // G
pixel[2] = 0; // B
// pixel[3] (alpha) stays as-is
}
let output_path = icons_dir.join(filename);
pixmap
.save_png(&output_path)
.expect("Failed to save tray icon PNG");
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+8 -60
View File
@@ -9,10 +9,10 @@ use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use muda::MenuEvent;
use serde::{Deserialize, Serialize};
use single_instance::SingleInstance;
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
@@ -69,52 +69,6 @@ fn write_state(state: &DaemonState) -> std::io::Result<()> {
fs::write(path, content)
}
fn detach_from_parent() {
#[cfg(unix)]
{
unsafe {
libc::setsid();
}
}
}
fn spawn_detached() {
#[cfg(unix)]
{
match unsafe { libc::fork() } {
-1 => {
eprintln!("Fork failed");
process::exit(1);
}
0 => {
detach_from_parent();
}
_ => {
process::exit(0);
}
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let current_exe = env::current_exe().expect("Failed to get current exe path");
let _ = Command::new(current_exe)
.arg("--daemon-internal")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn();
process::exit(0);
}
}
fn set_high_priority() {
#[cfg(unix)]
{
@@ -174,13 +128,6 @@ fn run_daemon() {
process::exit(1);
}
let instance =
SingleInstance::new("donut-browser-daemon").expect("Failed to create single instance lock");
if !instance.is_single() {
eprintln!("Daemon is already running");
process::exit(1);
}
log::info!("[daemon] Starting with PID {}", process::id());
// Create tokio runtime for async operations
@@ -231,7 +178,8 @@ fn run_daemon() {
// Run the event loop
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Poll;
// Use WaitUntil to check for menu events periodically while staying low on CPU
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100));
match event {
Event::NewEvents(StartCause::Init) => {
@@ -290,7 +238,8 @@ fn run_daemon() {
}
}
if SHOULD_QUIT.load(Ordering::SeqCst) {
// Use swap to only run cleanup once
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
// Cleanup
let mut state = read_state();
state.daemon_pid = None;
@@ -405,8 +354,10 @@ fn main() {
match args[1].as_str() {
"start" => {
// "start" is now an alias for "run"
// On macOS, the daemon should be started via launchctl (see daemon_spawn.rs)
// This command is kept for backward compatibility
eprintln!("Starting daemon...");
spawn_detached();
run_daemon();
}
"stop" => {
@@ -418,9 +369,6 @@ fn main() {
"run" => {
run_daemon();
}
"--daemon-internal" => {
run_daemon();
}
"autostart" => {
if args.len() < 3 {
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
+94 -15
View File
@@ -80,6 +80,12 @@ pub fn enable_autostart() -> io::Result<()> {
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
// Get log directory (use data directory instead of /tmp)
let log_dir = get_data_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("logs");
fs::create_dir_all(&log_dir)?;
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -89,21 +95,29 @@ pub fn enable_autostart() -> io::Result<()> {
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{}</string>
<string>start</string>
<string>{daemon_path}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>KeepAlive</key>
<false/>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>/tmp/donut-daemon.out.log</string>
<string>{log_dir}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/donut-daemon.err.log</string>
<string>{log_dir}/daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path.display()
daemon_path = daemon_path.display(),
log_dir = log_dir.display()
);
fs::write(&plist_path, plist_content)?;
@@ -112,13 +126,19 @@ pub fn enable_autostart() -> io::Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
pub fn get_plist_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents/com.donutbrowser.daemon.plist");
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
if plist_path.exists() {
// First unload the launch agent if it's loaded
let _ = unload_launch_agent();
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
@@ -128,12 +148,71 @@ pub fn disable_autostart() -> io::Result<()> {
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
dirs::home_dir()
.map(|h| {
h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist")
.exists()
})
.unwrap_or(false)
get_plist_path().is_some_and(|p| p.exists())
}
#[cfg(target_os = "macos")]
pub fn load_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Launch agent plist does not exist",
));
}
// Use launchctl load to start the daemon via launchd
// The -w flag writes the "disabled" key to the override plist
let output = Command::new("launchctl")
.args(["load", "-w"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// "already loaded" is not an error condition for us
if !stderr.contains("already loaded") {
return Err(io::Error::other(format!(
"launchctl load failed: {}",
stderr
)));
}
}
log::info!("Loaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Ok(());
}
let output = Command::new("launchctl")
.args(["unload"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Not being loaded is not an error
if !stderr.contains("Could not find specified service") {
log::warn!("launchctl unload warning: {}", stderr);
}
}
log::info!("Unloaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "linux")]
+11 -5
View File
@@ -6,7 +6,9 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
static GUI_RUNNING: AtomicBool = AtomicBool::new(false);
pub fn load_icon() -> Icon {
let icon_bytes = include_bytes!("../../icons/32x32.png");
// Use the generated template icon (44x44 for retina, macOS standard menu bar size)
// This is the donut logo converted to template format (black with alpha)
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
@@ -89,12 +91,16 @@ impl TrayMenu {
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
TrayIconBuilder::new()
let builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()))
.build()
.expect("Failed to create tray icon")
.with_menu(Box::new(menu.clone()));
// On macOS, template icons are automatically colored by the system for light/dark mode
#[cfg(target_os = "macos")]
let builder = builder.with_icon_as_template(true);
builder.build().expect("Failed to create tray icon")
}
pub fn open_gui() {
+161 -53
View File
@@ -60,6 +60,38 @@ fn is_daemon_running() -> bool {
}
}
fn is_dev_mode() -> bool {
if let Ok(current_exe) = std::env::current_exe() {
let path_str = current_exe.to_string_lossy();
path_str.contains("target/debug") || path_str.contains("target/release")
} else {
false
}
}
#[cfg(target_os = "macos")]
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let daemon_path = exe_dir.join("donut-daemon");
if daemon_path.exists() {
return Some(daemon_path);
}
}
}
// Try common installation paths
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
.unwrap_or_default(),
];
paths.into_iter().find(|path| path.exists())
}
#[cfg(any(target_os = "linux", windows))]
fn get_daemon_path() -> Option<PathBuf> {
// First, try to find it next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
@@ -68,25 +100,13 @@ fn get_daemon_path() -> Option<PathBuf> {
// Check for daemon binary in same directory
#[cfg(target_os = "windows")]
let daemon_name = "donut-daemon.exe";
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "linux")]
let daemon_name = "donut-daemon";
let daemon_path = exe_dir.join(daemon_name);
if daemon_path.exists() {
return Some(daemon_path);
}
// On macOS, check inside the app bundle
#[cfg(target_os = "macos")]
{
// If we're in Contents/MacOS, daemon should be there too
if exe_dir.ends_with("Contents/MacOS") {
let daemon_path = exe_dir.join(daemon_name);
if daemon_path.exists() {
return Some(daemon_path);
}
}
}
}
// Try to find it in PATH
@@ -101,7 +121,7 @@ fn get_daemon_path() -> Option<PathBuf> {
}
}
#[cfg(unix)]
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
if output.status.success() {
@@ -118,60 +138,39 @@ fn get_daemon_path() -> Option<PathBuf> {
}
pub fn spawn_daemon() -> Result<(), String> {
// Log the daemon state for debugging
let state = read_state();
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
// Check if already running
if is_daemon_running() {
log::info!("Daemon is already running");
log::info!("Daemon is already running (verified by PID check)");
return Ok(());
}
log::info!("Daemon is not running, attempting to start...");
// Log current exe location for debugging
let current_exe = std::env::current_exe().ok();
log::info!("Current exe: {:?}", current_exe);
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
current_exe
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Use "run" instead of "start" - we handle detachment here
#[cfg(unix)]
// On macOS, use launchctl to start the daemon via launchd
// This ensures the daemon runs in the user's Aqua session with WindowServer access
// and survives app termination since it's managed by launchd, not as a child process
#[cfg(target_os = "macos")]
{
use std::os::unix::process::CommandExt;
spawn_daemon_macos()?;
}
// Create a new process group so daemon survives parent exit
// Note: We don't call setsid() because on macOS that disconnects from the WindowServer
// which prevents the tray icon from appearing. Instead, we just set a new process group.
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0); // Create new process group without new session
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
// On Linux, use direct spawn
#[cfg(target_os = "linux")]
{
spawn_daemon_unix()?;
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
Command::new(&daemon_path)
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
spawn_daemon_windows()?;
}
// Wait for daemon to start (max 3 seconds)
@@ -196,6 +195,115 @@ pub fn spawn_daemon() -> Result<(), String> {
Err("Daemon did not start within timeout".to_string())
}
#[cfg(target_os = "macos")]
fn spawn_daemon_macos() -> Result<(), String> {
use std::os::unix::process::CommandExt;
// In dev mode, use direct spawn instead of launchctl
// This avoids issues with plist paths pointing to wrong binaries
if is_dev_mode() {
log::info!("Dev mode detected, using direct spawn instead of launchctl");
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
return Ok(());
}
// Production mode: use launchctl for proper daemon management
// First, ensure the LaunchAgent plist is installed
let autostart_enabled = autostart::is_autostart_enabled();
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
if !autostart_enabled {
log::info!("Installing LaunchAgent plist for daemon management");
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
log::info!("LaunchAgent plist installed successfully");
}
// Load the launch agent via launchctl
log::info!("Loading daemon via launchctl...");
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
Ok(())
}
#[cfg(target_os = "linux")]
fn spawn_daemon_unix() -> Result<(), String> {
use std::os::unix::process::CommandExt;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
#[cfg(windows)]
fn spawn_daemon_windows() -> Result<(), String> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
Command::new(&daemon_path)
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
pub fn ensure_daemon_running() -> Result<(), String> {
if !is_daemon_running() {
spawn_daemon()?;
+81 -5
View File
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::api_client::ApiClient;
use crate::browser::{create_browser, BrowserType};
@@ -13,6 +14,8 @@ use crate::events;
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
std::sync::Arc::new(Mutex::new(std::collections::HashSet::new()));
static ref DOWNLOAD_CANCELLATION_TOKENS: std::sync::Arc<Mutex<std::collections::HashMap<String, CancellationToken>>> =
std::sync::Arc::new(Mutex::new(std::collections::HashMap::new()));
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -438,6 +441,7 @@ impl Downloader {
version: &str,
download_info: &DownloadInfo,
dest_path: &Path,
cancel_token: Option<&CancellationToken>,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_path.join(&download_info.filename);
@@ -573,6 +577,13 @@ impl Downloader {
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
if let Some(token) = cancel_token {
if token.is_cancelled() {
drop(file);
let _ = std::fs::remove_file(&file_path);
return Err("Download cancelled".into());
}
}
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
downloaded += chunk.len() as u64;
@@ -635,21 +646,27 @@ impl Downloader {
browser_str: String,
version: String,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Check if Wayfern terms have been accepted before allowing any browser downloads
if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
// Only check Wayfern terms if Wayfern is already downloaded
let terms_manager = crate::wayfern_terms::WayfernTermsManager::instance();
if terms_manager.is_wayfern_downloaded() && !terms_manager.is_terms_accepted() {
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
}
// Check if this browser-version pair is already being downloaded
let download_key = format!("{browser_str}-{version}");
{
let cancel_token = {
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
if downloading.contains(&download_key) {
return Err(format!("Browser '{browser_str}' version '{version}' is already being downloaded. Please wait for the current download to complete.").into());
}
// Mark this browser-version pair as being downloaded
downloading.insert(download_key.clone());
}
let token = CancellationToken::new();
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.insert(download_key.clone(), token.clone());
token
};
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
@@ -681,6 +698,9 @@ impl Downloader {
// Remove from downloading set since it's already downloaded
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
drop(downloading);
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
return Ok(version);
} else {
// Registry says it's downloaded but files don't exist - clean up registry
@@ -702,6 +722,9 @@ impl Downloader {
// Remove from downloading set on error
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
drop(downloading);
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
return Err(
format!(
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
@@ -741,6 +764,7 @@ impl Downloader {
&version,
&download_info,
&browser_dir,
Some(&cancel_token),
)
.await
{
@@ -752,6 +776,25 @@ impl Downloader {
let _ = self.registry.save();
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
drop(downloading);
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
// Emit cancelled stage if the download was cancelled by user
if cancel_token.is_cancelled() {
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "cancelled".to_string(),
};
let _ = events::emit("download-progress", &progress);
}
return Err(format!("Failed to download browser: {e}").into());
}
};
@@ -782,6 +825,10 @@ impl Downloader {
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
return Err(format!("Failed to extract browser: {e}").into());
}
}
@@ -869,6 +916,10 @@ impl Downloader {
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
return Err(error_details.into());
}
@@ -941,11 +992,15 @@ impl Downloader {
};
let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set
// Remove browser-version pair from downloading set and cancel token
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
Ok(version)
}
@@ -964,6 +1019,24 @@ pub async fn download_browser(
.map_err(|e| format!("Failed to download browser: {e}"))
}
#[tauri::command]
pub async fn cancel_download(browser_str: String, version: String) -> Result<(), String> {
let download_key = format!("{browser_str}-{version}");
let token = {
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.get(&download_key).cloned()
};
if let Some(token) = token {
token.cancel();
Ok(())
} else {
Err(format!(
"No active download found for {browser_str} {version}"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1074,6 +1147,7 @@ mod tests {
"139.0",
&download_info,
dest_path,
None,
)
.await;
@@ -1118,6 +1192,7 @@ mod tests {
"139.0",
&download_info,
dest_path,
None,
)
.await;
@@ -1163,6 +1238,7 @@ mod tests {
"1465660",
&download_info,
dest_path,
None,
)
.await;
+248 -4
View File
@@ -47,6 +47,7 @@ pub mod events;
mod mcp_server;
mod tag_manager;
mod version_updater;
pub mod vpn;
use browser_runner::{
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
@@ -68,12 +69,12 @@ use downloaded_browsers_registry::{
check_missing_binaries, ensure_all_binaries_exist, get_downloaded_browser_versions,
};
use downloader::download_browser;
use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, enable_launch_on_login, get_app_settings, get_sync_settings,
get_table_sorting_settings, save_app_settings, save_sync_settings, save_table_sorting_settings,
should_show_launch_on_login_prompt,
get_system_language, get_table_sorting_settings, save_app_settings, save_sync_settings,
save_table_sorting_settings, should_show_launch_on_login_prompt,
};
use sync::{
@@ -232,6 +233,41 @@ fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::Prox
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
}
#[tauri::command]
fn export_proxies(format: String) -> Result<String, String> {
match format.as_str() {
"json" => crate::proxy_manager::PROXY_MANAGER.export_proxies_json(),
"txt" => Ok(crate::proxy_manager::PROXY_MANAGER.export_proxies_txt()),
_ => Err(format!("Unsupported export format: {format}")),
}
}
#[tauri::command]
async fn import_proxies_json(
app_handle: tauri::AppHandle,
content: String,
) -> Result<crate::proxy_manager::ProxyImportResult, String> {
crate::proxy_manager::PROXY_MANAGER
.import_proxies_json(&app_handle, &content)
.map_err(|e| format!("Failed to import proxies: {e}"))
}
#[tauri::command]
fn parse_txt_proxies(content: String) -> Vec<crate::proxy_manager::ProxyParseResult> {
crate::proxy_manager::ProxyManager::parse_txt_proxies(&content)
}
#[tauri::command]
async fn import_proxies_from_parsed(
app_handle: tauri::AppHandle,
parsed_proxies: Vec<crate::proxy_manager::ParsedProxyLine>,
name_prefix: Option<String>,
) -> Result<crate::proxy_manager::ProxyImportResult, String> {
crate::proxy_manager::PROXY_MANAGER
.import_proxies_from_parsed(&app_handle, parsed_proxies, name_prefix)
.map_err(|e| format!("Failed to import proxies: {e}"))
}
#[tauri::command]
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
cookie_manager::CookieManager::read_cookies(&profile_id)
@@ -250,6 +286,11 @@ fn check_wayfern_terms_accepted() -> bool {
wayfern_terms::WayfernTermsManager::instance().is_terms_accepted()
}
#[tauri::command]
fn check_wayfern_downloaded() -> bool {
wayfern_terms::WayfernTermsManager::instance().is_wayfern_downloaded()
}
#[tauri::command]
async fn accept_wayfern_terms() -> Result<(), String> {
wayfern_terms::WayfernTermsManager::instance()
@@ -374,6 +415,172 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
.map_err(|e| format!("Failed to download GeoIP database: {e}"))
}
// VPN commands
#[tauri::command]
async fn import_vpn_config(
content: String,
filename: String,
name: Option<String>,
) -> Result<vpn::VpnImportResult, String> {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
match storage.import_config(&content, &filename, name.clone()) {
Ok(config) => Ok(vpn::VpnImportResult {
success: true,
vpn_id: Some(config.id),
vpn_type: Some(config.vpn_type),
name: config.name,
error: None,
}),
Err(e) => Ok(vpn::VpnImportResult {
success: false,
vpn_id: None,
vpn_type: None,
name: name.unwrap_or_else(|| filename.clone()),
error: Some(e.to_string()),
}),
}
}
#[tauri::command]
async fn list_vpn_configs() -> Result<Vec<vpn::VpnConfig>, String> {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))
}
#[tauri::command]
async fn get_vpn_config(vpn_id: String) -> Result<vpn::VpnConfig, String> {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.load_config(&vpn_id)
.map_err(|e| format!("Failed to load VPN config: {e}"))
}
#[tauri::command]
async fn delete_vpn_config(vpn_id: String) -> Result<(), String> {
// First disconnect if connected
{
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
if manager.is_tunnel_active(&vpn_id) {
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
let _ = tunnel.disconnect().await;
}
manager.remove_tunnel(&vpn_id);
}
}
// Then delete from storage
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.delete_config(&vpn_id)
.map_err(|e| format!("Failed to delete VPN config: {e}"))
}
#[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Load config from storage
let config = {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.load_config(&vpn_id)
.map_err(|e| format!("Failed to load VPN config: {e}"))?
};
// Create and connect the appropriate tunnel
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
// Check if already connected
if manager.is_tunnel_active(&vpn_id) {
return Ok(());
}
let mut tunnel: Box<dyn vpn::VpnTunnel> = match config.vpn_type {
vpn::VpnType::WireGuard => {
let wg_config = vpn::parse_wireguard_config(&config.config_data)
.map_err(|e| format!("Invalid WireGuard config: {e}"))?;
Box::new(vpn::WireGuardTunnel::new(vpn_id.clone(), wg_config))
}
vpn::VpnType::OpenVPN => {
let ovpn_config = vpn::parse_openvpn_config(&config.config_data)
.map_err(|e| format!("Invalid OpenVPN config: {e}"))?;
Box::new(vpn::OpenVpnTunnel::new(vpn_id.clone(), ovpn_config))
}
};
tunnel
.connect()
.await
.map_err(|e| format!("Failed to connect VPN: {e}"))?;
manager.register_tunnel(vpn_id.clone(), tunnel);
// Update last_used timestamp
{
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
let _ = storage.update_last_used(&vpn_id);
}
Ok(())
}
#[tauri::command]
async fn disconnect_vpn(vpn_id: String) -> Result<(), String> {
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
tunnel
.disconnect()
.await
.map_err(|e| format!("Failed to disconnect VPN: {e}"))?;
}
manager.remove_tunnel(&vpn_id);
Ok(())
}
#[tauri::command]
async fn get_vpn_status(vpn_id: String) -> Result<vpn::VpnStatus, String> {
let manager = vpn::TUNNEL_MANAGER.lock().await;
if let Some(tunnel) = manager.get_tunnel(&vpn_id) {
Ok(tunnel.get_status())
} else {
// Not connected
Ok(vpn::VpnStatus {
connected: false,
vpn_id,
connected_at: None,
bytes_sent: None,
bytes_received: None,
last_handshake: None,
})
}
}
#[tauri::command]
async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
let manager = vpn::TUNNEL_MANAGER.lock().await;
Ok(manager.get_all_statuses())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -883,6 +1090,7 @@ pub fn run() {
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
cancel_download,
delete_profile,
check_browser_exists,
create_browser_profile_new,
@@ -907,6 +1115,7 @@ pub fn run() {
decline_launch_on_login,
get_table_sorting_settings,
save_table_sorting_settings,
get_system_language,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
@@ -931,6 +1140,10 @@ pub fn run() {
delete_stored_proxy,
check_proxy_validity,
get_cached_proxy_check,
export_proxies,
import_proxies_json,
parse_txt_proxies,
import_proxies_from_parsed,
update_camoufox_config,
update_wayfern_config,
get_profile_groups,
@@ -959,6 +1172,7 @@ pub fn run() {
read_profile_cookies,
copy_profile_cookies,
check_wayfern_terms_accepted,
check_wayfern_downloaded,
accept_wayfern_terms,
get_commercial_trial_status,
acknowledge_trial_expiration,
@@ -966,7 +1180,16 @@ pub fn run() {
start_mcp_server,
stop_mcp_server,
get_mcp_server_status,
get_mcp_config
get_mcp_config,
// VPN commands
import_vpn_config,
list_vpn_configs,
get_vpn_config,
delete_vpn_config,
connect_vpn,
disconnect_vpn,
get_vpn_status,
list_active_vpn_connections
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -987,6 +1210,18 @@ mod tests {
}
fn check_unused_commands(verbose: bool) {
// Commands that are intentionally not used in the frontend
// but are used via MCP server or other programmatic APIs
let mcp_only_commands = [
"list_vpn_configs",
"get_vpn_config",
"delete_vpn_config",
"connect_vpn",
"disconnect_vpn",
"get_vpn_status",
"list_active_vpn_connections",
];
// Extract command names from the generate_handler! macro in this file
let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs");
let commands = extract_tauri_commands(&lib_rs_content);
@@ -999,6 +1234,15 @@ mod tests {
let mut used_commands = Vec::new();
for command in &commands {
// Skip commands that are intentionally MCP-only
if mcp_only_commands.contains(&command.as_str()) {
used_commands.push(command.clone());
if verbose {
println!("{command} (MCP-only)");
}
continue;
}
let mut is_used = false;
for file_content in &frontend_files {
+533 -2
View File
@@ -553,6 +553,132 @@ impl McpServer {
"required": ["proxy_id"]
}),
},
McpTool {
name: "export_proxies".to_string(),
description: "Export all proxy configurations".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["json", "txt"],
"description": "Export format (json for structured data, txt for URL format)"
}
},
"required": ["format"]
}),
},
McpTool {
name: "import_proxies".to_string(),
description: "Import proxy configurations from JSON or TXT content".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The proxy configuration content to import"
},
"format": {
"type": "string",
"enum": ["json", "txt"],
"description": "Import format (json or txt)"
},
"name_prefix": {
"type": "string",
"description": "Optional prefix for imported proxy names (default: 'Imported')"
}
},
"required": ["content", "format"]
}),
},
// VPN management tools
McpTool {
name: "import_vpn".to_string(),
description: "Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Raw VPN config file content"
},
"filename": {
"type": "string",
"description": "Original filename (.conf or .ovpn) for type detection"
},
"name": {
"type": "string",
"description": "Optional display name for the VPN config"
}
},
"required": ["content", "filename"]
}),
},
McpTool {
name: "list_vpn_configs".to_string(),
description: "List all stored VPN configurations".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "delete_vpn".to_string(),
description: "Delete a VPN configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN config to delete"
}
},
"required": ["vpn_id"]
}),
},
McpTool {
name: "connect_vpn".to_string(),
description: "Connect to a VPN configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN config to connect"
}
},
"required": ["vpn_id"]
}),
},
McpTool {
name: "disconnect_vpn".to_string(),
description: "Disconnect from a VPN".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN to disconnect"
}
},
"required": ["vpn_id"]
}),
},
McpTool {
name: "get_vpn_status".to_string(),
description: "Get the connection status of a VPN".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN to check"
}
},
"required": ["vpn_id"]
}),
},
]
}
@@ -641,6 +767,16 @@ impl McpServer {
"create_proxy" => self.handle_create_proxy(&arguments).await,
"update_proxy" => self.handle_update_proxy(&arguments).await,
"delete_proxy" => self.handle_delete_proxy(&arguments).await,
// Proxy import/export
"export_proxies" => self.handle_export_proxies(&arguments).await,
"import_proxies" => self.handle_import_proxies(&arguments).await,
// VPN management
"import_vpn" => self.handle_import_vpn(&arguments).await,
"list_vpn_configs" => self.handle_list_vpn_configs().await,
"delete_vpn" => self.handle_delete_vpn(&arguments).await,
"connect_vpn" => self.handle_connect_vpn(&arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(&arguments).await,
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
@@ -1361,6 +1497,391 @@ impl McpServer {
}]
}))
}
async fn handle_export_proxies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let format = arguments
.get("format")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing format".to_string(),
})?;
let content = match format {
"json" => PROXY_MANAGER.export_proxies_json().map_err(|e| McpError {
code: -32000,
message: format!("Failed to export proxies: {e}"),
})?,
"txt" => PROXY_MANAGER.export_proxies_txt(),
_ => {
return Err(McpError {
code: -32602,
message: format!("Invalid format '{}', must be 'json' or 'txt'", format),
})
}
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": content
}]
}))
}
async fn handle_import_proxies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
})?;
let format = arguments
.get("format")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing format".to_string(),
})?;
let name_prefix = arguments
.get("name_prefix")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let inner = self.inner.lock().await;
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?;
let result = match format {
"json" => PROXY_MANAGER
.import_proxies_json(app_handle, content)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import proxies: {e}"),
})?,
"txt" => {
use crate::proxy_manager::{ProxyManager, ProxyParseResult};
let parse_results = ProxyManager::parse_txt_proxies(content);
let parsed: Vec<_> = parse_results
.into_iter()
.filter_map(|r| {
if let ProxyParseResult::Parsed(p) = r {
Some(p)
} else {
None
}
})
.collect();
if parsed.is_empty() {
return Err(McpError {
code: -32000,
message: "No valid proxies found in content".to_string(),
});
}
PROXY_MANAGER
.import_proxies_from_parsed(app_handle, parsed, name_prefix)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import proxies: {e}"),
})?
}
_ => {
return Err(McpError {
code: -32602,
message: format!("Invalid format '{}', must be 'json' or 'txt'", format),
})
}
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} skipped, {} errors",
result.imported_count,
result.skipped_count,
result.errors.len()
)
}]
}))
}
// VPN management handlers
async fn handle_import_vpn(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
})?;
let filename = arguments
.get("filename")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing filename".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock VPN storage: {e}"),
})?;
let config = storage
.import_config(content, filename, name)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import VPN config: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"VPN '{}' ({}) imported successfully with ID: {}",
config.name,
config.vpn_type,
config.id
)
}]
}))
}
async fn handle_list_vpn_configs(&self) -> Result<serde_json::Value, McpError> {
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock VPN storage: {e}"),
})?;
let configs = storage.list_configs().map_err(|e| McpError {
code: -32000,
message: format!("Failed to list VPN configs: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&configs).unwrap_or_default()
}]
}))
}
async fn handle_delete_vpn(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let vpn_id = arguments
.get("vpn_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing vpn_id".to_string(),
})?;
// First disconnect if connected
{
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
if manager.is_tunnel_active(vpn_id) {
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
let _ = tunnel.disconnect().await;
}
manager.remove_tunnel(vpn_id);
}
}
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock VPN storage: {e}"),
})?;
storage.delete_config(vpn_id).map_err(|e| McpError {
code: -32000,
message: format!("Failed to delete VPN config: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("VPN '{}' deleted successfully", vpn_id)
}]
}))
}
async fn handle_connect_vpn(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let vpn_id = arguments
.get("vpn_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing vpn_id".to_string(),
})?;
// Load config from storage
let config = {
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock VPN storage: {e}"),
})?;
storage.load_config(vpn_id).map_err(|e| McpError {
code: -32000,
message: format!("Failed to load VPN config: {e}"),
})?
};
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
// Check if already connected
if manager.is_tunnel_active(vpn_id) {
return Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("VPN '{}' is already connected", config.name)
}]
}));
}
let mut tunnel: Box<dyn crate::vpn::VpnTunnel> = match config.vpn_type {
crate::vpn::VpnType::WireGuard => {
let wg_config =
crate::vpn::parse_wireguard_config(&config.config_data).map_err(|e| McpError {
code: -32000,
message: format!("Invalid WireGuard config: {e}"),
})?;
Box::new(crate::vpn::WireGuardTunnel::new(
vpn_id.to_string(),
wg_config,
))
}
crate::vpn::VpnType::OpenVPN => {
let ovpn_config =
crate::vpn::parse_openvpn_config(&config.config_data).map_err(|e| McpError {
code: -32000,
message: format!("Invalid OpenVPN config: {e}"),
})?;
Box::new(crate::vpn::OpenVpnTunnel::new(
vpn_id.to_string(),
ovpn_config,
))
}
};
tunnel.connect().await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to connect VPN: {e}"),
})?;
manager.register_tunnel(vpn_id.to_string(), tunnel);
// Update last_used timestamp
{
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock VPN storage: {e}"),
})?;
let _ = storage.update_last_used(vpn_id);
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("VPN '{}' connected successfully", config.name)
}]
}))
}
async fn handle_disconnect_vpn(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let vpn_id = arguments
.get("vpn_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing vpn_id".to_string(),
})?;
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
tunnel.disconnect().await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to disconnect VPN: {e}"),
})?;
}
manager.remove_tunnel(vpn_id);
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("VPN '{}' disconnected successfully", vpn_id)
}]
}))
}
async fn handle_get_vpn_status(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let vpn_id = arguments
.get("vpn_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing vpn_id".to_string(),
})?;
let manager = crate::vpn::TUNNEL_MANAGER.lock().await;
let status = if let Some(tunnel) = manager.get_tunnel(vpn_id) {
tunnel.get_status()
} else {
crate::vpn::VpnStatus {
connected: false,
vpn_id: vpn_id.to_string(),
connected_at: None,
bytes_sent: None,
bytes_received: None,
last_handshake: None,
}
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&status).unwrap_or_default()
}]
}))
}
}
lazy_static::lazy_static! {
@@ -1376,8 +1897,8 @@ mod tests {
let server = McpServer::new();
let tools = server.get_tools();
// Should have all 16 tools
assert!(tools.len() >= 16);
// Should have at least 24 tools (18 + 6 VPN tools)
assert!(tools.len() >= 24);
// Check tool names
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
@@ -1400,6 +1921,16 @@ mod tests {
assert!(tool_names.contains(&"create_proxy"));
assert!(tool_names.contains(&"update_proxy"));
assert!(tool_names.contains(&"delete_proxy"));
// Proxy import/export tools
assert!(tool_names.contains(&"export_proxies"));
assert!(tool_names.contains(&"import_proxies"));
// VPN tools
assert!(tool_names.contains(&"import_vpn"));
assert!(tool_names.contains(&"list_vpn_configs"));
assert!(tool_names.contains(&"delete_vpn"));
assert!(tool_names.contains(&"connect_vpn"));
assert!(tool_names.contains(&"disconnect_vpn"));
assert!(tool_names.contains(&"get_vpn_status"));
}
#[test]
+380
View File
@@ -1,3 +1,4 @@
use chrono::Utc;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -12,6 +13,60 @@ use crate::browser::ProxySettings;
use crate::events;
use crate::ip_utils;
// Export data format for JSON export
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyExportData {
pub version: String,
pub proxies: Vec<ExportedProxy>,
pub exported_at: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportedProxy {
pub name: String,
#[serde(rename = "type")]
pub proxy_type: String,
pub host: String,
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyImportResult {
pub imported_count: usize,
pub skipped_count: usize,
pub errors: Vec<String>,
pub proxies: Vec<StoredProxy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedProxyLine {
pub proxy_type: String,
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub original_line: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum ProxyParseResult {
#[serde(rename = "parsed")]
Parsed(ParsedProxyLine),
#[serde(rename = "ambiguous")]
Ambiguous {
line: String,
possible_formats: Vec<String>,
},
#[serde(rename = "invalid")]
Invalid { line: String, reason: String },
}
// Store active proxy information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyInfo {
@@ -541,6 +596,331 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
// Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
let proxies: Vec<ExportedProxy> = stored_proxies
.values()
.map(|p| ExportedProxy {
name: p.name.clone(),
proxy_type: p.proxy_settings.proxy_type.clone(),
host: p.proxy_settings.host.clone(),
port: p.proxy_settings.port,
username: p.proxy_settings.username.clone(),
password: p.proxy_settings.password.clone(),
})
.collect();
let export_data = ProxyExportData {
version: "1.0".to_string(),
proxies,
exported_at: Utc::now().to_rfc3339(),
source: "DonutBrowser".to_string(),
};
serde_json::to_string_pretty(&export_data).map_err(|e| format!("Failed to serialize: {e}"))
}
// Export all proxies as TXT (one per line: protocol://user:pass@host:port)
pub fn export_proxies_txt(&self) -> String {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies
.values()
.map(|p| Self::build_proxy_url(&p.proxy_settings))
.collect::<Vec<_>>()
.join("\n")
}
// Parse TXT content with auto-detection of formats
pub fn parse_txt_proxies(content: &str) -> Vec<ProxyParseResult> {
content
.lines()
.filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
.map(|line| Self::parse_single_proxy_line(line.trim()))
.collect()
}
// Parse a single proxy line with format auto-detection
fn parse_single_proxy_line(line: &str) -> ProxyParseResult {
// Format 1: protocol://username:password@host:port (full URL)
if let Some(result) = Self::try_parse_url_format(line) {
return result;
}
// Try colon-separated formats
let parts: Vec<&str> = line.split(':').collect();
match parts.len() {
// host:port (no auth)
2 => {
if let Ok(port) = parts[1].parse::<u16>() {
return ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: parts[0].to_string(),
port,
username: None,
password: None,
original_line: line.to_string(),
});
}
ProxyParseResult::Invalid {
line: line.to_string(),
reason: "Invalid port number".to_string(),
}
}
// Could be: host:port:user or user:pass@host (with @ in the middle)
3 => {
// Try username:password@host:port first
if let Some(result) = Self::try_parse_user_pass_at_host_port(line) {
return result;
}
ProxyParseResult::Invalid {
line: line.to_string(),
reason: "Could not determine format with 3 parts".to_string(),
}
}
// 4 parts: could be host:port:user:pass OR user:pass:host:port
4 => {
// Try to detect which format
let port_at_1 = parts[1].parse::<u16>().is_ok();
let port_at_3 = parts[3].parse::<u16>().is_ok();
match (port_at_1, port_at_3) {
// host:port:user:pass
(true, false) => {
let port = parts[1].parse::<u16>().unwrap();
ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: parts[0].to_string(),
port,
username: Some(parts[2].to_string()),
password: Some(parts[3].to_string()),
original_line: line.to_string(),
})
}
// user:pass:host:port
(false, true) => {
let port = parts[3].parse::<u16>().unwrap();
ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: parts[2].to_string(),
port,
username: Some(parts[0].to_string()),
password: Some(parts[1].to_string()),
original_line: line.to_string(),
})
}
// Both could be ports - ambiguous
(true, true) => ProxyParseResult::Ambiguous {
line: line.to_string(),
possible_formats: vec![
"host:port:username:password".to_string(),
"username:password:host:port".to_string(),
],
},
// Neither is a valid port
(false, false) => ProxyParseResult::Invalid {
line: line.to_string(),
reason: "No valid port number found".to_string(),
},
}
}
_ => ProxyParseResult::Invalid {
line: line.to_string(),
reason: format!("Unexpected format with {} parts", parts.len()),
},
}
}
// Try to parse URL format: protocol://username:password@host:port
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
// Check for protocol prefix using strip_prefix
let (protocol, rest) = if let Some(rest) = line.strip_prefix("http://") {
("http", rest)
} else if let Some(rest) = line.strip_prefix("https://") {
("https", rest)
} else if let Some(rest) = line.strip_prefix("socks4://") {
("socks4", rest)
} else if let Some(rest) = line.strip_prefix("socks5://") {
("socks5", rest)
} else if let Some(rest) = line.strip_prefix("socks://") {
("socks5", rest) // Default socks to socks5
} else {
return None;
};
// Check if there's auth (contains @)
if let Some(at_pos) = rest.rfind('@') {
let auth = &rest[..at_pos];
let host_port = &rest[at_pos + 1..];
// Parse auth (user:pass)
let (username, password) = if let Some(colon_pos) = auth.find(':') {
let user = urlencoding::decode(&auth[..colon_pos]).unwrap_or_default();
let pass = urlencoding::decode(&auth[colon_pos + 1..]).unwrap_or_default();
(Some(user.to_string()), Some(pass.to_string()))
} else {
(
Some(urlencoding::decode(auth).unwrap_or_default().to_string()),
None,
)
};
// Parse host:port
if let Some(colon_pos) = host_port.rfind(':') {
let host = &host_port[..colon_pos];
if let Ok(port) = host_port[colon_pos + 1..].parse::<u16>() {
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: protocol.to_string(),
host: host.to_string(),
port,
username,
password,
original_line: line.to_string(),
}));
}
}
} else {
// No auth, just host:port
if let Some(colon_pos) = rest.rfind(':') {
let host = &rest[..colon_pos];
if let Ok(port) = rest[colon_pos + 1..].parse::<u16>() {
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: protocol.to_string(),
host: host.to_string(),
port,
username: None,
password: None,
original_line: line.to_string(),
}));
}
}
}
Some(ProxyParseResult::Invalid {
line: line.to_string(),
reason: "Invalid URL format".to_string(),
})
}
// Try to parse: username:password@host:port format (no protocol)
fn try_parse_user_pass_at_host_port(line: &str) -> Option<ProxyParseResult> {
if let Some(at_pos) = line.rfind('@') {
let auth = &line[..at_pos];
let host_port = &line[at_pos + 1..];
// Parse auth
let (username, password) = if let Some(colon_pos) = auth.find(':') {
(
Some(auth[..colon_pos].to_string()),
Some(auth[colon_pos + 1..].to_string()),
)
} else {
return None;
};
// Parse host:port
if let Some(colon_pos) = host_port.rfind(':') {
let host = &host_port[..colon_pos];
if let Ok(port) = host_port[colon_pos + 1..].parse::<u16>() {
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: host.to_string(),
port,
username,
password,
original_line: line.to_string(),
}));
}
}
}
None
}
// Import proxies from JSON content
pub fn import_proxies_json(
&self,
app_handle: &tauri::AppHandle,
content: &str,
) -> Result<ProxyImportResult, String> {
let export_data: ProxyExportData =
serde_json::from_str(content).map_err(|e| format!("Invalid JSON format: {e}"))?;
let mut imported = Vec::new();
let mut skipped = 0;
let mut errors = Vec::new();
for exported in export_data.proxies {
let proxy_settings = ProxySettings {
proxy_type: exported.proxy_type,
host: exported.host,
port: exported.port,
username: exported.username,
password: exported.password,
};
match self.create_stored_proxy(app_handle, exported.name.clone(), proxy_settings) {
Ok(proxy) => imported.push(proxy),
Err(e) => {
if e.contains("already exists") {
skipped += 1;
} else {
errors.push(format!("Failed to import '{}': {}", exported.name, e));
}
}
}
}
Ok(ProxyImportResult {
imported_count: imported.len(),
skipped_count: skipped,
errors,
proxies: imported,
})
}
// Import proxies from already parsed proxy lines
pub fn import_proxies_from_parsed(
&self,
app_handle: &tauri::AppHandle,
parsed_proxies: Vec<ParsedProxyLine>,
name_prefix: Option<String>,
) -> Result<ProxyImportResult, String> {
let mut imported = Vec::new();
let mut skipped = 0;
let mut errors = Vec::new();
let prefix = name_prefix.unwrap_or_else(|| "Imported".to_string());
for (i, parsed) in parsed_proxies.into_iter().enumerate() {
let proxy_name = format!("{} Proxy {}", prefix, i + 1);
let proxy_settings = ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
};
match self.create_stored_proxy(app_handle, proxy_name.clone(), proxy_settings) {
Ok(proxy) => imported.push(proxy),
Err(e) => {
if e.contains("already exists") {
skipped += 1;
} else {
errors.push(format!("Failed to import '{}': {}", proxy_name, e));
}
}
}
}
Ok(ProxyImportResult {
imported_count: imported.len(),
skipped_count: skipped,
errors,
proxies: imported,
})
}
// Start a proxy for given proxy settings and associate it with a browser process ID
// If proxy_settings is None, starts a direct proxy for traffic monitoring
pub async fn start_proxy(
+29
View File
@@ -52,6 +52,8 @@ pub struct AppSettings {
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
#[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -84,6 +86,7 @@ impl Default for AppSettings {
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
}
}
}
@@ -809,6 +812,17 @@ pub async fn save_app_settings(
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
persist_settings.mcp_token = None;
log::info!(
"[settings] Saving settings: theme={}, custom_theme_keys={}",
persist_settings.theme,
persist_settings
.custom_theme
.as_ref()
.map(|t| t.len())
.unwrap_or(0)
);
manager
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
@@ -899,6 +913,20 @@ pub async fn save_sync_settings(
})
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
.map(|locale| {
// Extract just the language code (e.g., "en" from "en-US")
locale
.split(['-', '_'])
.next()
.unwrap_or("en")
.to_lowercase()
})
.unwrap_or_else(|| "en".to_string())
}
// Global singleton instance
lazy_static::lazy_static! {
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
@@ -985,6 +1013,7 @@ mod tests {
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
};
// Save settings
+489
View File
@@ -0,0 +1,489 @@
//! VPN configuration types and parsing.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
/// VPN-related errors
#[derive(Error, Debug)]
pub enum VpnError {
#[error("Unknown VPN config format")]
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}")]
Connection(String),
#[error("Encryption error: {0}")]
Encryption(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("VPN not found: {0}")]
NotFound(String),
#[error("Tunnel error: {0}")]
Tunnel(String),
}
/// The type of VPN configuration
#[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"),
}
}
}
/// A stored VPN configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnConfig {
pub id: String,
pub name: String,
pub vpn_type: VpnType,
pub config_data: String, // Raw config content (encrypted at rest)
pub created_at: i64,
pub last_used: Option<i64>,
}
/// Parsed WireGuard configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireGuardConfig {
pub private_key: String,
pub address: String,
pub dns: Option<String>,
pub mtu: Option<u16>,
pub peer_public_key: String,
pub peer_endpoint: String,
pub allowed_ips: Vec<String>,
pub persistent_keepalive: Option<u16>,
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 {
pub success: bool,
pub vpn_id: Option<String>,
pub vpn_type: Option<VpnType>,
pub name: String,
pub error: Option<String>,
}
/// VPN connection status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnStatus {
pub connected: bool,
pub vpn_id: String,
pub connected_at: Option<i64>,
pub bytes_sent: Option<u64>,
pub bytes_received: Option<u64>,
pub last_handshake: Option<i64>,
}
/// Detect the VPN type from file content and filename
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]")
{
return Ok(VpnType::WireGuard);
}
if content.contains("remote ") && (content.contains("client") || content.contains("dev tun")) {
return Ok(VpnType::OpenVPN);
}
Err(VpnError::UnknownFormat)
}
/// Parse a WireGuard configuration file
pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError> {
let mut interface: HashMap<String, String> = HashMap::new();
let mut peer: HashMap<String, String> = HashMap::new();
let mut current_section: Option<&str> = None;
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
continue;
}
// Check for section headers
if line == "[Interface]" {
current_section = Some("interface");
continue;
}
if line == "[Peer]" {
current_section = Some("peer");
continue;
}
// Parse key-value pairs
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
match current_section {
Some("interface") => {
interface.insert(key, value);
}
Some("peer") => {
peer.insert(key, value);
}
_ => {}
}
}
}
// Validate required fields
let private_key = interface
.get("PrivateKey")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PrivateKey in [Interface]".to_string()))?
.clone();
let address = interface
.get("Address")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing Address in [Interface]".to_string()))?
.clone();
let peer_public_key = peer
.get("PublicKey")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PublicKey in [Peer]".to_string()))?
.clone();
let peer_endpoint = peer
.get("Endpoint")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing Endpoint in [Peer]".to_string()))?
.clone();
let allowed_ips = peer
.get("AllowedIPs")
.map(|s| s.split(',').map(|ip| ip.trim().to_string()).collect())
.unwrap_or_else(|| vec!["0.0.0.0/0".to_string()]);
let persistent_keepalive = peer.get("PersistentKeepalive").and_then(|s| s.parse().ok());
let dns = interface.get("DNS").cloned();
let mtu = interface.get("MTU").and_then(|s| s.parse().ok());
let preshared_key = peer.get("PresharedKey").cloned();
Ok(WireGuardConfig {
private_key,
address,
dns,
mtu,
peer_public_key,
peer_endpoint,
allowed_ips,
persistent_keepalive,
preshared_key,
})
}
/// 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 parts.len() >= 3 {
if let Ok(port) = parts[2].parse() {
remote_port = port;
}
}
if parts.len() >= 4 {
protocol = parts[3].to_string();
}
}
"proto" => {
if parts.len() >= 2 {
protocol = parts[1].to_string();
}
}
"port" => {
if parts.len() >= 2 {
if let Ok(port) = parts[1].parse() {
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::*;
#[test]
fn test_detect_wireguard_by_extension() {
let content = "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = test";
assert_eq!(
detect_vpn_type(content, "test.conf").unwrap(),
VpnType::WireGuard
);
}
#[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";
assert_eq!(
detect_vpn_type(content, "config").unwrap(),
VpnType::WireGuard
);
}
#[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_parse_wireguard_config() {
let content = r#"
[Interface]
PrivateKey = WGTestPrivateKey123456789012345678901234567890
Address = 10.0.0.2/24
DNS = 1.1.1.1
MTU = 1420
[Peer]
PublicKey = WGTestPublicKey1234567890123456789012345678901
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
"#;
let config = parse_wireguard_config(content).unwrap();
assert_eq!(
config.private_key,
"WGTestPrivateKey123456789012345678901234567890"
);
assert_eq!(config.address, "10.0.0.2/24");
assert_eq!(config.dns, Some("1.1.1.1".to_string()));
assert_eq!(config.mtu, Some(1420));
assert_eq!(
config.peer_public_key,
"WGTestPublicKey1234567890123456789012345678901"
);
assert_eq!(config.peer_endpoint, "vpn.example.com:51820");
assert_eq!(config.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
assert_eq!(config.persistent_keepalive, Some(25));
}
#[test]
fn test_parse_wireguard_config_minimal() {
let content = r#"
[Interface]
PrivateKey = minimalkey
Address = 10.0.0.2/32
[Peer]
PublicKey = peerpubkey
Endpoint = 1.2.3.4:51820
"#;
let config = parse_wireguard_config(content).unwrap();
assert_eq!(config.private_key, "minimalkey");
assert_eq!(config.address, "10.0.0.2/32");
assert!(config.dns.is_none());
assert!(config.mtu.is_none());
assert_eq!(config.peer_public_key, "peerpubkey");
assert_eq!(config.peer_endpoint, "1.2.3.4:51820");
}
#[test]
fn test_parse_wireguard_missing_private_key() {
let content = r#"
[Interface]
Address = 10.0.0.2/24
[Peer]
PublicKey = key
Endpoint = 1.2.3.4:51820
"#;
let result = parse_wireguard_config(content);
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"));
}
}
+31
View File
@@ -0,0 +1,31 @@
//! VPN support module for WireGuard and OpenVPN configurations.
//!
//! This module provides:
//! - VPN config parsing (WireGuard .conf and OpenVPN .ovpn files)
//! - Encrypted storage for VPN configurations
//! - Tunnel management with userspace WireGuard (boringtun) and OpenVPN process management
mod config;
mod openvpn;
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,
};
pub use openvpn::OpenVpnTunnel;
pub use storage::VpnStorage;
pub use tunnel::{TunnelManager, VpnTunnel};
pub use wireguard::WireGuardTunnel;
use once_cell::sync::Lazy;
use std::sync::Mutex;
/// Global VPN storage instance
pub static VPN_STORAGE: Lazy<Mutex<VpnStorage>> = Lazy::new(|| Mutex::new(VpnStorage::new()));
/// Global tunnel manager instance
pub static TUNNEL_MANAGER: Lazy<tokio::sync::Mutex<TunnelManager>> =
Lazy::new(|| tokio::sync::Mutex::new(TunnelManager::new()));
+343
View File
@@ -0,0 +1,343 @@
//! 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)]
{
if let Ok(output) = Command::new("where").arg("openvpn").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")),
}
}
}
+415
View File
@@ -0,0 +1,415 @@
//! Encrypted storage for VPN configurations.
use super::config::{VpnConfig, VpnError, VpnType};
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
/// Storage format version for migration support
const STORAGE_VERSION: u32 = 1;
/// Stored VPN configs container
#[derive(Debug, Serialize, Deserialize)]
struct VpnStorageData {
version: u32,
configs: Vec<StoredVpnConfig>,
}
/// Encrypted VPN config as stored on disk
#[derive(Debug, Serialize, Deserialize)]
struct StoredVpnConfig {
id: String,
name: String,
vpn_type: VpnType,
encrypted_data: String, // Base64 encoded encrypted config
nonce: String, // Base64 encoded nonce
created_at: i64,
last_used: Option<i64>,
}
/// VPN storage manager with encryption
pub struct VpnStorage {
storage_path: PathBuf,
encryption_key: [u8; 32],
}
impl Default for VpnStorage {
fn default() -> Self {
Self::new()
}
}
impl VpnStorage {
/// Create a new VPN storage manager
pub fn new() -> Self {
let storage_path = Self::get_storage_path();
let encryption_key = Self::get_or_create_key();
Self {
storage_path,
encryption_key,
}
}
/// Get the storage file path
fn get_storage_path() -> PathBuf {
let data_dir = directories::ProjectDirs::from("com", "donut", "donutbrowser")
.map(|dirs| dirs.data_local_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
if !data_dir.exists() {
let _ = fs::create_dir_all(&data_dir);
}
data_dir.join("vpn_configs.json")
}
/// Get or create the encryption key
fn get_or_create_key() -> [u8; 32] {
let key_path = directories::ProjectDirs::from("com", "donut", "donutbrowser")
.map(|dirs| dirs.data_local_dir().join(".vpn_key"))
.unwrap_or_else(|| PathBuf::from(".vpn_key"));
if key_path.exists() {
if let Ok(key_data) = fs::read(&key_path) {
if key_data.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&key_data);
return key;
}
}
}
// Generate a new key
let key: [u8; 32] = rand::rng().random();
let _ = fs::write(&key_path, key);
// Set restrictive permissions on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
}
key
}
/// Load storage data from disk
fn load_storage(&self) -> Result<VpnStorageData, VpnError> {
if !self.storage_path.exists() {
return Ok(VpnStorageData {
version: STORAGE_VERSION,
configs: Vec::new(),
});
}
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)
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))
}
/// Save storage data to disk
fn save_storage(&self, data: &VpnStorageData) -> Result<(), VpnError> {
let content = serde_json::to_string_pretty(data)
.map_err(|e| VpnError::Storage(format!("Failed to serialize storage: {e}")))?;
fs::write(&self.storage_path, content)
.map_err(|e| VpnError::Storage(format!("Failed to write storage file: {e}")))?;
Ok(())
}
/// Encrypt config data
fn encrypt(&self, data: &str) -> Result<(String, String), VpnError> {
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| VpnError::Encryption(format!("Failed to create cipher: {e}")))?;
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, data.as_bytes())
.map_err(|e| VpnError::Encryption(format!("Encryption failed: {e}")))?;
Ok((
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &ciphertext),
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce_bytes),
))
}
/// Decrypt config data
fn decrypt(&self, encrypted_data: &str, nonce_str: &str) -> Result<String, VpnError> {
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| VpnError::Encryption(format!("Failed to create cipher: {e}")))?;
let ciphertext =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encrypted_data)
.map_err(|e| VpnError::Encryption(format!("Failed to decode ciphertext: {e}")))?;
let nonce_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, nonce_str)
.map_err(|e| VpnError::Encryption(format!("Failed to decode nonce: {e}")))?;
if nonce_bytes.len() != 12 {
return Err(VpnError::Encryption("Invalid nonce length".to_string()));
}
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| VpnError::Encryption(format!("Decryption failed: {e}")))?;
String::from_utf8(plaintext)
.map_err(|e| VpnError::Encryption(format!("Failed to decode plaintext: {e}")))
}
/// Save a VPN configuration
pub fn save_config(&self, config: &VpnConfig) -> Result<(), VpnError> {
let mut storage = self.load_storage()?;
// Encrypt the config data
let (encrypted_data, nonce) = self.encrypt(&config.config_data)?;
let stored = StoredVpnConfig {
id: config.id.clone(),
name: config.name.clone(),
vpn_type: config.vpn_type,
encrypted_data,
nonce,
created_at: config.created_at,
last_used: config.last_used,
};
// Update existing or add new
if let Some(pos) = storage.configs.iter().position(|c| c.id == config.id) {
storage.configs[pos] = stored;
} else {
storage.configs.push(stored);
}
self.save_storage(&storage)
}
/// Load a VPN configuration by ID
pub fn load_config(&self, id: &str) -> Result<VpnConfig, VpnError> {
let storage = self.load_storage()?;
let stored = storage
.configs
.iter()
.find(|c| c.id == id)
.ok_or_else(|| VpnError::NotFound(id.to_string()))?;
let config_data = self.decrypt(&stored.encrypted_data, &stored.nonce)?;
Ok(VpnConfig {
id: stored.id.clone(),
name: stored.name.clone(),
vpn_type: stored.vpn_type,
config_data,
created_at: stored.created_at,
last_used: stored.last_used,
})
}
/// List all VPN configurations (without decrypted config data)
pub fn list_configs(&self) -> Result<Vec<VpnConfig>, VpnError> {
let storage = self.load_storage()?;
Ok(
storage
.configs
.iter()
.map(|stored| VpnConfig {
id: stored.id.clone(),
name: stored.name.clone(),
vpn_type: stored.vpn_type,
config_data: String::new(), // Don't include config data in list
created_at: stored.created_at,
last_used: stored.last_used,
})
.collect(),
)
}
/// Delete a VPN configuration
pub fn delete_config(&self, id: &str) -> Result<(), VpnError> {
let mut storage = self.load_storage()?;
let initial_len = storage.configs.len();
storage.configs.retain(|c| c.id != id);
if storage.configs.len() == initial_len {
return Err(VpnError::NotFound(id.to_string()));
}
self.save_storage(&storage)
}
/// Update last_used timestamp
pub fn update_last_used(&self, id: &str) -> Result<(), VpnError> {
let mut storage = self.load_storage()?;
if let Some(config) = storage.configs.iter_mut().find(|c| c.id == id) {
config.last_used = Some(Utc::now().timestamp());
self.save_storage(&storage)
} else {
Err(VpnError::NotFound(id.to_string()))
}
}
/// Import a VPN config from raw content
pub fn import_config(
&self,
content: &str,
filename: &str,
name: Option<String>,
) -> 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");
format!("{} ({})", base, vpn_type)
});
let config = VpnConfig {
id,
name: display_name,
vpn_type,
config_data: content.to_string(),
created_at: Utc::now().timestamp(),
last_used: None,
};
self.save_config(&config)?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_storage() -> (VpnStorage, TempDir) {
let temp_dir = TempDir::new().unwrap();
let mut storage = VpnStorage::new();
storage.storage_path = temp_dir.path().join("test_vpn_configs.json");
(storage, temp_dir)
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let (storage, _temp) = create_test_storage();
let original = "This is a secret VPN configuration";
let (encrypted, nonce) = storage.encrypt(original).unwrap();
let decrypted = storage.decrypt(&encrypted, &nonce).unwrap();
assert_eq!(original, decrypted);
}
#[test]
fn test_save_and_load_config() {
let (storage, _temp) = create_test_storage();
let config = VpnConfig {
id: "test-id-123".to_string(),
name: "Test VPN".to_string(),
vpn_type: VpnType::WireGuard,
config_data: "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer".to_string(),
created_at: 1234567890,
last_used: None,
};
storage.save_config(&config).unwrap();
let loaded = storage.load_config("test-id-123").unwrap();
assert_eq!(loaded.id, config.id);
assert_eq!(loaded.name, config.name);
assert_eq!(loaded.vpn_type, config.vpn_type);
assert_eq!(loaded.config_data, config.config_data);
}
#[test]
fn test_list_configs() {
let (storage, _temp) = create_test_storage();
let config1 = VpnConfig {
id: "id-1".to_string(),
name: "VPN 1".to_string(),
vpn_type: VpnType::WireGuard,
config_data: "secret1".to_string(),
created_at: 1000,
last_used: None,
};
let config2 = VpnConfig {
id: "id-2".to_string(),
name: "VPN 2".to_string(),
vpn_type: VpnType::OpenVPN,
config_data: "secret2".to_string(),
created_at: 2000,
last_used: Some(3000),
};
storage.save_config(&config1).unwrap();
storage.save_config(&config2).unwrap();
let configs = storage.list_configs().unwrap();
assert_eq!(configs.len(), 2);
// Config data should be empty in listing
assert!(configs[0].config_data.is_empty());
assert!(configs[1].config_data.is_empty());
}
#[test]
fn test_delete_config() {
let (storage, _temp) = create_test_storage();
let config = VpnConfig {
id: "delete-me".to_string(),
name: "To Delete".to_string(),
vpn_type: VpnType::WireGuard,
config_data: "data".to_string(),
created_at: 1000,
last_used: None,
};
storage.save_config(&config).unwrap();
assert!(storage.load_config("delete-me").is_ok());
storage.delete_config("delete-me").unwrap();
assert!(storage.load_config("delete-me").is_err());
}
#[test]
fn test_load_nonexistent_config() {
let (storage, _temp) = create_test_storage();
let result = storage.load_config("nonexistent");
assert!(result.is_err());
}
}
+256
View File
@@ -0,0 +1,256 @@
//! VPN tunnel trait and management.
use super::config::{VpnError, VpnStatus};
use async_trait::async_trait;
use std::collections::HashMap;
/// Trait for VPN tunnel implementations
#[async_trait]
pub trait VpnTunnel: Send + Sync {
/// Connect the VPN tunnel
async fn connect(&mut self) -> Result<(), VpnError>;
/// Disconnect the VPN tunnel
async fn disconnect(&mut self) -> Result<(), VpnError>;
/// Check if the tunnel is connected
fn is_connected(&self) -> bool;
/// Get the VPN config ID
fn vpn_id(&self) -> &str;
/// Get the current status of the tunnel
fn get_status(&self) -> VpnStatus;
/// Get bytes sent through the tunnel
fn bytes_sent(&self) -> u64;
/// Get bytes received through the tunnel
fn bytes_received(&self) -> u64;
}
/// Manager for active VPN tunnels
pub struct TunnelManager {
active_tunnels: HashMap<String, Box<dyn VpnTunnel>>,
}
impl Default for TunnelManager {
fn default() -> Self {
Self::new()
}
}
impl TunnelManager {
/// Create a new tunnel manager
pub fn new() -> Self {
Self {
active_tunnels: HashMap::new(),
}
}
/// Register an active tunnel
pub fn register_tunnel(&mut self, vpn_id: String, tunnel: Box<dyn VpnTunnel>) {
self.active_tunnels.insert(vpn_id, tunnel);
}
/// Remove a tunnel from management
pub fn remove_tunnel(&mut self, vpn_id: &str) -> Option<Box<dyn VpnTunnel>> {
self.active_tunnels.remove(vpn_id)
}
/// Get a reference to an active tunnel
pub fn get_tunnel(&self, vpn_id: &str) -> Option<&dyn VpnTunnel> {
self.active_tunnels.get(vpn_id).map(|t| t.as_ref())
}
/// Get a mutable reference to an active tunnel
pub fn get_tunnel_mut(&mut self, vpn_id: &str) -> Option<&mut Box<dyn VpnTunnel>> {
self.active_tunnels.get_mut(vpn_id)
}
/// Check if a tunnel is active
pub fn is_tunnel_active(&self, vpn_id: &str) -> bool {
self
.active_tunnels
.get(vpn_id)
.is_some_and(|t| t.is_connected())
}
/// Get status of all active tunnels
pub fn get_all_statuses(&self) -> Vec<VpnStatus> {
self
.active_tunnels
.values()
.map(|t| t.get_status())
.collect()
}
/// Disconnect all active tunnels
pub async fn disconnect_all(&mut self) -> Vec<Result<(), VpnError>> {
let mut results = Vec::new();
for tunnel in self.active_tunnels.values_mut() {
results.push(tunnel.disconnect().await);
}
self.active_tunnels.clear();
results
}
/// Get the number of active tunnels
pub fn active_count(&self) -> usize {
self
.active_tunnels
.values()
.filter(|t| t.is_connected())
.count()
}
/// List IDs of all active VPN connections
pub fn list_active_ids(&self) -> Vec<String> {
self
.active_tunnels
.iter()
.filter(|(_, t)| t.is_connected())
.map(|(id, _)| id.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockTunnel {
id: String,
connected: bool,
bytes_sent: u64,
bytes_received: u64,
}
#[async_trait]
impl VpnTunnel for MockTunnel {
async fn connect(&mut self) -> Result<(), VpnError> {
self.connected = true;
Ok(())
}
async fn disconnect(&mut self) -> Result<(), VpnError> {
self.connected = false;
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn vpn_id(&self) -> &str {
&self.id
}
fn get_status(&self) -> VpnStatus {
VpnStatus {
connected: self.connected,
vpn_id: self.id.clone(),
connected_at: if self.connected { Some(1000) } else { None },
bytes_sent: Some(self.bytes_sent),
bytes_received: Some(self.bytes_received),
last_handshake: None,
}
}
fn bytes_sent(&self) -> u64 {
self.bytes_sent
}
fn bytes_received(&self) -> u64 {
self.bytes_received
}
}
#[test]
fn test_tunnel_manager_register() {
let mut manager = TunnelManager::new();
let tunnel = Box::new(MockTunnel {
id: "test-1".to_string(),
connected: true,
bytes_sent: 100,
bytes_received: 200,
});
manager.register_tunnel("test-1".to_string(), tunnel);
assert!(manager.is_tunnel_active("test-1"));
assert!(!manager.is_tunnel_active("test-2"));
}
#[test]
fn test_tunnel_manager_remove() {
let mut manager = TunnelManager::new();
let tunnel = Box::new(MockTunnel {
id: "test-1".to_string(),
connected: true,
bytes_sent: 0,
bytes_received: 0,
});
manager.register_tunnel("test-1".to_string(), tunnel);
assert!(manager.is_tunnel_active("test-1"));
let removed = manager.remove_tunnel("test-1");
assert!(removed.is_some());
assert!(!manager.is_tunnel_active("test-1"));
}
#[test]
fn test_tunnel_manager_active_count() {
let mut manager = TunnelManager::new();
let tunnel1 = Box::new(MockTunnel {
id: "t1".to_string(),
connected: true,
bytes_sent: 0,
bytes_received: 0,
});
let tunnel2 = Box::new(MockTunnel {
id: "t2".to_string(),
connected: false,
bytes_sent: 0,
bytes_received: 0,
});
manager.register_tunnel("t1".to_string(), tunnel1);
manager.register_tunnel("t2".to_string(), tunnel2);
assert_eq!(manager.active_count(), 1);
}
#[tokio::test]
async fn test_tunnel_manager_disconnect_all() {
let mut manager = TunnelManager::new();
let tunnel1 = Box::new(MockTunnel {
id: "t1".to_string(),
connected: true,
bytes_sent: 0,
bytes_received: 0,
});
let tunnel2 = Box::new(MockTunnel {
id: "t2".to_string(),
connected: true,
bytes_sent: 0,
bytes_received: 0,
});
manager.register_tunnel("t1".to_string(), tunnel1);
manager.register_tunnel("t2".to_string(), tunnel2);
assert_eq!(manager.active_count(), 2);
let results = manager.disconnect_all().await;
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.is_ok()));
assert_eq!(manager.active_count(), 0);
}
}
+413
View File
@@ -0,0 +1,413 @@
//! WireGuard tunnel implementation using boringtun.
use super::config::{VpnError, VpnStatus, WireGuardConfig};
use super::tunnel::VpnTunnel;
use async_trait::async_trait;
use boringtun::noise::{Tunn, TunnResult};
use boringtun::x25519::{PublicKey, StaticSecret};
use chrono::Utc;
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
/// WireGuard tunnel implementation
pub struct WireGuardTunnel {
vpn_id: String,
config: WireGuardConfig,
tunnel: Option<Arc<Mutex<Box<Tunn>>>>,
socket: Option<Arc<UdpSocket>>,
connected: AtomicBool,
connected_at: Option<i64>,
bytes_sent: AtomicU64,
bytes_received: AtomicU64,
last_handshake: Option<i64>,
peer_addr: Option<SocketAddr>,
}
impl WireGuardTunnel {
/// Create a new WireGuard tunnel
pub fn new(vpn_id: String, config: WireGuardConfig) -> Self {
Self {
vpn_id,
config,
tunnel: None,
socket: None,
connected: AtomicBool::new(false),
connected_at: None,
bytes_sent: AtomicU64::new(0),
bytes_received: AtomicU64::new(0),
last_handshake: None,
peer_addr: None,
}
}
/// Parse base64 key to bytes
fn parse_key(key: &str) -> Result<[u8; 32], VpnError> {
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key)
.map_err(|e| VpnError::InvalidWireGuard(format!("Invalid key encoding: {e}")))?;
if decoded.len() != 32 {
return Err(VpnError::InvalidWireGuard(format!(
"Invalid key length: {} (expected 32)",
decoded.len()
)));
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&decoded);
Ok(key_bytes)
}
/// Initialize the WireGuard tunnel
fn init_tunnel(&mut self) -> Result<(), VpnError> {
// Parse private key
let private_key_bytes = Self::parse_key(&self.config.private_key)?;
let static_private = StaticSecret::from(private_key_bytes);
// Parse peer public key
let peer_public_bytes = Self::parse_key(&self.config.peer_public_key)?;
let peer_public = PublicKey::from(peer_public_bytes);
// Parse optional preshared key
let preshared_key = if let Some(ref psk) = self.config.preshared_key {
Some(Self::parse_key(psk)?)
} else {
None
};
// Create the boringtun tunnel
let tunn = Tunn::new(
static_private,
peer_public,
preshared_key,
self.config.persistent_keepalive,
0, // index
None,
)
.map_err(|e| VpnError::Tunnel(format!("Failed to create tunnel: {e}")))?;
self.tunnel = Some(Arc::new(Mutex::new(Box::new(tunn))));
Ok(())
}
/// Resolve peer endpoint to socket address
fn resolve_endpoint(&mut self) -> Result<SocketAddr, VpnError> {
let endpoint = &self.config.peer_endpoint;
// Try to resolve the endpoint
let addrs: Vec<SocketAddr> = endpoint
.to_socket_addrs()
.map_err(|e| VpnError::Connection(format!("Failed to resolve endpoint '{endpoint}': {e}")))?
.collect();
addrs
.into_iter()
.next()
.ok_or_else(|| VpnError::Connection(format!("No addresses found for endpoint: {endpoint}")))
}
/// Perform WireGuard handshake
async fn handshake(&mut self) -> Result<(), VpnError> {
let tunnel = self
.tunnel
.as_ref()
.ok_or_else(|| VpnError::Tunnel("Tunnel not initialized".to_string()))?;
let socket = self
.socket
.as_ref()
.ok_or_else(|| VpnError::Tunnel("Socket not initialized".to_string()))?;
let peer_addr = self
.peer_addr
.ok_or_else(|| VpnError::Tunnel("Peer address not resolved".to_string()))?;
let mut tunnel_guard = tunnel.lock().await;
// Generate handshake initiation
let mut dst = vec![0u8; 2048];
let result = tunnel_guard.format_handshake_initiation(&mut dst, false);
match result {
TunnResult::WriteToNetwork(packet) => {
socket
.send_to(packet, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
self
.bytes_sent
.fetch_add(packet.len() as u64, Ordering::Relaxed);
}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake initiation failed: {e:?}"
)));
}
_ => {}
}
// Wait for handshake response (with timeout)
socket
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
let mut recv_buf = vec![0u8; 2048];
match socket.recv_from(&mut recv_buf) {
Ok((len, _from)) => {
self.bytes_received.fetch_add(len as u64, Ordering::Relaxed);
let result = tunnel_guard.decapsulate(None, &recv_buf[..len], &mut dst);
match result {
TunnResult::WriteToNetwork(response) => {
socket
.send_to(response, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
self
.bytes_sent
.fetch_add(response.len() as u64, Ordering::Relaxed);
self.last_handshake = Some(Utc::now().timestamp());
}
TunnResult::Done => {
self.last_handshake = Some(Utc::now().timestamp());
}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake response failed: {e:?}"
)));
}
_ => {}
}
}
Err(e) => {
return Err(VpnError::Connection(format!(
"Handshake timeout or error: {e}"
)));
}
}
Ok(())
}
/// Encrypt and send data through the tunnel
pub async fn send(&self, data: &[u8]) -> Result<(), VpnError> {
let tunnel = self
.tunnel
.as_ref()
.ok_or_else(|| VpnError::Tunnel("Tunnel not initialized".to_string()))?;
let socket = self
.socket
.as_ref()
.ok_or_else(|| VpnError::Tunnel("Socket not initialized".to_string()))?;
let peer_addr = self
.peer_addr
.ok_or_else(|| VpnError::Tunnel("Peer address not resolved".to_string()))?;
let mut tunnel_guard = tunnel.lock().await;
let mut dst = vec![0u8; data.len() + 256]; // Extra space for WireGuard overhead
let result = tunnel_guard.encapsulate(data, &mut dst);
match result {
TunnResult::WriteToNetwork(packet) => {
socket
.send_to(packet, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send data: {e}")))?;
self
.bytes_sent
.fetch_add(packet.len() as u64, Ordering::Relaxed);
}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!("Encryption failed: {e:?}")));
}
_ => {}
}
Ok(())
}
/// Receive and decrypt data from the tunnel
pub async fn receive(&self, buf: &mut [u8]) -> Result<usize, VpnError> {
let tunnel = self
.tunnel
.as_ref()
.ok_or_else(|| VpnError::Tunnel("Tunnel not initialized".to_string()))?;
let socket = self
.socket
.as_ref()
.ok_or_else(|| VpnError::Tunnel("Socket not initialized".to_string()))?;
let mut recv_buf = vec![0u8; 2048];
let (len, _from) = socket
.recv_from(&mut recv_buf)
.map_err(|e| VpnError::Connection(format!("Receive failed: {e}")))?;
self.bytes_received.fetch_add(len as u64, Ordering::Relaxed);
let mut tunnel_guard = tunnel.lock().await;
// decapsulate writes decrypted data directly to buf and returns a slice pointing to it
let result = tunnel_guard.decapsulate(None, &recv_buf[..len], buf);
match result {
// Data is already written to buf by decapsulate, just return the length
TunnResult::WriteToTunnelV4(decrypted, _) => Ok(decrypted.len()),
TunnResult::WriteToTunnelV6(decrypted, _) => Ok(decrypted.len()),
TunnResult::Done => Ok(0),
TunnResult::Err(e) => Err(VpnError::Tunnel(format!("Decryption failed: {e:?}"))),
_ => Ok(0),
}
}
}
#[async_trait]
impl VpnTunnel for WireGuardTunnel {
async fn connect(&mut self) -> Result<(), VpnError> {
if self.connected.load(Ordering::Relaxed) {
return Ok(());
}
// Initialize the tunnel
self.init_tunnel()?;
// Resolve endpoint
self.peer_addr = Some(self.resolve_endpoint()?);
// Create UDP socket
let socket = UdpSocket::bind("0.0.0.0:0")
.map_err(|e| VpnError::Connection(format!("Failed to create socket: {e}")))?;
socket
.set_nonblocking(false)
.map_err(|e| VpnError::Connection(format!("Failed to set socket options: {e}")))?;
self.socket = Some(Arc::new(socket));
// Perform handshake
self.handshake().await?;
self.connected.store(true, Ordering::Release);
self.connected_at = Some(Utc::now().timestamp());
log::info!("[vpn] WireGuard tunnel {} connected", self.vpn_id);
Ok(())
}
async fn disconnect(&mut self) -> Result<(), VpnError> {
if !self.connected.load(Ordering::Relaxed) {
return Ok(());
}
self.connected.store(false, Ordering::Release);
self.tunnel = None;
self.socket = None;
self.connected_at = None;
log::info!("[vpn] WireGuard 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: self.last_handshake,
}
}
fn bytes_sent(&self) -> u64 {
self.bytes_sent.load(Ordering::Relaxed)
}
fn bytes_received(&self) -> u64 {
self.bytes_received.load(Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_config() -> WireGuardConfig {
WireGuardConfig {
// These are test keys, not real ones
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
address: "10.0.0.2/24".to_string(),
dns: Some("1.1.1.1".to_string()),
mtu: Some(1420),
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(),
peer_endpoint: "127.0.0.1:51820".to_string(),
allowed_ips: vec!["0.0.0.0/0".to_string()],
persistent_keepalive: Some(25),
preshared_key: None,
}
}
#[test]
fn test_wireguard_tunnel_creation() {
let config = create_test_config();
let tunnel = WireGuardTunnel::new("test-wg-1".to_string(), config);
assert_eq!(tunnel.vpn_id(), "test-wg-1");
assert!(!tunnel.is_connected());
assert_eq!(tunnel.bytes_sent(), 0);
assert_eq!(tunnel.bytes_received(), 0);
}
#[test]
fn test_parse_key_valid() {
// Valid base64-encoded 32-byte key
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
let result = WireGuardTunnel::parse_key(key);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 32);
}
#[test]
fn test_parse_key_invalid_base64() {
let key = "not-valid-base64!!!";
let result = WireGuardTunnel::parse_key(key);
assert!(result.is_err());
}
#[test]
fn test_parse_key_wrong_length() {
// Valid base64 but wrong length
let key = "YWJjZA=="; // "abcd" in base64
let result = WireGuardTunnel::parse_key(key);
assert!(result.is_err());
}
#[test]
fn test_wireguard_status() {
let config = create_test_config();
let tunnel = WireGuardTunnel::new("test-wg-2".to_string(), config);
let status = tunnel.get_status();
assert!(!status.connected);
assert_eq!(status.vpn_id, "test-wg-2");
assert!(status.connected_at.is_none());
assert_eq!(status.bytes_sent, Some(0));
assert_eq!(status.bytes_received, Some(0));
}
}
+6
View File
@@ -97,6 +97,12 @@ impl WayfernTermsManager {
timestamp >= MIN_VALID_TIMESTAMP
}
pub fn is_wayfern_downloaded(&self) -> bool {
let registry = DownloadedBrowsersRegistry::instance();
let versions = registry.get_downloaded_versions("wayfern");
!versions.is_empty()
}
fn get_any_wayfern_executable(&self) -> Option<PathBuf> {
// First try to get executable from any downloaded Wayfern version
let registry = DownloadedBrowsersRegistry::instance();
+13
View File
@@ -0,0 +1,13 @@
# Sample WireGuard configuration for testing
# This is NOT a real configuration - for unit test purposes only
[Interface]
PrivateKey = YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=
Address = 10.0.0.2/24
DNS = 1.1.1.1
[Peer]
PublicKey = aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = vpn.example.com:51820
PersistentKeepalive = 25
+39
View File
@@ -0,0 +1,39 @@
# 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>
+275
View File
@@ -0,0 +1,275 @@
//! Test harness for VPN integration tests.
//!
//! This module provides Docker-based test infrastructure for WireGuard and OpenVPN tests.
//! In CI environments, it uses pre-configured service containers.
//! In local development, it spawns Docker containers on demand.
//!
//! Note: These utilities are available for tests that need Docker containers,
//! but may not be used in all test configurations.
#![allow(dead_code)]
use std::process::Command;
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";
/// Check if running in CI environment
pub fn is_ci() -> bool {
std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok()
}
/// Check if Docker is available
pub fn is_docker_available() -> bool {
Command::new("docker")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Start a WireGuard test server and return client config
pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
if is_ci() {
// In CI, use the service container configured in workflow
let host = std::env::var("VPN_TEST_WG_HOST").unwrap_or_else(|_| "localhost".into());
let port = std::env::var("VPN_TEST_WG_PORT").unwrap_or_else(|_| "51820".into());
// Wait for service to be ready
wait_for_service(&host, port.parse().unwrap_or(51820)).await?;
return get_ci_wireguard_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", WG_CONTAINER])
.output();
// Start WireGuard container
let output = Command::new("docker")
.args([
"run",
"-d",
"--name",
WG_CONTAINER,
"--cap-add=NET_ADMIN",
"-p",
"51820:51820/udp",
"-e",
"PEERS=1",
"-e",
"SERVERURL=127.0.0.1",
"-e",
"SERVERPORT=51820",
"-e",
"PEERDNS=auto",
WIREGUARD_IMAGE,
])
.output()
.map_err(|e| format!("Failed to start WireGuard container: {e}"))?;
if !output.status.success() {
return Err(format!(
"Docker run failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
// Wait for container to be ready and generate configs
sleep(Duration::from_secs(10)).await;
// Extract client config from container
let config_output = Command::new("docker")
.args(["exec", WG_CONTAINER, "cat", "/config/peer1/peer1.conf"])
.output()
.map_err(|e| format!("Failed to get client config: {e}"))?;
if !config_output.status.success() {
return Err(format!(
"Failed to read config: {}",
String::from_utf8_lossy(&config_output.stderr)
));
}
let config_str = String::from_utf8_lossy(&config_output.stdout).to_string();
parse_wireguard_test_config(&config_str)
}
/// Start an OpenVPN test server and return client config
pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
if is_ci() {
// In CI, use the service container configured in workflow
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());
// Wait for service to be ready
wait_for_service(&host, port.parse().unwrap_or(1194)).await?;
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();
// For OpenVPN, we need to initialize PKI first, which is complex
// For simplicity in tests, we'll use a pre-configured test config
Err("OpenVPN container setup requires pre-configured PKI. Use test fixtures instead.".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();
}
/// Wait for a network service to be ready
async fn wait_for_service(host: &str, port: u16) -> Result<(), String> {
let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if std::net::TcpStream::connect(format!("{host}:{port}")).is_ok() {
return Ok(());
}
sleep(Duration::from_millis(500)).await;
}
Err(format!("Timeout waiting for service at {host}:{port}"))
}
/// WireGuard test configuration
pub struct WireGuardTestConfig {
pub private_key: String,
pub address: String,
pub dns: Option<String>,
pub peer_public_key: String,
pub peer_endpoint: String,
pub allowed_ips: Vec<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();
let mut address = String::new();
let mut dns = None;
let mut peer_public_key = String::new();
let mut peer_endpoint = String::new();
let mut allowed_ips = vec!["0.0.0.0/0".to_string()];
let mut current_section = "";
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line == "[Interface]" {
current_section = "interface";
continue;
}
if line == "[Peer]" {
current_section = "peer";
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
match (current_section, key) {
("interface", "PrivateKey") => private_key = value.to_string(),
("interface", "Address") => address = value.to_string(),
("interface", "DNS") => dns = Some(value.to_string()),
("peer", "PublicKey") => peer_public_key = value.to_string(),
("peer", "Endpoint") => peer_endpoint = value.to_string(),
("peer", "AllowedIPs") => {
allowed_ips = value.split(',').map(|s| s.trim().to_string()).collect();
}
_ => {}
}
}
}
if private_key.is_empty() || address.is_empty() || peer_public_key.is_empty() {
return Err("Invalid WireGuard config: missing required fields".to_string());
}
// Replace Endpoint with localhost for local testing
if peer_endpoint.contains("10.") || peer_endpoint.contains("172.") {
let port = peer_endpoint.split(':').next_back().unwrap_or("51820");
peer_endpoint = format!("127.0.0.1:{port}");
}
Ok(WireGuardTestConfig {
private_key,
address,
dns,
peer_public_key,
peer_endpoint,
allowed_ips,
})
}
/// Get WireGuard config from CI environment
fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig, String> {
// In CI, use environment variables or test fixtures
let private_key =
std::env::var("VPN_TEST_WG_PRIVATE_KEY").unwrap_or_else(|_| "test-private-key".to_string());
let public_key =
std::env::var("VPN_TEST_WG_PUBLIC_KEY").unwrap_or_else(|_| "test-public-key".to_string());
Ok(WireGuardTestConfig {
private_key,
address: "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}"),
allowed_ips: vec!["0.0.0.0/0".to_string()],
})
}
/// Get OpenVPN config from CI environment
fn get_ci_openvpn_config(host: &str, port: &str) -> Result<OpenVpnTestConfig, 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(),
})
}
+421
View File
@@ -0,0 +1,421 @@
//! VPN integration tests
//!
//! These tests verify VPN config parsing, storage, and tunnel functionality.
//! Connection tests require Docker and are skipped if Docker is not available.
mod test_harness;
use donutbrowser_lib::vpn::{
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
VpnStorage, VpnType, WireGuardConfig,
};
use serial_test::serial;
// ============================================================================
// Config Parsing Tests
// ============================================================================
#[test]
fn test_wireguard_config_import() {
let config = include_str!("fixtures/test.conf");
let result = parse_wireguard_config(config);
assert!(
result.is_ok(),
"Failed to parse WireGuard config: {:?}",
result.err()
);
let wg = result.unwrap();
assert!(!wg.private_key.is_empty());
assert_eq!(wg.address, "10.0.0.2/24");
assert_eq!(wg.dns, Some("1.1.1.1".to_string()));
assert!(!wg.peer_public_key.is_empty());
assert_eq!(wg.peer_endpoint, "vpn.example.com:51820");
assert!(wg.allowed_ips.contains(&"0.0.0.0/0".to_string()));
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";
let result = detect_vpn_type(content, "my-vpn.conf");
assert!(result.is_ok());
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#"
[Interface]
PrivateKey = somekey
Address = 10.0.0.2/24
[Peer]
PublicKey = peerkey
Endpoint = 1.2.3.4:51820
"#;
let result = detect_vpn_type(content, "config.txt");
assert!(result.is_ok());
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";
let result = detect_vpn_type(content, "random.txt");
assert!(result.is_err());
}
#[test]
fn test_wireguard_config_missing_private_key() {
let config = r#"
[Interface]
Address = 10.0.0.2/24
[Peer]
PublicKey = somekey
Endpoint = 1.2.3.4:51820
"#;
let result = parse_wireguard_config(config);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("PrivateKey"));
}
#[test]
fn test_wireguard_config_missing_peer() {
let config = r#"
[Interface]
PrivateKey = somekey
Address = 10.0.0.2/24
"#;
let result = parse_wireguard_config(config);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
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
// ============================================================================
#[test]
#[serial]
fn test_vpn_storage_save_and_load() {
let temp_dir = tempfile::TempDir::new().unwrap();
let storage = create_test_storage(&temp_dir);
let config = VpnConfig {
id: "test-id-1".to_string(),
name: "Test VPN".to_string(),
vpn_type: VpnType::WireGuard,
config_data: "[Interface]\nPrivateKey=key\n[Peer]\nPublicKey=peer".to_string(),
created_at: 1234567890,
last_used: None,
};
let save_result = storage.save_config(&config);
assert!(
save_result.is_ok(),
"Failed to save config: {:?}",
save_result.err()
);
let load_result = storage.load_config("test-id-1");
assert!(
load_result.is_ok(),
"Failed to load config: {:?}",
load_result.err()
);
let loaded = load_result.unwrap();
assert_eq!(loaded.id, config.id);
assert_eq!(loaded.name, config.name);
assert_eq!(loaded.vpn_type, config.vpn_type);
assert_eq!(loaded.config_data, config.config_data);
}
#[test]
#[serial]
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
},
config_data: "secret data".to_string(),
created_at: 1000 * i as i64,
last_used: None,
};
storage.save_config(&config).unwrap();
}
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());
}
}
#[test]
#[serial]
fn test_vpn_storage_delete() {
let temp_dir = tempfile::TempDir::new().unwrap();
let storage = create_test_storage(&temp_dir);
let config = VpnConfig {
id: "delete-test".to_string(),
name: "To Delete".to_string(),
vpn_type: VpnType::WireGuard,
config_data: "data".to_string(),
created_at: 1000,
last_used: None,
};
storage.save_config(&config).unwrap();
assert!(storage.load_config("delete-test").is_ok());
storage.delete_config("delete-test").unwrap();
assert!(storage.load_config("delete-test").is_err());
}
#[test]
#[serial]
fn test_vpn_storage_import() {
let temp_dir = tempfile::TempDir::new().unwrap();
let storage = create_test_storage(&temp_dir);
let wg_config = include_str!("fixtures/test.conf");
let result = storage.import_config(wg_config, "my-vpn.conf", Some("My WireGuard".to_string()));
assert!(result.is_ok(), "Import failed: {:?}", result.err());
let imported = result.unwrap();
assert_eq!(imported.name, "My WireGuard");
assert_eq!(imported.vpn_type, VpnType::WireGuard);
assert!(!imported.id.is_empty());
}
// ============================================================================
// Helper Functions
// ============================================================================
fn create_test_storage(_temp_dir: &tempfile::TempDir) -> VpnStorage {
// VpnStorage::new() uses the default path
// TODO: Pass temp_dir path when VpnStorage supports custom paths
VpnStorage::new()
}
// ============================================================================
// 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(),
dns: Some("1.1.1.1".to_string()),
mtu: None,
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(),
peer_endpoint: "127.0.0.1:51820".to_string(),
allowed_ips: vec!["0.0.0.0/0".to_string()],
persistent_keepalive: Some(25),
preshared_key: None,
};
use donutbrowser_lib::vpn::{VpnTunnel, WireGuardTunnel};
let tunnel = WireGuardTunnel::new("test-wg".to_string(), config);
assert_eq!(tunnel.vpn_id(), "test-wg");
assert!(!tunnel.is_connected());
assert_eq!(tunnel.bytes_sent(), 0);
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() {
use donutbrowser_lib::vpn::{TunnelManager, VpnStatus, VpnTunnel};
// Create a mock tunnel for testing the manager
struct MockTunnel {
id: String,
connected: bool,
}
#[async_trait::async_trait]
impl VpnTunnel for MockTunnel {
async fn connect(&mut self) -> Result<(), donutbrowser_lib::vpn::VpnError> {
self.connected = true;
Ok(())
}
async fn disconnect(&mut self) -> Result<(), donutbrowser_lib::vpn::VpnError> {
self.connected = false;
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn vpn_id(&self) -> &str {
&self.id
}
fn get_status(&self) -> VpnStatus {
VpnStatus {
connected: self.connected,
vpn_id: self.id.clone(),
connected_at: None,
bytes_sent: Some(0),
bytes_received: Some(0),
last_handshake: None,
}
}
fn bytes_sent(&self) -> u64 {
0
}
fn bytes_received(&self) -> u64 {
0
}
}
let mut manager = TunnelManager::new();
let tunnel = Box::new(MockTunnel {
id: "mock-1".to_string(),
connected: true,
});
manager.register_tunnel("mock-1".to_string(), tunnel);
assert!(manager.is_tunnel_active("mock-1"));
assert!(!manager.is_tunnel_active("nonexistent"));
assert_eq!(manager.active_count(), 1);
manager.remove_tunnel("mock-1");
assert!(!manager.is_tunnel_active("mock-1"));
assert_eq!(manager.active_count(), 0);
}
// NOTE: Actual connection tests require Docker containers running.
// These are meant to be run with the CI workflow that sets up service containers.
// To run locally: docker run -d --cap-add=NET_ADMIN -p 51820:51820/udp -e PEERS=1 linuxserver/wireguard
+8 -5
View File
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "@/styles/globals.css";
import "flag-icons/css/flag-icons.min.css";
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -33,11 +34,13 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden bg-background`}
>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</CustomThemeProvider>
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
</body>
</html>
);
+19
View File
@@ -786,6 +786,25 @@ export default function Home() {
}
}, [profiles]);
// Re-check Wayfern terms when a browser download completes
useEffect(() => {
let unlisten: (() => void) | null = null;
const setup = async () => {
unlisten = await listen<{ stage: string }>(
"download-progress",
(event) => {
if (event.payload.stage === "completed") {
void checkTerms();
}
},
);
};
void setup();
return () => {
if (unlisten) unlisten();
};
}, [checkTerms]);
// Check permissions when they are initialized
useEffect(() => {
if (isInitialized) {
+2 -7
View File
@@ -58,13 +58,8 @@ export function CommercialTrialModal({
<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.
</p>
<p className="text-sm font-medium">
Personal use remains free and unrestricted.
</p>
<p className="text-xs text-muted-foreground">
Visit our website to learn more about commercial licensing options.
purchase a commercial license to continue. You can still use it for
personal use for free.
</p>
</div>
+12 -10
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
@@ -37,6 +38,7 @@ const HomeHeader = ({
searchQuery,
onSearchQueryChange,
}: Props) => {
const { t } = useTranslation();
const handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
@@ -61,7 +63,7 @@ const HomeHeader = ({
<div className="relative">
<Input
type="text"
placeholder="Search profiles..."
placeholder={t("header.searchPlaceholder")}
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
className="pr-8 pl-10 w-48"
@@ -72,7 +74,7 @@ const HomeHeader = ({
type="button"
onClick={() => onSearchQueryChange("")}
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label="Clear search"
aria-label={t("header.clearSearch")}
>
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
@@ -93,7 +95,7 @@ const HomeHeader = ({
</Button>
</span>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
<TooltipContent>{t("header.moreActions")}</TooltipContent>
</Tooltip>
</span>
</DropdownMenuTrigger>
@@ -104,7 +106,7 @@ const HomeHeader = ({
}}
>
<GoGear className="mr-2 w-4 h-4" />
Settings
{t("header.menu.settings")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -112,7 +114,7 @@ const HomeHeader = ({
}}
>
<FiWifi className="mr-2 w-4 h-4" />
Proxies
{t("header.menu.proxies")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -120,7 +122,7 @@ const HomeHeader = ({
}}
>
<LuUsers className="mr-2 w-4 h-4" />
Groups
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -128,7 +130,7 @@ const HomeHeader = ({
}}
>
<LuCloud className="mr-2 w-4 h-4" />
Sync Service
{t("header.menu.syncService")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -136,7 +138,7 @@ const HomeHeader = ({
}}
>
<LuPlug className="mr-2 w-4 h-4" />
Integrations
{t("header.menu.integrations")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -144,7 +146,7 @@ const HomeHeader = ({
}}
>
<FaDownload className="mr-2 w-4 h-4" />
Import Profile
{t("header.menu.importProfile")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -166,7 +168,7 @@ const HomeHeader = ({
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
Create a new profile
{t("header.createProfile")}
</TooltipContent>
</Tooltip>
</div>
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { I18nextProvider } from "react-i18next";
import i18n, { getLanguageWithFallback, SUPPORTED_LANGUAGES } from "@/i18n";
interface AppSettings {
language?: string | null;
[key: string]: unknown;
}
interface I18nProviderProps {
children: React.ReactNode;
}
export function I18nProvider({ children }: I18nProviderProps) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const initializeLanguage = async () => {
try {
const settings = await invoke<AppSettings>("get_app_settings");
let language = settings.language;
if (!language) {
const systemLanguage = await invoke<string>("get_system_language");
language = getLanguageWithFallback(systemLanguage);
}
if (
language &&
SUPPORTED_LANGUAGES.some((lang) => lang.code === language)
) {
await i18n.changeLanguage(language);
}
} catch (error) {
console.error("Failed to initialize language:", error);
} finally {
setIsReady(true);
}
};
void initializeLanguage();
}, []);
if (!isReady) {
return null;
}
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { LuCheck, LuCopy, LuDownload } from "react-icons/lu";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { RippleButton } from "./ui/ripple";
interface ProxyExportDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
const [format, setFormat] = useState<"json" | "txt">("json");
const [exportContent, setExportContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const loadExportContent = useCallback(async () => {
setIsLoading(true);
try {
const content = await invoke<string>("export_proxies", { format });
setExportContent(content);
} catch (error) {
console.error("Failed to export proxies:", error);
toast.error("Failed to export proxies");
setExportContent("");
} finally {
setIsLoading(false);
}
}, [format]);
useEffect(() => {
if (isOpen) {
void loadExportContent();
}
}, [isOpen, loadExportContent]);
const handleCopyToClipboard = useCallback(async () => {
try {
await navigator.clipboard.writeText(exportContent);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
toast.error("Failed to copy to clipboard");
}
}, [exportContent]);
const handleDownload = useCallback(() => {
const filename = format === "json" ? "proxies.json" : "proxies.txt";
const mimeType = format === "json" ? "application/json" : "text/plain";
const blob = new Blob([exportContent], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`Downloaded ${filename}`);
}, [format, exportContent]);
const handleClose = useCallback(() => {
setFormat("json");
setExportContent("");
setCopied(false);
onClose();
}, [onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Export Proxies</DialogTitle>
<DialogDescription>
Export your proxy configurations to a file
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Export Format</Label>
<RadioGroup
value={format}
onValueChange={(value) => setFormat(value as "json" | "txt")}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="json" id="format-json" />
<Label htmlFor="format-json" className="cursor-pointer">
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)
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label>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...
</div>
) : exportContent ? (
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
{exportContent}
</pre>
) : (
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
No proxies to export
</div>
)}
</ScrollArea>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<RippleButton variant="outline" onClick={handleClose}>
Close
</RippleButton>
<RippleButton
variant="outline"
onClick={() => void handleCopyToClipboard()}
disabled={!exportContent || isLoading}
className="flex gap-2 items-center"
>
{copied ? (
<LuCheck className="w-4 h-4" />
) : (
<LuCopy className="w-4 h-4" />
)}
{copied ? "Copied" : "Copy"}
</RippleButton>
<RippleButton
onClick={handleDownload}
disabled={!exportContent || isLoading}
className="flex gap-2 items-center"
>
<LuDownload className="w-4 h-4" />
Download
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+727
View File
@@ -0,0 +1,727 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { LuShield, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getCurrentOS } from "@/lib/browser-utils";
import type {
ParsedProxyLine,
ProxyImportResult,
ProxyParseResult,
VpnImportResult,
VpnType,
} from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyImportDialogProps {
isOpen: boolean;
onClose: () => void;
}
type ImportStep =
| "dropzone"
| "preview"
| "ambiguous"
| "result"
| "vpn-preview"
| "vpn-result";
interface AmbiguousProxy {
line: string;
possible_formats: string[];
selectedFormat?: string;
}
interface VpnPreviewData {
content: string;
filename: string;
detectedType: VpnType | null;
endpoint: string | null;
}
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
const [step, setStep] = useState<ImportStep>("dropzone");
const [isDragOver, setIsDragOver] = useState(false);
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
const [ambiguousProxies, setAmbiguousProxies] = useState<AmbiguousProxy[]>(
[],
);
const [invalidProxies, setInvalidProxies] = useState<
{ line: string; reason: string }[]
>([]);
const [importResult, setImportResult] = useState<ProxyImportResult | null>(
null,
);
const [isImporting, setIsImporting] = useState(false);
const [namePrefix, setNamePrefix] = useState("Imported");
// VPN import state
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
const [vpnName, setVpnName] = useState("");
const [vpnImportResult, setVpnImportResult] =
useState<VpnImportResult | null>(null);
const os = getCurrentOS();
const modKey = os === "macos" ? "⌘" : "Ctrl";
const resetState = useCallback(() => {
setStep("dropzone");
setIsDragOver(false);
setParsedProxies([]);
setAmbiguousProxies([]);
setInvalidProxies([]);
setImportResult(null);
setIsImporting(false);
setNamePrefix("Imported");
// Reset VPN state
setVpnPreview(null);
setVpnName("");
setVpnImportResult(null);
}, []);
// Detect VPN type from content
const detectVpnType = useCallback(
(
content: string,
filename: string,
): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => {
const lowerFilename = filename.toLowerCase();
// Check for WireGuard config
if (
lowerFilename.endsWith(".conf") &&
content.includes("[Interface]") &&
content.includes("[Peer]")
) {
// Extract endpoint from WireGuard config
const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i);
return {
isVpn: true,
type: "WireGuard",
endpoint: endpointMatch ? endpointMatch[1] : null,
};
}
// Check for OpenVPN config
if (
lowerFilename.endsWith(".ovpn") ||
(content.includes("remote ") &&
(content.includes("client") || content.includes("dev tun")))
) {
// Extract remote from OpenVPN config
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 };
},
[],
);
const processContent = useCallback(
async (content: string, isJson: boolean, filename: string = "") => {
try {
// Check if it's a VPN config
const vpnDetection = detectVpnType(content, filename);
if (vpnDetection.isVpn) {
setVpnPreview({
content,
filename,
detectedType: vpnDetection.type,
endpoint: vpnDetection.endpoint,
});
// Generate default name from filename
const baseName = filename
.replace(/\.(conf|ovpn)$/i, "")
.replace(/_/g, " ")
.replace(/-/g, " ");
setVpnName(baseName || `${vpnDetection.type} VPN`);
setStep("vpn-preview");
return;
}
if (isJson) {
setIsImporting(true);
const result = await invoke<ProxyImportResult>(
"import_proxies_json",
{
content,
},
);
setImportResult(result);
setStep("result");
await emit("stored-proxies-changed");
} else {
const results = await invoke<ProxyParseResult[]>(
"parse_txt_proxies",
{
content,
},
);
const parsed: ParsedProxyLine[] = [];
const ambiguous: AmbiguousProxy[] = [];
const invalid: { line: string; reason: string }[] = [];
for (const result of results) {
if (result.status === "parsed") {
parsed.push(result);
} else if (result.status === "ambiguous") {
ambiguous.push({
line: result.line,
possible_formats: result.possible_formats,
});
} else if (result.status === "invalid") {
invalid.push({ line: result.line, reason: result.reason });
}
}
setParsedProxies(parsed);
setAmbiguousProxies(ambiguous);
setInvalidProxies(invalid);
if (ambiguous.length > 0) {
setStep("ambiguous");
} else if (parsed.length > 0) {
setStep("preview");
} else {
toast.error("No valid proxies found in the file");
}
}
} catch (error) {
console.error("Failed to process content:", error);
toast.error(
error instanceof Error ? error.message : "Failed to process file",
);
} finally {
setIsImporting(false);
}
},
[detectVpnType],
);
const handleFileRead = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const isJson = file.name.endsWith(".json");
void processContent(content, isJson, file.name);
};
reader.onerror = () => {
toast.error("Failed to read file");
};
reader.readAsText(file);
},
[processContent],
);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
const validFile = files.find(
(f) =>
f.name.endsWith(".json") ||
f.name.endsWith(".txt") ||
f.name.endsWith(".conf") || // WireGuard
f.name.endsWith(".ovpn"), // OpenVPN
);
if (validFile) {
handleFileRead(validFile);
} else {
toast.error("Please drop a .json, .txt, .conf, or .ovpn file");
}
},
[handleFileRead],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
}, []);
// Handle paste from clipboard
useEffect(() => {
if (!isOpen || step !== "dropzone") return;
const handlePaste = async (e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text");
if (text) {
// Try to detect if it's JSON
const trimmed = text.trim();
const isJson =
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
// Use "pasted.txt" as filename to trigger content-based detection
await processContent(text, isJson, "pasted.txt");
}
};
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
};
}, [isOpen, step, processContent]);
const handleImport = useCallback(async () => {
setIsImporting(true);
try {
const result = await invoke<ProxyImportResult>(
"import_proxies_from_parsed",
{
parsedProxies,
namePrefix: namePrefix.trim() || "Imported",
},
);
setImportResult(result);
setStep("result");
await emit("stored-proxies-changed");
} catch (error) {
console.error("Failed to import proxies:", error);
toast.error(
error instanceof Error ? error.message : "Failed to import proxies",
);
} finally {
setIsImporting(false);
}
}, [parsedProxies, namePrefix]);
const handleVpnImport = useCallback(async () => {
if (!vpnPreview) return;
setIsImporting(true);
try {
const result = await invoke<VpnImportResult>("import_vpn_config", {
content: vpnPreview.content,
filename: vpnPreview.filename,
name: vpnName.trim() || null,
});
setVpnImportResult(result);
setStep("vpn-result");
if (result.success) {
await emit("vpn-configs-changed");
}
} catch (error) {
console.error("Failed to import VPN config:", error);
toast.error(
error instanceof Error ? error.message : "Failed to import VPN config",
);
} finally {
setIsImporting(false);
}
}, [vpnPreview, vpnName]);
const handleAmbiguousFormatSelect = useCallback(
(index: number, format: string) => {
setAmbiguousProxies((prev) =>
prev.map((p, i) =>
i === index ? { ...p, selectedFormat: format } : p,
),
);
},
[],
);
const handleResolveAmbiguous = useCallback(() => {
// Convert ambiguous proxies to parsed based on selected format
const resolved: ParsedProxyLine[] = ambiguousProxies
.filter((p) => p.selectedFormat)
.map((p) => {
const parts = p.line.split(":");
if (p.selectedFormat === "host:port:username:password") {
return {
proxy_type: "http",
host: parts[0],
port: Number.parseInt(parts[1], 10),
username: parts[2],
password: parts[3],
original_line: p.line,
};
}
// username:password:host:port
return {
proxy_type: "http",
host: parts[2],
port: Number.parseInt(parts[3], 10),
username: parts[0],
password: parts[1],
original_line: p.line,
};
});
setParsedProxies((prev) => [...prev, ...resolved]);
setStep("preview");
}, [ambiguousProxies]);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{step === "vpn-preview" || step === "vpn-result"
? "Import VPN Config"
: "Import Proxies"}
</DialogTitle>
<DialogDescription>
{step === "dropzone" &&
"Import proxies from a JSON or TXT file, or VPN configs (.conf/.ovpn)"}
{step === "preview" && "Review the proxies to import"}
{step === "ambiguous" &&
"Some proxies have ambiguous formats. Please select the correct format."}
{step === "result" && "Import completed"}
{step === "vpn-preview" && "Review the VPN configuration to import"}
{step === "vpn-result" && "VPN import completed"}
</DialogDescription>
</DialogHeader>
{step === "dropzone" && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-lg p-8
transition-colors cursor-pointer
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() =>
document.getElementById("proxy-file-input")?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("proxy-file-input")?.click();
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Drop a proxy or VPN config file
<br />
<span className="text-xs">(.json, .txt, .conf, .ovpn)</span>
</p>
<input
id="proxy-file-input"
type="file"
accept=".json,.txt,.conf,.ovpn"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
<p className="text-xs text-muted-foreground text-center">
Paste from clipboard with {modKey}+V
</p>
</div>
)}
{step === "preview" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name-prefix">Name Prefix</Label>
<Input
id="name-prefix"
placeholder="Imported"
value={namePrefix}
onChange={(e) => setNamePrefix(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Proxies will be named &quot;{namePrefix || "Imported"} Proxy
1&quot;, &quot;{namePrefix || "Imported"} Proxy 2&quot;, etc.
</p>
</div>
<div className="space-y-2">
<Label>
Proxies to import ({parsedProxies.length})
{invalidProxies.length > 0 && (
<span className="text-muted-foreground ml-2">
({invalidProxies.length} invalid)
</span>
)}
</Label>
<ScrollArea className="h-[200px] border rounded-md">
<div className="p-2 space-y-1">
{parsedProxies.map((proxy, i) => (
<div
key={`${proxy.original_line}-${i}`}
className="text-xs font-mono p-2 bg-muted/30 rounded"
>
<span className="text-primary">
{proxy.proxy_type}://
</span>
{proxy.username && (
<span className="text-muted-foreground">
{proxy.username}:***@
</span>
)}
<span>
{proxy.host}:{proxy.port}
</span>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
)}
{step === "ambiguous" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
The following proxies have an ambiguous format. Please select the
correct interpretation for each.
</p>
<ScrollArea className="h-[250px] border rounded-md">
<div className="p-3 space-y-4">
{ambiguousProxies.map((proxy, i) => (
<div
key={`${proxy.line}-${i}`}
className="space-y-2 pb-3 border-b last:border-0"
>
<code className="text-xs bg-muted px-2 py-1 rounded block">
{proxy.line}
</code>
<div className="flex flex-col gap-2">
{proxy.possible_formats.map((format) => (
<label
key={format}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="radio"
name={`format-${i}`}
checked={proxy.selectedFormat === format}
onChange={() =>
handleAmbiguousFormatSelect(i, format)
}
className="accent-primary"
/>
<span className="text-xs">{format}</span>
</label>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{step === "result" && importResult && (
<div className="space-y-4">
<div className="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 font-medium text-green-600 dark:text-green-400">
{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 font-medium text-yellow-600 dark:text-yellow-400">
{importResult.skipped_count}
</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">Errors:</span>
<span className="text-sm font-medium text-red-600 dark:text-red-400">
{importResult.errors.length}
</span>
</div>
)}
</div>
{importResult.errors.length > 0 && (
<div className="space-y-2">
<Label>Errors</Label>
<ScrollArea className="h-[100px] border rounded-md">
<div className="p-2 space-y-1">
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-red-600 dark:text-red-400"
>
{error}
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
)}
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<LuShield className="w-8 h-8 text-primary" />
<div>
<div className="font-medium">
{vpnPreview.detectedType} Configuration
</div>
{vpnPreview.endpoint && (
<div className="text-sm text-muted-foreground">
Endpoint: {vpnPreview.endpoint}
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="vpn-name">VPN Name</Label>
<Input
id="vpn-name"
placeholder="My VPN"
value={vpnName}
onChange={(e) => setVpnName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Config Preview</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)}
{vpnPreview.content.length > 1000 && "..."}
</pre>
</ScrollArea>
</div>
</div>
)}
{step === "vpn-result" && vpnImportResult && (
<div className="space-y-4">
<div
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
<div>
<div className="font-medium text-green-600 dark:text-green-400">
VPN Imported Successfully
</div>
<div className="text-sm text-muted-foreground">
{vpnImportResult.name} ({vpnImportResult.vpn_type})
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-red-600 dark:text-red-400">
Import Failed
</div>
<div className="text-sm text-red-600 dark:text-red-400">
{vpnImportResult.error}
</div>
</div>
)}
</div>
</div>
)}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
)}
{step === "preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={parsedProxies.length === 0}
>
Import {parsedProxies.length} Proxies
</LoadingButton>
</>
)}
{step === "ambiguous" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<RippleButton
onClick={handleResolveAmbiguous}
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
>
Continue
</RippleButton>
</>
)}
{step === "result" && (
<RippleButton onClick={handleClose}>Done</RippleButton>
)}
{step === "vpn-preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleVpnImport()}
>
Import VPN
</LoadingButton>
</>
)}
{step === "vpn-result" && (
<RippleButton onClick={handleClose}>Done</RippleButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+35 -4
View File
@@ -4,10 +4,12 @@ import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProxyExportDialog } from "@/components/proxy-export-dialog";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { ProxyImportDialog } from "@/components/proxy-import-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -19,7 +21,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
@@ -82,6 +83,8 @@ export function ProxyManagementDialog({
onClose,
}: ProxyManagementDialogProps) {
const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@@ -221,9 +224,29 @@ export function ProxyManagementDialog({
</DialogHeader>
<div className="space-y-4">
{/* Create new proxy button */}
{/* Proxy actions */}
<div className="flex justify-between items-center">
<Label>Proxies</Label>
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowExportDialog(true)}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="w-4 h-4" />
Export
</RippleButton>
</div>
<RippleButton
size="sm"
onClick={handleCreateProxy}
@@ -414,6 +437,14 @@ export function ProxyManagementDialog({
confirmButtonText="Delete"
isLoading={isDeleting}
/>
<ProxyImportDialog
isOpen={showImportDialog}
onClose={() => setShowImportDialog(false)}
/>
<ProxyExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
/>
</>
);
}
+96 -2
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import Color from "color";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
@@ -37,6 +38,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import { useLanguage } from "@/hooks/use-language";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import {
@@ -112,6 +114,7 @@ export function SettingsDialog({
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const { t } = useTranslation();
const { setTheme } = useTheme();
const {
requestPermission,
@@ -119,6 +122,14 @@ export function SettingsDialog({
isCameraAccessGranted,
} = usePermissions();
const { trialStatus } = useCommercialTrial();
const {
currentLanguage,
changeLanguage,
supportedLanguages,
isLoading: isLanguageLoading,
} = useLanguage();
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
const [originalLanguage, setOriginalLanguage] = useState<string | null>(null);
const getPermissionIcon = useCallback((type: PermissionType) => {
switch (type) {
@@ -316,9 +327,26 @@ export function SettingsDialog({
: settings.custom_theme,
};
console.log("[settings-dialog] Saving settings:", {
theme: settingsToSave.theme,
hasCustomTheme: !!settingsToSave.custom_theme,
customThemeKeys: settingsToSave.custom_theme
? Object.keys(settingsToSave.custom_theme).length
: 0,
});
const savedSettings = await invoke<AppSettings>("save_app_settings", {
settings: settingsToSave,
});
console.log("[settings-dialog] Saved settings response:", {
theme: savedSettings.theme,
hasCustomTheme: !!savedSettings.custom_theme,
customThemeKeys: savedSettings.custom_theme
? Object.keys(savedSettings.custom_theme).length
: 0,
});
// Update settings with any generated tokens
setSettings(savedSettings);
settingsToSave = savedSettings;
@@ -350,6 +378,23 @@ export function SettingsDialog({
} catch {}
}
// Save language if changed
if (selectedLanguage !== originalLanguage) {
await changeLanguage(
selectedLanguage === "system"
? null
: (selectedLanguage as
| "en"
| "es"
| "pt"
| "fr"
| "zh"
| "ja"
| "ru"),
);
setOriginalLanguage(selectedLanguage);
}
setOriginalSettings(settingsToSave);
onClose();
} catch (error) {
@@ -357,7 +402,15 @@ export function SettingsDialog({
} finally {
setIsSaving(false);
}
}, [onClose, setTheme, settings, customThemeState]);
}, [
onClose,
setTheme,
settings,
customThemeState,
selectedLanguage,
originalLanguage,
changeLanguage,
]);
const updateSetting = useCallback(
(
@@ -428,6 +481,14 @@ export function SettingsDialog({
}
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
// Initialize language selection when dialog opens or language loads
useEffect(() => {
if (isOpen && !isLanguageLoading) {
setSelectedLanguage(currentLanguage);
setOriginalLanguage(currentLanguage);
}
}, [isOpen, currentLanguage, isLanguageLoading]);
// Update permissions when the permission states change
useEffect(() => {
if (isMacOS) {
@@ -458,6 +519,7 @@ export function SettingsDialog({
const hasChanges =
settings.theme !== originalSettings.theme ||
settings.api_enabled !== originalSettings.api_enabled ||
selectedLanguage !== originalLanguage ||
(settings.theme === "custom" &&
JSON.stringify(customThemeState.colors) !==
JSON.stringify(originalSettings.custom_theme ?? {})) ||
@@ -469,7 +531,7 @@ export function SettingsDialog({
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>Settings</DialogTitle>
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
@@ -625,6 +687,38 @@ export function SettingsDialog({
)}
</div>
{/* Language Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Language</Label>
<div className="grid gap-2">
<Label htmlFor="language-select" className="text-sm">
Interface Language
</Label>
<Select
value={selectedLanguage || "system"}
onValueChange={(value) => setSelectedLanguage(value)}
disabled={isLanguageLoading}
>
<SelectTrigger id="language-select">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">System Default</SelectItem>
{supportedLanguages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Choose your preferred language for the application interface.
</p>
</div>
{/* Default Browser Section */}
<div className="space-y-4">
<div className="flex justify-between items-center">
+8
View File
@@ -31,6 +31,14 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const settings = await invoke<AppSettings>("get_app_settings");
const themeValue = settings?.theme ?? "system";
console.log("[theme-provider] Loaded settings:", {
theme: themeValue,
hasCustomTheme: !!settings?.custom_theme,
customThemeKeys: settings?.custom_theme
? Object.keys(settings.custom_theme).length
: 0,
});
if (
themeValue === "light" ||
themeValue === "dark" ||
+14 -1
View File
@@ -295,13 +295,26 @@ export function useBrowserDownload() {
eta: etaText,
},
{
onCancel: () => dismissToast(toastId),
onCancel: () => {
invoke("cancel_download", {
browserStr: progress.browser,
version: progress.version,
}).catch((err) =>
console.error("Failed to cancel download:", err),
);
dismissToast(toastId);
},
},
);
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
} else if (progress.stage === "cancelled") {
dismissToast(
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
} else if (progress.stage === "completed") {
// On completion, refresh the downloaded versions for this browser and also refresh camoufox,
// since the Create dialog implicitly uses camoufox on the anti-detect tab
+81
View File
@@ -0,0 +1,81 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getLanguageWithFallback,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from "@/i18n";
interface AppSettings {
language?: string | null;
[key: string]: unknown;
}
export function useLanguage() {
const { i18n } = useTranslation();
const [isLoading, setIsLoading] = useState(true);
const [currentLanguage, setCurrentLanguage] = useState<string>("en");
const loadLanguage = useCallback(async () => {
try {
const settings = await invoke<AppSettings>("get_app_settings");
let language = settings.language;
if (!language) {
const systemLanguage = await invoke<string>("get_system_language");
language = getLanguageWithFallback(systemLanguage);
}
if (
language &&
SUPPORTED_LANGUAGES.some((lang) => lang.code === language)
) {
await i18n.changeLanguage(language);
setCurrentLanguage(language);
}
} catch (error) {
console.error("Failed to load language setting:", error);
} finally {
setIsLoading(false);
}
}, [i18n]);
const changeLanguage = useCallback(
async (language: SupportedLanguage | null) => {
try {
const settings = await invoke<AppSettings>("get_app_settings");
const updatedSettings = {
...settings,
language,
};
await invoke("save_app_settings", { settings: updatedSettings });
if (language) {
await i18n.changeLanguage(language);
setCurrentLanguage(language);
} else {
const systemLanguage = await invoke<string>("get_system_language");
const fallbackLanguage = getLanguageWithFallback(systemLanguage);
await i18n.changeLanguage(fallbackLanguage);
setCurrentLanguage(fallbackLanguage);
}
} catch (error) {
console.error("Failed to change language:", error);
throw error;
}
},
[i18n],
);
useEffect(() => {
void loadLanguage();
}, [loadLanguage]);
return {
currentLanguage,
changeLanguage,
isLoading,
supportedLanguages: SUPPORTED_LANGUAGES,
};
}
+10 -2
View File
@@ -13,8 +13,16 @@ export function useWayfernTerms(): UseWayfernTermsReturn {
const checkTerms = useCallback(async () => {
try {
const accepted = await invoke<boolean>("check_wayfern_terms_accepted");
setTermsAccepted(accepted);
const [accepted, downloaded] = await Promise.all([
invoke<boolean>("check_wayfern_terms_accepted"),
invoke<boolean>("check_wayfern_downloaded"),
]);
// Only require terms when Wayfern is downloaded and terms not accepted
if (!downloaded) {
setTermsAccepted(true);
} else {
setTermsAccepted(accepted);
}
} catch (error) {
console.error("Failed to check terms acceptance:", error);
setTermsAccepted(false);
+79
View File
@@ -0,0 +1,79 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import es from "./locales/es.json";
import fr from "./locales/fr.json";
import ja from "./locales/ja.json";
import pt from "./locales/pt.json";
import ru from "./locales/ru.json";
import zh from "./locales/zh.json";
export const SUPPORTED_LANGUAGES = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "es", name: "Spanish", nativeName: "Español" },
{ code: "pt", name: "Portuguese", nativeName: "Português" },
{ code: "fr", name: "French", nativeName: "Français" },
{ code: "zh", name: "Chinese", nativeName: "中文" },
{ code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ru", name: "Russian", nativeName: "Русский" },
] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
export const LANGUAGE_FALLBACKS: Record<string, string[]> = {
uk: ["ru", "en"],
be: ["ru", "en"],
"zh-TW": ["zh", "en"],
"zh-CN": ["zh", "en"],
"zh-HK": ["zh", "en"],
"pt-BR": ["pt", "en"],
"pt-PT": ["pt", "en"],
"es-MX": ["es", "en"],
"es-AR": ["es", "en"],
"es-ES": ["es", "en"],
"fr-CA": ["fr", "en"],
"fr-FR": ["fr", "en"],
};
export function getLanguageWithFallback(systemLocale: string): string {
const baseLanguage = systemLocale.split(/[-_]/)[0].toLowerCase();
if (SUPPORTED_LANGUAGES.some((lang) => lang.code === baseLanguage)) {
return baseLanguage;
}
if (LANGUAGE_FALLBACKS[systemLocale]) {
return LANGUAGE_FALLBACKS[systemLocale][0];
}
if (LANGUAGE_FALLBACKS[baseLanguage]) {
return LANGUAGE_FALLBACKS[baseLanguage][0];
}
return "en";
}
const resources = {
en: { translation: en },
es: { translation: es },
pt: { translation: pt },
fr: { translation: fr },
zh: { translation: zh },
ja: { translation: ja },
ru: { translation: ru },
};
i18n.use(initReactI18next).init({
resources,
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
export default i18n;
+460
View File
@@ -0,0 +1,460 @@
{
"common": {
"buttons": {
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"delete": "Delete",
"create": "Create",
"back": "Back",
"retry": "Retry",
"download": "Download",
"confirm": "Confirm",
"apply": "Apply",
"reset": "Reset",
"add": "Add",
"edit": "Edit",
"copy": "Copy",
"clear": "Clear",
"search": "Search",
"select": "Select",
"grant": "Grant",
"start": "Start",
"stop": "Stop",
"enable": "Enable",
"disable": "Disable",
"import": "Import",
"export": "Export",
"refresh": "Refresh",
"loading": "Loading...",
"saveSettings": "Save Settings"
},
"status": {
"active": "Active",
"inactive": "Inactive",
"running": "Running",
"stopped": "Stopped",
"enabled": "Enabled",
"disabled": "Disabled",
"granted": "Granted",
"notGranted": "Not Granted",
"connected": "Connected",
"disconnected": "Disconnected",
"synced": "Synced",
"syncing": "Syncing",
"pending": "Pending",
"error": "Error"
},
"labels": {
"name": "Name",
"type": "Type",
"status": "Status",
"actions": "Actions",
"description": "Description",
"none": "None",
"default": "Default",
"custom": "Custom",
"optional": "Optional",
"required": "Required"
},
"time": {
"days": "days",
"hours": "hours",
"minutes": "minutes",
"seconds": "seconds",
"remaining": "remaining"
}
},
"settings": {
"title": "Settings",
"appearance": {
"title": "Appearance",
"theme": "Theme",
"themeDescription": "Choose your preferred theme or follow your system settings. Custom theme changes are applied only when you save.",
"themePreset": "Theme Preset",
"customColors": "Custom Colors",
"selectTheme": "Select theme",
"selectThemePreset": "Select a theme preset",
"yourOwn": "Your Own",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"language": {
"title": "Language",
"description": "Choose your preferred language for the application interface.",
"systemDefault": "System Default",
"selectLanguage": "Select language"
},
"defaultBrowser": {
"title": "Default Browser",
"setAsDefault": "Set as Default Browser",
"alreadyDefault": "Already Default Browser",
"description": "When set as default, Donut Browser will handle web links and allow you to choose which profile to use."
},
"permissions": {
"title": "System Permissions",
"loading": "Loading permissions...",
"description": "These permissions allow browsers launched from Donut Browser to access system resources. Each website will still ask for your permission individually.",
"microphone": "Microphone",
"microphoneDescription": "Access to microphone for browser applications",
"camera": "Camera",
"cameraDescription": "Access to camera for browser applications"
},
"integrations": {
"title": "Integrations",
"description": "Configure Local API and MCP (Model Context Protocol) for integrating with external tools and AI assistants.",
"openSettings": "Open Integrations Settings"
},
"commercial": {
"title": "Commercial License",
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
"trialActiveDescription": "Commercial use is free during the trial period",
"trialExpired": "Trial expired",
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license."
},
"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."
}
},
"header": {
"searchPlaceholder": "Search profiles...",
"clearSearch": "Clear search",
"moreActions": "More actions",
"createProfile": "Create a new profile",
"menu": {
"settings": "Settings",
"proxies": "Proxies",
"groups": "Groups",
"syncService": "Sync Service",
"integrations": "Integrations",
"importProfile": "Import Profile"
}
},
"profiles": {
"title": "Profiles",
"empty": "No profiles yet",
"emptyDescription": "Create your first browser profile to get started.",
"createFirst": "Create Profile",
"noResults": "No profiles found",
"noResultsDescription": "No profiles match your search criteria.",
"table": {
"name": "Name",
"browser": "Browser",
"status": "Status",
"actions": "Actions",
"note": "Note",
"group": "Group",
"proxy": "Proxy",
"lastLaunch": "Last Launch"
},
"actions": {
"launch": "Launch",
"stop": "Stop",
"edit": "Edit",
"delete": "Delete",
"copyCookies": "Copy Cookies",
"configure": "Configure"
}
},
"createProfile": {
"title": "Create New Profile",
"configureTitle": "Configure Profile",
"antiDetect": {
"title": "Anti-Detect Browser",
"description": "Choose a browser with anti-detection capabilities",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"badge": "Anti-Detect Browser"
},
"regular": {
"title": "Regular Browsers",
"description": "Choose from supported regular browsers",
"badge": "Regular Browser"
},
"profileName": "Profile Name",
"profileNamePlaceholder": "Enter profile name",
"proxy": {
"title": "Proxy",
"addProxy": "Add Proxy",
"noProxy": "No proxy",
"noProxiesAvailable": "No proxies available. Add one to route this profile's traffic."
},
"version": {
"fetching": "Fetching available versions...",
"fetchError": "Failed to fetch browser versions. Please check your internet connection and try again.",
"needsDownload": "{{browser}} version ({{version}}) needs to be downloaded",
"available": "{{browser}} version ({{version}}) is available",
"downloading": "Downloading {{browser}} version ({{version}})...",
"latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded",
"latestAvailable": "Latest version ({{version}}) is available",
"latestDownloading": "Downloading version ({{version}})..."
}
},
"deleteDialog": {
"title": "Delete Profile",
"description": "Are you sure you want to delete this profile? This action cannot be undone.",
"profilesTitle": "Delete Profiles",
"profilesDescription": "Are you sure you want to delete the selected profiles? This action cannot be undone.",
"profilesToDelete": "Profiles to be deleted:"
},
"proxies": {
"title": "Proxies",
"management": "Proxy Management",
"add": "Add Proxy",
"edit": "Edit Proxy",
"delete": "Delete Proxy",
"import": "Import",
"export": "Export",
"noProxies": "No proxies configured",
"noProxiesDescription": "Add a proxy to route browser traffic through it.",
"form": {
"name": "Name",
"namePlaceholder": "Enter proxy name",
"type": "Type",
"host": "Host",
"hostPlaceholder": "proxy.example.com",
"port": "Port",
"portPlaceholder": "8080",
"username": "Username",
"usernamePlaceholder": "Optional",
"password": "Password",
"passwordPlaceholder": "Optional"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"check": {
"checking": "Checking proxy...",
"valid": "Proxy is valid",
"invalid": "Proxy is invalid",
"lastChecked": "Last checked: {{time}}"
},
"sync": {
"enabled": "Sync Enabled",
"disabled": "Sync Disabled"
}
},
"groups": {
"title": "Groups",
"management": "Group Management",
"add": "Add Group",
"edit": "Edit Group",
"delete": "Delete Group",
"noGroups": "No groups created",
"noGroupsDescription": "Create a group to organize your profiles.",
"form": {
"name": "Name",
"namePlaceholder": "Enter group name"
},
"profileCount": "{{count}} profile",
"profileCount_plural": "{{count}} profiles",
"assignProfiles": "Assign Profiles",
"sync": {
"enabled": "Sync Enabled",
"disabled": "Sync Disabled"
}
},
"sync": {
"title": "Sync Service",
"config": "Sync Configuration",
"serverUrl": "Server URL",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "Sync Token",
"tokenPlaceholder": "Enter your sync token",
"status": {
"connected": "Connected",
"disconnected": "Disconnected",
"syncing": "Syncing...",
"error": "Sync Error"
},
"description": "Connect to a sync server to synchronize your profiles, proxies, and groups across devices."
},
"integrations": {
"title": "Integrations",
"api": {
"title": "Local API",
"description": "Enable the local API server for external integrations.",
"enabled": "API Enabled",
"disabled": "API Disabled",
"port": "Port",
"token": "API Token",
"copyToken": "Copy Token",
"regenerateToken": "Regenerate Token"
},
"mcp": {
"title": "MCP Server",
"description": "Enable the MCP (Model Context Protocol) server for AI assistant integrations.",
"enabled": "MCP Enabled",
"disabled": "MCP Disabled",
"port": "Port",
"token": "MCP Token",
"config": "MCP Configuration",
"copyConfig": "Copy Configuration"
}
},
"import": {
"title": "Import Profile",
"description": "Import an existing browser profile from your system.",
"selectProfile": "Select a profile to import",
"noProfiles": "No profiles detected",
"noProfilesDescription": "No browser profiles were detected on your system.",
"importing": "Importing profile...",
"success": "Profile imported successfully",
"error": "Failed to import profile"
},
"config": {
"camoufox": {
"title": "Camoufox Configuration",
"fingerprint": {
"title": "Fingerprint",
"randomize": "Randomize on Launch",
"randomizeDescription": "Generate a new fingerprint each time the browser is launched."
},
"os": {
"title": "Operating System",
"description": "The operating system to emulate for fingerprint generation.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux"
},
"screen": {
"title": "Screen Size",
"minWidth": "Min Width",
"maxWidth": "Max Width",
"minHeight": "Min Height",
"maxHeight": "Max Height"
},
"geoip": {
"title": "GeoIP",
"auto": "Automatic (based on proxy)",
"manual": "Manual",
"disabled": "Disabled"
},
"blocking": {
"title": "Blocking",
"images": "Block Images",
"webrtc": "Block WebRTC",
"webgl": "Block WebGL"
}
},
"wayfern": {
"title": "Wayfern Configuration",
"fingerprint": {
"title": "Fingerprint",
"randomize": "Randomize on Launch",
"randomizeDescription": "Generate a new fingerprint each time the browser is launched."
},
"os": {
"title": "Operating System",
"description": "The operating system to emulate for fingerprint generation.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"android": "Android",
"ios": "iOS"
},
"screen": {
"title": "Screen Size",
"minWidth": "Min Width",
"maxWidth": "Max Width",
"minHeight": "Min Height",
"maxHeight": "Max Height"
},
"blocking": {
"title": "Blocking",
"webrtc": "Block WebRTC",
"webgl": "Block WebGL"
}
}
},
"cookies": {
"title": "Cookies",
"copy": {
"title": "Copy Cookies",
"description": "Select cookies to copy to other profiles.",
"selectSource": "Select Source Profile",
"selectTarget": "Select Target Profiles",
"selectCookies": "Select Cookies",
"allDomains": "All Domains",
"selectedCount": "{{count}} cookie selected",
"selectedCount_plural": "{{count}} cookies selected"
},
"success": "Cookies copied successfully",
"error": "Failed to copy cookies"
},
"toasts": {
"success": {
"profileCreated": "Profile created successfully",
"profileDeleted": "Profile deleted successfully",
"profileUpdated": "Profile updated successfully",
"profileLaunched": "Profile launched successfully",
"proxyCreated": "Proxy created successfully",
"proxyDeleted": "Proxy deleted successfully",
"proxyUpdated": "Proxy updated successfully",
"groupCreated": "Group created successfully",
"groupDeleted": "Group deleted successfully",
"groupUpdated": "Group updated successfully",
"settingsSaved": "Settings saved successfully",
"copied": "Copied to clipboard",
"permissionRequested": "{{permission}} access requested",
"downloadComplete": "{{browser}} {{version}} downloaded successfully!",
"importSuccess": "Successfully imported {{count}} items",
"exportSuccess": "Successfully exported {{count}} items",
"syncSuccess": "Sync completed successfully",
"cacheCleared": "Cache cleared successfully"
},
"error": {
"profileCreateFailed": "Failed to create profile",
"profileDeleteFailed": "Failed to delete profile",
"profileUpdateFailed": "Failed to update profile",
"profileLaunchFailed": "Failed to launch profile",
"proxyCreateFailed": "Failed to create proxy",
"proxyDeleteFailed": "Failed to delete proxy",
"proxyUpdateFailed": "Failed to update proxy",
"groupCreateFailed": "Failed to create group",
"groupDeleteFailed": "Failed to delete group",
"groupUpdateFailed": "Failed to update group",
"settingsSaveFailed": "Failed to save settings",
"copyFailed": "Failed to copy to clipboard",
"downloadFailed": "Failed to download {{browser}}",
"importFailed": "Failed to import",
"exportFailed": "Failed to export",
"syncFailed": "Sync failed",
"cacheClearFailed": "Failed to clear cache",
"unknown": "An unknown error occurred"
},
"loading": {
"downloading": "Downloading {{browser}} {{version}}",
"extracting": "Extracting {{browser}} {{version}}",
"verifying": "Verifying {{browser}} {{version}}",
"syncing": "Syncing...",
"updatingVersions": "Updating browser versions..."
}
},
"errors": {
"required": "This field is required",
"invalidUrl": "Please enter a valid URL",
"invalidPort": "Please enter a valid port number (1-65535)",
"invalidEmail": "Please enter a valid email address",
"minLength": "Must be at least {{min}} characters",
"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."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
}
}
+460
View File
@@ -0,0 +1,460 @@
{
"common": {
"buttons": {
"save": "Guardar",
"cancel": "Cancelar",
"close": "Cerrar",
"delete": "Eliminar",
"create": "Crear",
"back": "Atrás",
"retry": "Reintentar",
"download": "Descargar",
"confirm": "Confirmar",
"apply": "Aplicar",
"reset": "Restablecer",
"add": "Agregar",
"edit": "Editar",
"copy": "Copiar",
"clear": "Limpiar",
"search": "Buscar",
"select": "Seleccionar",
"grant": "Otorgar",
"start": "Iniciar",
"stop": "Detener",
"enable": "Habilitar",
"disable": "Deshabilitar",
"import": "Importar",
"export": "Exportar",
"refresh": "Actualizar",
"loading": "Cargando...",
"saveSettings": "Guardar Configuración"
},
"status": {
"active": "Activo",
"inactive": "Inactivo",
"running": "Ejecutando",
"stopped": "Detenido",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"granted": "Otorgado",
"notGranted": "No Otorgado",
"connected": "Conectado",
"disconnected": "Desconectado",
"synced": "Sincronizado",
"syncing": "Sincronizando",
"pending": "Pendiente",
"error": "Error"
},
"labels": {
"name": "Nombre",
"type": "Tipo",
"status": "Estado",
"actions": "Acciones",
"description": "Descripción",
"none": "Ninguno",
"default": "Predeterminado",
"custom": "Personalizado",
"optional": "Opcional",
"required": "Requerido"
},
"time": {
"days": "días",
"hours": "horas",
"minutes": "minutos",
"seconds": "segundos",
"remaining": "restantes"
}
},
"settings": {
"title": "Configuración",
"appearance": {
"title": "Apariencia",
"theme": "Tema",
"themeDescription": "Elige tu tema preferido o sigue la configuración del sistema. Los cambios de tema personalizado se aplican solo al guardar.",
"themePreset": "Tema Predefinido",
"customColors": "Colores Personalizados",
"selectTheme": "Seleccionar tema",
"selectThemePreset": "Seleccionar tema predefinido",
"yourOwn": "Tu Propio",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema"
},
"language": {
"title": "Idioma",
"description": "Elige tu idioma preferido para la interfaz de la aplicación.",
"systemDefault": "Predeterminado del Sistema",
"selectLanguage": "Seleccionar idioma"
},
"defaultBrowser": {
"title": "Navegador Predeterminado",
"setAsDefault": "Establecer como Navegador Predeterminado",
"alreadyDefault": "Ya es el Navegador Predeterminado",
"description": "Cuando se establece como predeterminado, Donut Browser manejará los enlaces web y te permitirá elegir qué perfil usar."
},
"permissions": {
"title": "Permisos del Sistema",
"loading": "Cargando permisos...",
"description": "Estos permisos permiten que los navegadores iniciados desde Donut Browser accedan a los recursos del sistema. Cada sitio web seguirá pidiendo tu permiso individualmente.",
"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"
},
"integrations": {
"title": "Integraciones",
"description": "Configura la API Local y MCP (Protocolo de Contexto de Modelo) para integración con herramientas externas y asistentes de IA.",
"openSettings": "Abrir Configuración de Integraciones"
},
"commercial": {
"title": "Licencia Comercial",
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
"trialActiveDescription": "El uso comercial es gratuito durante el período de prueba",
"trialExpired": "Prueba expirada",
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia."
},
"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."
}
},
"header": {
"searchPlaceholder": "Buscar perfiles...",
"clearSearch": "Limpiar búsqueda",
"moreActions": "Más acciones",
"createProfile": "Crear un nuevo perfil",
"menu": {
"settings": "Configuración",
"proxies": "Proxies",
"groups": "Grupos",
"syncService": "Servicio de Sincronización",
"integrations": "Integraciones",
"importProfile": "Importar Perfil"
}
},
"profiles": {
"title": "Perfiles",
"empty": "Sin perfiles aún",
"emptyDescription": "Crea tu primer perfil de navegador para comenzar.",
"createFirst": "Crear Perfil",
"noResults": "No se encontraron perfiles",
"noResultsDescription": "Ningún perfil coincide con tus criterios de búsqueda.",
"table": {
"name": "Nombre",
"browser": "Navegador",
"status": "Estado",
"actions": "Acciones",
"note": "Nota",
"group": "Grupo",
"proxy": "Proxy",
"lastLaunch": "Último Inicio"
},
"actions": {
"launch": "Iniciar",
"stop": "Detener",
"edit": "Editar",
"delete": "Eliminar",
"copyCookies": "Copiar Cookies",
"configure": "Configurar"
}
},
"createProfile": {
"title": "Crear Nuevo Perfil",
"configureTitle": "Configurar Perfil",
"antiDetect": {
"title": "Navegador Anti-Detección",
"description": "Elige un navegador con capacidades anti-detección",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"badge": "Navegador Anti-Detección"
},
"regular": {
"title": "Navegadores Regulares",
"description": "Elige entre navegadores regulares soportados",
"badge": "Navegador Regular"
},
"profileName": "Nombre del Perfil",
"profileNamePlaceholder": "Ingresa el nombre del perfil",
"proxy": {
"title": "Proxy",
"addProxy": "Agregar Proxy",
"noProxy": "Sin proxy",
"noProxiesAvailable": "No hay proxies disponibles. Agrega uno para enrutar el tráfico de este perfil."
},
"version": {
"fetching": "Obteniendo versiones disponibles...",
"fetchError": "Error al obtener versiones del navegador. Por favor verifica tu conexión a internet e intenta de nuevo.",
"needsDownload": "La versión de {{browser}} ({{version}}) necesita ser descargada",
"available": "La versión de {{browser}} ({{version}}) está disponible",
"downloading": "Descargando versión de {{browser}} ({{version}})...",
"latestNeedsDownload": "La última versión ({{version}}) necesita ser descargada",
"latestAvailable": "La última versión ({{version}}) está disponible",
"latestDownloading": "Descargando versión ({{version}})..."
}
},
"deleteDialog": {
"title": "Eliminar Perfil",
"description": "¿Estás seguro de que deseas eliminar este perfil? Esta acción no se puede deshacer.",
"profilesTitle": "Eliminar Perfiles",
"profilesDescription": "¿Estás seguro de que deseas eliminar los perfiles seleccionados? Esta acción no se puede deshacer.",
"profilesToDelete": "Perfiles a eliminar:"
},
"proxies": {
"title": "Proxies",
"management": "Gestión de Proxies",
"add": "Agregar Proxy",
"edit": "Editar Proxy",
"delete": "Eliminar Proxy",
"import": "Importar",
"export": "Exportar",
"noProxies": "No hay proxies configurados",
"noProxiesDescription": "Agrega un proxy para enrutar el tráfico del navegador a través de él.",
"form": {
"name": "Nombre",
"namePlaceholder": "Ingresa el nombre del proxy",
"type": "Tipo",
"host": "Host",
"hostPlaceholder": "proxy.ejemplo.com",
"port": "Puerto",
"portPlaceholder": "8080",
"username": "Usuario",
"usernamePlaceholder": "Opcional",
"password": "Contraseña",
"passwordPlaceholder": "Opcional"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"check": {
"checking": "Verificando proxy...",
"valid": "El proxy es válido",
"invalid": "El proxy es inválido",
"lastChecked": "Última verificación: {{time}}"
},
"sync": {
"enabled": "Sincronización Habilitada",
"disabled": "Sincronización Deshabilitada"
}
},
"groups": {
"title": "Grupos",
"management": "Gestión de Grupos",
"add": "Agregar Grupo",
"edit": "Editar Grupo",
"delete": "Eliminar Grupo",
"noGroups": "No hay grupos creados",
"noGroupsDescription": "Crea un grupo para organizar tus perfiles.",
"form": {
"name": "Nombre",
"namePlaceholder": "Ingresa el nombre del grupo"
},
"profileCount": "{{count}} perfil",
"profileCount_plural": "{{count}} perfiles",
"assignProfiles": "Asignar Perfiles",
"sync": {
"enabled": "Sincronización Habilitada",
"disabled": "Sincronización Deshabilitada"
}
},
"sync": {
"title": "Servicio de Sincronización",
"config": "Configuración de Sincronización",
"serverUrl": "URL del Servidor",
"serverUrlPlaceholder": "https://sync.ejemplo.com",
"token": "Token de Sincronización",
"tokenPlaceholder": "Ingresa tu token de sincronización",
"status": {
"connected": "Conectado",
"disconnected": "Desconectado",
"syncing": "Sincronizando...",
"error": "Error de Sincronización"
},
"description": "Conéctate a un servidor de sincronización para sincronizar tus perfiles, proxies y grupos entre dispositivos."
},
"integrations": {
"title": "Integraciones",
"api": {
"title": "API Local",
"description": "Habilita el servidor de API local para integraciones externas.",
"enabled": "API Habilitada",
"disabled": "API Deshabilitada",
"port": "Puerto",
"token": "Token de API",
"copyToken": "Copiar Token",
"regenerateToken": "Regenerar Token"
},
"mcp": {
"title": "Servidor MCP",
"description": "Habilita el servidor MCP (Protocolo de Contexto de Modelo) para integraciones con asistentes de IA.",
"enabled": "MCP Habilitado",
"disabled": "MCP Deshabilitado",
"port": "Puerto",
"token": "Token MCP",
"config": "Configuración MCP",
"copyConfig": "Copiar Configuración"
}
},
"import": {
"title": "Importar Perfil",
"description": "Importa un perfil de navegador existente de tu sistema.",
"selectProfile": "Selecciona un perfil para importar",
"noProfiles": "No se detectaron perfiles",
"noProfilesDescription": "No se detectaron perfiles de navegador en tu sistema.",
"importing": "Importando perfil...",
"success": "Perfil importado exitosamente",
"error": "Error al importar perfil"
},
"config": {
"camoufox": {
"title": "Configuración de Camoufox",
"fingerprint": {
"title": "Huella Digital",
"randomize": "Aleatorizar al Iniciar",
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador."
},
"os": {
"title": "Sistema Operativo",
"description": "El sistema operativo a emular para la generación de huellas digitales.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux"
},
"screen": {
"title": "Tamaño de Pantalla",
"minWidth": "Ancho Mín",
"maxWidth": "Ancho Máx",
"minHeight": "Alto Mín",
"maxHeight": "Alto Máx"
},
"geoip": {
"title": "GeoIP",
"auto": "Automático (basado en proxy)",
"manual": "Manual",
"disabled": "Deshabilitado"
},
"blocking": {
"title": "Bloqueo",
"images": "Bloquear Imágenes",
"webrtc": "Bloquear WebRTC",
"webgl": "Bloquear WebGL"
}
},
"wayfern": {
"title": "Configuración de Wayfern",
"fingerprint": {
"title": "Huella Digital",
"randomize": "Aleatorizar al Iniciar",
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador."
},
"os": {
"title": "Sistema Operativo",
"description": "El sistema operativo a emular para la generación de huellas digitales.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"android": "Android",
"ios": "iOS"
},
"screen": {
"title": "Tamaño de Pantalla",
"minWidth": "Ancho Mín",
"maxWidth": "Ancho Máx",
"minHeight": "Alto Mín",
"maxHeight": "Alto Máx"
},
"blocking": {
"title": "Bloqueo",
"webrtc": "Bloquear WebRTC",
"webgl": "Bloquear WebGL"
}
}
},
"cookies": {
"title": "Cookies",
"copy": {
"title": "Copiar Cookies",
"description": "Selecciona cookies para copiar a otros perfiles.",
"selectSource": "Seleccionar Perfil de Origen",
"selectTarget": "Seleccionar Perfiles de Destino",
"selectCookies": "Seleccionar Cookies",
"allDomains": "Todos los Dominios",
"selectedCount": "{{count}} cookie seleccionada",
"selectedCount_plural": "{{count}} cookies seleccionadas"
},
"success": "Cookies copiadas exitosamente",
"error": "Error al copiar cookies"
},
"toasts": {
"success": {
"profileCreated": "Perfil creado exitosamente",
"profileDeleted": "Perfil eliminado exitosamente",
"profileUpdated": "Perfil actualizado exitosamente",
"profileLaunched": "Perfil iniciado exitosamente",
"proxyCreated": "Proxy creado exitosamente",
"proxyDeleted": "Proxy eliminado exitosamente",
"proxyUpdated": "Proxy actualizado exitosamente",
"groupCreated": "Grupo creado exitosamente",
"groupDeleted": "Grupo eliminado exitosamente",
"groupUpdated": "Grupo actualizado exitosamente",
"settingsSaved": "Configuración guardada exitosamente",
"copied": "Copiado al portapapeles",
"permissionRequested": "Acceso a {{permission}} solicitado",
"downloadComplete": "¡{{browser}} {{version}} descargado exitosamente!",
"importSuccess": "{{count}} elementos importados exitosamente",
"exportSuccess": "{{count}} elementos exportados exitosamente",
"syncSuccess": "Sincronización completada exitosamente",
"cacheCleared": "Caché limpiada exitosamente"
},
"error": {
"profileCreateFailed": "Error al crear perfil",
"profileDeleteFailed": "Error al eliminar perfil",
"profileUpdateFailed": "Error al actualizar perfil",
"profileLaunchFailed": "Error al iniciar perfil",
"proxyCreateFailed": "Error al crear proxy",
"proxyDeleteFailed": "Error al eliminar proxy",
"proxyUpdateFailed": "Error al actualizar proxy",
"groupCreateFailed": "Error al crear grupo",
"groupDeleteFailed": "Error al eliminar grupo",
"groupUpdateFailed": "Error al actualizar grupo",
"settingsSaveFailed": "Error al guardar configuración",
"copyFailed": "Error al copiar al portapapeles",
"downloadFailed": "Error al descargar {{browser}}",
"importFailed": "Error al importar",
"exportFailed": "Error al exportar",
"syncFailed": "Error de sincronización",
"cacheClearFailed": "Error al limpiar caché",
"unknown": "Ocurrió un error desconocido"
},
"loading": {
"downloading": "Descargando {{browser}} {{version}}",
"extracting": "Extrayendo {{browser}} {{version}}",
"verifying": "Verificando {{browser}} {{version}}",
"syncing": "Sincronizando...",
"updatingVersions": "Actualizando versiones de navegadores..."
}
},
"errors": {
"required": "Este campo es requerido",
"invalidUrl": "Por favor ingresa una URL válida",
"invalidPort": "Por favor ingresa un número de puerto válido (1-65535)",
"invalidEmail": "Por favor ingresa un correo electrónico válido",
"minLength": "Debe tener al menos {{min}} caracteres",
"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."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
}
}
+460
View File
@@ -0,0 +1,460 @@
{
"common": {
"buttons": {
"save": "Enregistrer",
"cancel": "Annuler",
"close": "Fermer",
"delete": "Supprimer",
"create": "Créer",
"back": "Retour",
"retry": "Réessayer",
"download": "Télécharger",
"confirm": "Confirmer",
"apply": "Appliquer",
"reset": "Réinitialiser",
"add": "Ajouter",
"edit": "Modifier",
"copy": "Copier",
"clear": "Effacer",
"search": "Rechercher",
"select": "Sélectionner",
"grant": "Accorder",
"start": "Démarrer",
"stop": "Arrêter",
"enable": "Activer",
"disable": "Désactiver",
"import": "Importer",
"export": "Exporter",
"refresh": "Actualiser",
"loading": "Chargement...",
"saveSettings": "Enregistrer les paramètres"
},
"status": {
"active": "Actif",
"inactive": "Inactif",
"running": "En cours",
"stopped": "Arrêté",
"enabled": "Activé",
"disabled": "Désactivé",
"granted": "Accordé",
"notGranted": "Non accordé",
"connected": "Connecté",
"disconnected": "Déconnecté",
"synced": "Synchronisé",
"syncing": "Synchronisation",
"pending": "En attente",
"error": "Erreur"
},
"labels": {
"name": "Nom",
"type": "Type",
"status": "Statut",
"actions": "Actions",
"description": "Description",
"none": "Aucun",
"default": "Par défaut",
"custom": "Personnalisé",
"optional": "Optionnel",
"required": "Requis"
},
"time": {
"days": "jours",
"hours": "heures",
"minutes": "minutes",
"seconds": "secondes",
"remaining": "restants"
}
},
"settings": {
"title": "Paramètres",
"appearance": {
"title": "Apparence",
"theme": "Thème",
"themeDescription": "Choisissez votre thème préféré ou suivez les paramètres du système. Les modifications de thème personnalisé ne sont appliquées qu'à l'enregistrement.",
"themePreset": "Thème prédéfini",
"customColors": "Couleurs personnalisées",
"selectTheme": "Sélectionner un thème",
"selectThemePreset": "Sélectionner un thème prédéfini",
"yourOwn": "Le vôtre",
"light": "Clair",
"dark": "Sombre",
"system": "Système"
},
"language": {
"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"
},
"defaultBrowser": {
"title": "Navigateur par défaut",
"setAsDefault": "Définir comme navigateur par défaut",
"alreadyDefault": "Déjà le navigateur par défaut",
"description": "Lorsqu'il est défini par défaut, Donut Browser gérera les liens web et vous permettra de choisir quel profil utiliser."
},
"permissions": {
"title": "Permissions système",
"loading": "Chargement des permissions...",
"description": "Ces permissions permettent aux navigateurs lancés depuis Donut Browser d'accéder aux ressources système. Chaque site web demandera toujours votre permission individuellement.",
"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"
},
"integrations": {
"title": "Intégrations",
"description": "Configurez l'API locale et MCP (Model Context Protocol) pour l'intégration avec des outils externes et des assistants IA.",
"openSettings": "Ouvrir les paramètres d'intégration"
},
"commercial": {
"title": "Licence commerciale",
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
"trialActiveDescription": "L'utilisation commerciale est gratuite pendant la période d'essai",
"trialExpired": "Essai expiré",
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence."
},
"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."
}
},
"header": {
"searchPlaceholder": "Rechercher des profils...",
"clearSearch": "Effacer la recherche",
"moreActions": "Plus d'actions",
"createProfile": "Créer un nouveau profil",
"menu": {
"settings": "Paramètres",
"proxies": "Proxies",
"groups": "Groupes",
"syncService": "Service de synchronisation",
"integrations": "Intégrations",
"importProfile": "Importer un profil"
}
},
"profiles": {
"title": "Profils",
"empty": "Aucun profil pour l'instant",
"emptyDescription": "Créez votre premier profil de navigateur pour commencer.",
"createFirst": "Créer un profil",
"noResults": "Aucun profil trouvé",
"noResultsDescription": "Aucun profil ne correspond à vos critères de recherche.",
"table": {
"name": "Nom",
"browser": "Navigateur",
"status": "Statut",
"actions": "Actions",
"note": "Note",
"group": "Groupe",
"proxy": "Proxy",
"lastLaunch": "Dernier lancement"
},
"actions": {
"launch": "Lancer",
"stop": "Arrêter",
"edit": "Modifier",
"delete": "Supprimer",
"copyCookies": "Copier les cookies",
"configure": "Configurer"
}
},
"createProfile": {
"title": "Créer un nouveau profil",
"configureTitle": "Configurer le profil",
"antiDetect": {
"title": "Navigateur anti-détection",
"description": "Choisissez un navigateur avec des capacités anti-détection",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"badge": "Navigateur anti-détection"
},
"regular": {
"title": "Navigateurs réguliers",
"description": "Choisissez parmi les navigateurs réguliers pris en charge",
"badge": "Navigateur régulier"
},
"profileName": "Nom du profil",
"profileNamePlaceholder": "Entrez le nom du profil",
"proxy": {
"title": "Proxy",
"addProxy": "Ajouter un proxy",
"noProxy": "Pas de proxy",
"noProxiesAvailable": "Aucun proxy disponible. Ajoutez-en un pour acheminer le trafic de ce profil."
},
"version": {
"fetching": "Récupération des versions disponibles...",
"fetchError": "Échec de la récupération des versions du navigateur. Veuillez vérifier votre connexion Internet et réessayer.",
"needsDownload": "La version de {{browser}} ({{version}}) doit être téléchargée",
"available": "La version de {{browser}} ({{version}}) est disponible",
"downloading": "Téléchargement de la version de {{browser}} ({{version}})...",
"latestNeedsDownload": "La dernière version ({{version}}) doit être téléchargée",
"latestAvailable": "La dernière version ({{version}}) est disponible",
"latestDownloading": "Téléchargement de la version ({{version}})..."
}
},
"deleteDialog": {
"title": "Supprimer le profil",
"description": "Êtes-vous sûr de vouloir supprimer ce profil ? Cette action ne peut pas être annulée.",
"profilesTitle": "Supprimer les profils",
"profilesDescription": "Êtes-vous sûr de vouloir supprimer les profils sélectionnés ? Cette action ne peut pas être annulée.",
"profilesToDelete": "Profils à supprimer :"
},
"proxies": {
"title": "Proxies",
"management": "Gestion des proxies",
"add": "Ajouter un proxy",
"edit": "Modifier le proxy",
"delete": "Supprimer le proxy",
"import": "Importer",
"export": "Exporter",
"noProxies": "Aucun proxy configuré",
"noProxiesDescription": "Ajoutez un proxy pour acheminer le trafic du navigateur à travers lui.",
"form": {
"name": "Nom",
"namePlaceholder": "Entrez le nom du proxy",
"type": "Type",
"host": "Hôte",
"hostPlaceholder": "proxy.exemple.com",
"port": "Port",
"portPlaceholder": "8080",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "Optionnel",
"password": "Mot de passe",
"passwordPlaceholder": "Optionnel"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"check": {
"checking": "Vérification du proxy...",
"valid": "Le proxy est valide",
"invalid": "Le proxy est invalide",
"lastChecked": "Dernière vérification : {{time}}"
},
"sync": {
"enabled": "Synchronisation activée",
"disabled": "Synchronisation désactivée"
}
},
"groups": {
"title": "Groupes",
"management": "Gestion des groupes",
"add": "Ajouter un groupe",
"edit": "Modifier le groupe",
"delete": "Supprimer le groupe",
"noGroups": "Aucun groupe créé",
"noGroupsDescription": "Créez un groupe pour organiser vos profils.",
"form": {
"name": "Nom",
"namePlaceholder": "Entrez le nom du groupe"
},
"profileCount": "{{count}} profil",
"profileCount_plural": "{{count}} profils",
"assignProfiles": "Attribuer des profils",
"sync": {
"enabled": "Synchronisation activée",
"disabled": "Synchronisation désactivée"
}
},
"sync": {
"title": "Service de synchronisation",
"config": "Configuration de la synchronisation",
"serverUrl": "URL du serveur",
"serverUrlPlaceholder": "https://sync.exemple.com",
"token": "Jeton de synchronisation",
"tokenPlaceholder": "Entrez votre jeton de synchronisation",
"status": {
"connected": "Connecté",
"disconnected": "Déconnecté",
"syncing": "Synchronisation...",
"error": "Erreur de synchronisation"
},
"description": "Connectez-vous à un serveur de synchronisation pour synchroniser vos profils, proxies et groupes entre appareils."
},
"integrations": {
"title": "Intégrations",
"api": {
"title": "API locale",
"description": "Activez le serveur d'API locale pour les intégrations externes.",
"enabled": "API activée",
"disabled": "API désactivée",
"port": "Port",
"token": "Jeton API",
"copyToken": "Copier le jeton",
"regenerateToken": "Régénérer le jeton"
},
"mcp": {
"title": "Serveur MCP",
"description": "Activez le serveur MCP (Model Context Protocol) pour les intégrations avec les assistants IA.",
"enabled": "MCP activé",
"disabled": "MCP désactivé",
"port": "Port",
"token": "Jeton MCP",
"config": "Configuration MCP",
"copyConfig": "Copier la configuration"
}
},
"import": {
"title": "Importer un profil",
"description": "Importez un profil de navigateur existant depuis votre système.",
"selectProfile": "Sélectionnez un profil à importer",
"noProfiles": "Aucun profil détecté",
"noProfilesDescription": "Aucun profil de navigateur n'a été détecté sur votre système.",
"importing": "Importation du profil...",
"success": "Profil importé avec succès",
"error": "Échec de l'importation du profil"
},
"config": {
"camoufox": {
"title": "Configuration Camoufox",
"fingerprint": {
"title": "Empreinte digitale",
"randomize": "Randomiser au lancement",
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur."
},
"os": {
"title": "Système d'exploitation",
"description": "Le système d'exploitation à émuler pour la génération d'empreinte digitale.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux"
},
"screen": {
"title": "Taille d'écran",
"minWidth": "Largeur min",
"maxWidth": "Largeur max",
"minHeight": "Hauteur min",
"maxHeight": "Hauteur max"
},
"geoip": {
"title": "GeoIP",
"auto": "Automatique (basé sur le proxy)",
"manual": "Manuel",
"disabled": "Désactivé"
},
"blocking": {
"title": "Blocage",
"images": "Bloquer les images",
"webrtc": "Bloquer WebRTC",
"webgl": "Bloquer WebGL"
}
},
"wayfern": {
"title": "Configuration Wayfern",
"fingerprint": {
"title": "Empreinte digitale",
"randomize": "Randomiser au lancement",
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur."
},
"os": {
"title": "Système d'exploitation",
"description": "Le système d'exploitation à émuler pour la génération d'empreinte digitale.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"android": "Android",
"ios": "iOS"
},
"screen": {
"title": "Taille d'écran",
"minWidth": "Largeur min",
"maxWidth": "Largeur max",
"minHeight": "Hauteur min",
"maxHeight": "Hauteur max"
},
"blocking": {
"title": "Blocage",
"webrtc": "Bloquer WebRTC",
"webgl": "Bloquer WebGL"
}
}
},
"cookies": {
"title": "Cookies",
"copy": {
"title": "Copier les cookies",
"description": "Sélectionnez les cookies à copier vers d'autres profils.",
"selectSource": "Sélectionner le profil source",
"selectTarget": "Sélectionner les profils cibles",
"selectCookies": "Sélectionner les cookies",
"allDomains": "Tous les domaines",
"selectedCount": "{{count}} cookie sélectionné",
"selectedCount_plural": "{{count}} cookies sélectionnés"
},
"success": "Cookies copiés avec succès",
"error": "Échec de la copie des cookies"
},
"toasts": {
"success": {
"profileCreated": "Profil créé avec succès",
"profileDeleted": "Profil supprimé avec succès",
"profileUpdated": "Profil mis à jour avec succès",
"profileLaunched": "Profil lancé avec succès",
"proxyCreated": "Proxy créé avec succès",
"proxyDeleted": "Proxy supprimé avec succès",
"proxyUpdated": "Proxy mis à jour avec succès",
"groupCreated": "Groupe créé avec succès",
"groupDeleted": "Groupe supprimé avec succès",
"groupUpdated": "Groupe mis à jour avec succès",
"settingsSaved": "Paramètres enregistrés avec succès",
"copied": "Copié dans le presse-papiers",
"permissionRequested": "Accès au {{permission}} demandé",
"downloadComplete": "{{browser}} {{version}} téléchargé avec succès !",
"importSuccess": "{{count}} éléments importés avec succès",
"exportSuccess": "{{count}} éléments exportés avec succès",
"syncSuccess": "Synchronisation terminée avec succès",
"cacheCleared": "Cache effacé avec succès"
},
"error": {
"profileCreateFailed": "Échec de la création du profil",
"profileDeleteFailed": "Échec de la suppression du profil",
"profileUpdateFailed": "Échec de la mise à jour du profil",
"profileLaunchFailed": "Échec du lancement du profil",
"proxyCreateFailed": "Échec de la création du proxy",
"proxyDeleteFailed": "Échec de la suppression du proxy",
"proxyUpdateFailed": "Échec de la mise à jour du proxy",
"groupCreateFailed": "Échec de la création du groupe",
"groupDeleteFailed": "Échec de la suppression du groupe",
"groupUpdateFailed": "Échec de la mise à jour du groupe",
"settingsSaveFailed": "Échec de l'enregistrement des paramètres",
"copyFailed": "Échec de la copie dans le presse-papiers",
"downloadFailed": "Échec du téléchargement de {{browser}}",
"importFailed": "Échec de l'importation",
"exportFailed": "Échec de l'exportation",
"syncFailed": "Échec de la synchronisation",
"cacheClearFailed": "Échec de l'effacement du cache",
"unknown": "Une erreur inconnue s'est produite"
},
"loading": {
"downloading": "Téléchargement de {{browser}} {{version}}",
"extracting": "Extraction de {{browser}} {{version}}",
"verifying": "Vérification de {{browser}} {{version}}",
"syncing": "Synchronisation...",
"updatingVersions": "Mise à jour des versions de navigateurs..."
}
},
"errors": {
"required": "Ce champ est requis",
"invalidUrl": "Veuillez entrer une URL valide",
"invalidPort": "Veuillez entrer un numéro de port valide (1-65535)",
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
"minLength": "Doit contenir au moins {{min}} caractères",
"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."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
}
}
+460
View File
@@ -0,0 +1,460 @@
{
"common": {
"buttons": {
"save": "保存",
"cancel": "キャンセル",
"close": "閉じる",
"delete": "削除",
"create": "作成",
"back": "戻る",
"retry": "再試行",
"download": "ダウンロード",
"confirm": "確認",
"apply": "適用",
"reset": "リセット",
"add": "追加",
"edit": "編集",
"copy": "コピー",
"clear": "クリア",
"search": "検索",
"select": "選択",
"grant": "許可",
"start": "開始",
"stop": "停止",
"enable": "有効",
"disable": "無効",
"import": "インポート",
"export": "エクスポート",
"refresh": "更新",
"loading": "読み込み中...",
"saveSettings": "設定を保存"
},
"status": {
"active": "アクティブ",
"inactive": "非アクティブ",
"running": "実行中",
"stopped": "停止",
"enabled": "有効",
"disabled": "無効",
"granted": "許可済み",
"notGranted": "未許可",
"connected": "接続済み",
"disconnected": "切断",
"synced": "同期済み",
"syncing": "同期中",
"pending": "保留中",
"error": "エラー"
},
"labels": {
"name": "名前",
"type": "タイプ",
"status": "ステータス",
"actions": "アクション",
"description": "説明",
"none": "なし",
"default": "デフォルト",
"custom": "カスタム",
"optional": "任意",
"required": "必須"
},
"time": {
"days": "日",
"hours": "時間",
"minutes": "分",
"seconds": "秒",
"remaining": "残り"
}
},
"settings": {
"title": "設定",
"appearance": {
"title": "外観",
"theme": "テーマ",
"themeDescription": "お好みのテーマを選択するか、システム設定に従います。カスタムテーマの変更は保存時のみ適用されます。",
"themePreset": "テーマプリセット",
"customColors": "カスタムカラー",
"selectTheme": "テーマを選択",
"selectThemePreset": "テーマプリセットを選択",
"yourOwn": "カスタム",
"light": "ライト",
"dark": "ダーク",
"system": "システム"
},
"language": {
"title": "言語",
"description": "アプリケーションインターフェースの言語を選択します。",
"systemDefault": "システムデフォルト",
"selectLanguage": "言語を選択"
},
"defaultBrowser": {
"title": "デフォルトブラウザ",
"setAsDefault": "デフォルトブラウザに設定",
"alreadyDefault": "既にデフォルトブラウザです",
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。"
},
"permissions": {
"title": "システム権限",
"loading": "権限を読み込み中...",
"description": "これらの権限により、Donut Browser から起動したブラウザがシステムリソースにアクセスできます。各ウェブサイトは個別に許可を求めます。",
"microphone": "マイク",
"microphoneDescription": "ブラウザアプリケーションのマイクアクセス",
"camera": "カメラ",
"cameraDescription": "ブラウザアプリケーションのカメラアクセス"
},
"integrations": {
"title": "統合",
"description": "外部ツールやAIアシスタントと統合するためのローカルAPIとMCP(モデルコンテキストプロトコル)を設定します。",
"openSettings": "統合設定を開く"
},
"commercial": {
"title": "商用ライセンス",
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
"trialActiveDescription": "トライアル期間中は商用利用が無料です",
"trialExpired": "トライアル期限切れ",
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。"
},
"advanced": {
"title": "詳細設定",
"clearCache": "すべてのバージョンキャッシュをクリア",
"clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。"
}
},
"header": {
"searchPlaceholder": "プロファイルを検索...",
"clearSearch": "検索をクリア",
"moreActions": "その他のアクション",
"createProfile": "新しいプロファイルを作成",
"menu": {
"settings": "設定",
"proxies": "プロキシ",
"groups": "グループ",
"syncService": "同期サービス",
"integrations": "統合",
"importProfile": "プロファイルをインポート"
}
},
"profiles": {
"title": "プロファイル",
"empty": "プロファイルがありません",
"emptyDescription": "最初のブラウザプロファイルを作成して始めましょう。",
"createFirst": "プロファイルを作成",
"noResults": "プロファイルが見つかりません",
"noResultsDescription": "検索条件に一致するプロファイルがありません。",
"table": {
"name": "名前",
"browser": "ブラウザ",
"status": "ステータス",
"actions": "アクション",
"note": "メモ",
"group": "グループ",
"proxy": "プロキシ",
"lastLaunch": "最終起動"
},
"actions": {
"launch": "起動",
"stop": "停止",
"edit": "編集",
"delete": "削除",
"copyCookies": "Cookieをコピー",
"configure": "設定"
}
},
"createProfile": {
"title": "新しいプロファイルを作成",
"configureTitle": "プロファイルを設定",
"antiDetect": {
"title": "アンチ検出ブラウザ",
"description": "アンチ検出機能を持つブラウザを選択",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"badge": "アンチ検出ブラウザ"
},
"regular": {
"title": "通常ブラウザ",
"description": "サポートされている通常ブラウザから選択",
"badge": "通常ブラウザ"
},
"profileName": "プロファイル名",
"profileNamePlaceholder": "プロファイル名を入力",
"proxy": {
"title": "プロキシ",
"addProxy": "プロキシを追加",
"noProxy": "プロキシなし",
"noProxiesAvailable": "利用可能なプロキシがありません。このプロファイルのトラフィックをルーティングするためにプロキシを追加してください。"
},
"version": {
"fetching": "利用可能なバージョンを取得中...",
"fetchError": "ブラウザバージョンの取得に失敗しました。インターネット接続を確認して再試行してください。",
"needsDownload": "{{browser}} バージョン ({{version}}) をダウンロードする必要があります",
"available": "{{browser}} バージョン ({{version}}) は利用可能です",
"downloading": "{{browser}} バージョン ({{version}}) をダウンロード中...",
"latestNeedsDownload": "最新バージョン ({{version}}) をダウンロードする必要があります",
"latestAvailable": "最新バージョン ({{version}}) は利用可能です",
"latestDownloading": "バージョン ({{version}}) をダウンロード中..."
}
},
"deleteDialog": {
"title": "プロファイルを削除",
"description": "このプロファイルを削除してもよろしいですか?この操作は取り消せません。",
"profilesTitle": "プロファイルを削除",
"profilesDescription": "選択したプロファイルを削除してもよろしいですか?この操作は取り消せません。",
"profilesToDelete": "削除されるプロファイル:"
},
"proxies": {
"title": "プロキシ",
"management": "プロキシ管理",
"add": "プロキシを追加",
"edit": "プロキシを編集",
"delete": "プロキシを削除",
"import": "インポート",
"export": "エクスポート",
"noProxies": "プロキシが設定されていません",
"noProxiesDescription": "ブラウザトラフィックをルーティングするためのプロキシを追加してください。",
"form": {
"name": "名前",
"namePlaceholder": "プロキシ名を入力",
"type": "タイプ",
"host": "ホスト",
"hostPlaceholder": "proxy.example.com",
"port": "ポート",
"portPlaceholder": "8080",
"username": "ユーザー名",
"usernamePlaceholder": "任意",
"password": "パスワード",
"passwordPlaceholder": "任意"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"check": {
"checking": "プロキシを確認中...",
"valid": "プロキシは有効です",
"invalid": "プロキシは無効です",
"lastChecked": "最終確認: {{time}}"
},
"sync": {
"enabled": "同期有効",
"disabled": "同期無効"
}
},
"groups": {
"title": "グループ",
"management": "グループ管理",
"add": "グループを追加",
"edit": "グループを編集",
"delete": "グループを削除",
"noGroups": "グループがありません",
"noGroupsDescription": "プロファイルを整理するためのグループを作成してください。",
"form": {
"name": "名前",
"namePlaceholder": "グループ名を入力"
},
"profileCount": "{{count}} プロファイル",
"profileCount_plural": "{{count}} プロファイル",
"assignProfiles": "プロファイルを割り当て",
"sync": {
"enabled": "同期有効",
"disabled": "同期無効"
}
},
"sync": {
"title": "同期サービス",
"config": "同期設定",
"serverUrl": "サーバーURL",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "同期トークン",
"tokenPlaceholder": "同期トークンを入力",
"status": {
"connected": "接続済み",
"disconnected": "切断",
"syncing": "同期中...",
"error": "同期エラー"
},
"description": "同期サーバーに接続して、デバイス間でプロファイル、プロキシ、グループを同期します。"
},
"integrations": {
"title": "統合",
"api": {
"title": "ローカルAPI",
"description": "外部統合用のローカルAPIサーバーを有効にします。",
"enabled": "API有効",
"disabled": "API無効",
"port": "ポート",
"token": "APIトークン",
"copyToken": "トークンをコピー",
"regenerateToken": "トークンを再生成"
},
"mcp": {
"title": "MCPサーバー",
"description": "AIアシスタント統合用のMCP(モデルコンテキストプロトコル)サーバーを有効にします。",
"enabled": "MCP有効",
"disabled": "MCP無効",
"port": "ポート",
"token": "MCPトークン",
"config": "MCP設定",
"copyConfig": "設定をコピー"
}
},
"import": {
"title": "プロファイルをインポート",
"description": "システムから既存のブラウザプロファイルをインポートします。",
"selectProfile": "インポートするプロファイルを選択",
"noProfiles": "プロファイルが検出されませんでした",
"noProfilesDescription": "システム上にブラウザプロファイルが検出されませんでした。",
"importing": "プロファイルをインポート中...",
"success": "プロファイルが正常にインポートされました",
"error": "プロファイルのインポートに失敗しました"
},
"config": {
"camoufox": {
"title": "Camoufox設定",
"fingerprint": {
"title": "フィンガープリント",
"randomize": "起動時にランダム化",
"randomizeDescription": "ブラウザ起動時に新しいフィンガープリントを生成します。"
},
"os": {
"title": "オペレーティングシステム",
"description": "フィンガープリント生成用にエミュレートするOS。",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux"
},
"screen": {
"title": "画面サイズ",
"minWidth": "最小幅",
"maxWidth": "最大幅",
"minHeight": "最小高さ",
"maxHeight": "最大高さ"
},
"geoip": {
"title": "GeoIP",
"auto": "自動(プロキシに基づく)",
"manual": "手動",
"disabled": "無効"
},
"blocking": {
"title": "ブロック",
"images": "画像をブロック",
"webrtc": "WebRTCをブロック",
"webgl": "WebGLをブロック"
}
},
"wayfern": {
"title": "Wayfern設定",
"fingerprint": {
"title": "フィンガープリント",
"randomize": "起動時にランダム化",
"randomizeDescription": "ブラウザ起動時に新しいフィンガープリントを生成します。"
},
"os": {
"title": "オペレーティングシステム",
"description": "フィンガープリント生成用にエミュレートするOS。",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"android": "Android",
"ios": "iOS"
},
"screen": {
"title": "画面サイズ",
"minWidth": "最小幅",
"maxWidth": "最大幅",
"minHeight": "最小高さ",
"maxHeight": "最大高さ"
},
"blocking": {
"title": "ブロック",
"webrtc": "WebRTCをブロック",
"webgl": "WebGLをブロック"
}
}
},
"cookies": {
"title": "Cookie",
"copy": {
"title": "Cookieをコピー",
"description": "他のプロファイルにコピーするCookieを選択します。",
"selectSource": "ソースプロファイルを選択",
"selectTarget": "ターゲットプロファイルを選択",
"selectCookies": "Cookieを選択",
"allDomains": "すべてのドメイン",
"selectedCount": "{{count}} 個のCookieを選択",
"selectedCount_plural": "{{count}} 個のCookieを選択"
},
"success": "Cookieが正常にコピーされました",
"error": "Cookieのコピーに失敗しました"
},
"toasts": {
"success": {
"profileCreated": "プロファイルが正常に作成されました",
"profileDeleted": "プロファイルが正常に削除されました",
"profileUpdated": "プロファイルが正常に更新されました",
"profileLaunched": "プロファイルが正常に起動しました",
"proxyCreated": "プロキシが正常に作成されました",
"proxyDeleted": "プロキシが正常に削除されました",
"proxyUpdated": "プロキシが正常に更新されました",
"groupCreated": "グループが正常に作成されました",
"groupDeleted": "グループが正常に削除されました",
"groupUpdated": "グループが正常に更新されました",
"settingsSaved": "設定が正常に保存されました",
"copied": "クリップボードにコピーしました",
"permissionRequested": "{{permission}}へのアクセスをリクエストしました",
"downloadComplete": "{{browser}} {{version}} が正常にダウンロードされました!",
"importSuccess": "{{count}} 個のアイテムが正常にインポートされました",
"exportSuccess": "{{count}} 個のアイテムが正常にエクスポートされました",
"syncSuccess": "同期が正常に完了しました",
"cacheCleared": "キャッシュが正常にクリアされました"
},
"error": {
"profileCreateFailed": "プロファイルの作成に失敗しました",
"profileDeleteFailed": "プロファイルの削除に失敗しました",
"profileUpdateFailed": "プロファイルの更新に失敗しました",
"profileLaunchFailed": "プロファイルの起動に失敗しました",
"proxyCreateFailed": "プロキシの作成に失敗しました",
"proxyDeleteFailed": "プロキシの削除に失敗しました",
"proxyUpdateFailed": "プロキシの更新に失敗しました",
"groupCreateFailed": "グループの作成に失敗しました",
"groupDeleteFailed": "グループの削除に失敗しました",
"groupUpdateFailed": "グループの更新に失敗しました",
"settingsSaveFailed": "設定の保存に失敗しました",
"copyFailed": "クリップボードへのコピーに失敗しました",
"downloadFailed": "{{browser}} のダウンロードに失敗しました",
"importFailed": "インポートに失敗しました",
"exportFailed": "エクスポートに失敗しました",
"syncFailed": "同期に失敗しました",
"cacheClearFailed": "キャッシュのクリアに失敗しました",
"unknown": "不明なエラーが発生しました"
},
"loading": {
"downloading": "{{browser}} {{version}} をダウンロード中",
"extracting": "{{browser}} {{version}} を展開中",
"verifying": "{{browser}} {{version}} を確認中",
"syncing": "同期中...",
"updatingVersions": "ブラウザバージョンを更新中..."
}
},
"errors": {
"required": "この項目は必須です",
"invalidUrl": "有効なURLを入力してください",
"invalidPort": "有効なポート番号を入力してください(1-65535)",
"invalidEmail": "有効なメールアドレスを入力してください",
"minLength": "{{min}} 文字以上で入力してください",
"maxLength": "{{max}} 文字以内で入力してください",
"networkError": "ネットワークエラー。接続を確認してください。",
"serverError": "サーバーエラー。後でもう一度お試しください。",
"unknownError": "不明なエラーが発生しました。もう一度お試しください。"
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
}
}
+460
View File
@@ -0,0 +1,460 @@
{
"common": {
"buttons": {
"save": "Salvar",
"cancel": "Cancelar",
"close": "Fechar",
"delete": "Excluir",
"create": "Criar",
"back": "Voltar",
"retry": "Tentar novamente",
"download": "Baixar",
"confirm": "Confirmar",
"apply": "Aplicar",
"reset": "Redefinir",
"add": "Adicionar",
"edit": "Editar",
"copy": "Copiar",
"clear": "Limpar",
"search": "Pesquisar",
"select": "Selecionar",
"grant": "Conceder",
"start": "Iniciar",
"stop": "Parar",
"enable": "Ativar",
"disable": "Desativar",
"import": "Importar",
"export": "Exportar",
"refresh": "Atualizar",
"loading": "Carregando...",
"saveSettings": "Salvar Configurações"
},
"status": {
"active": "Ativo",
"inactive": "Inativo",
"running": "Executando",
"stopped": "Parado",
"enabled": "Ativado",
"disabled": "Desativado",
"granted": "Concedido",
"notGranted": "Não Concedido",
"connected": "Conectado",
"disconnected": "Desconectado",
"synced": "Sincronizado",
"syncing": "Sincronizando",
"pending": "Pendente",
"error": "Erro"
},
"labels": {
"name": "Nome",
"type": "Tipo",
"status": "Status",
"actions": "Ações",
"description": "Descrição",
"none": "Nenhum",
"default": "Padrão",
"custom": "Personalizado",
"optional": "Opcional",
"required": "Obrigatório"
},
"time": {
"days": "dias",
"hours": "horas",
"minutes": "minutos",
"seconds": "segundos",
"remaining": "restantes"
}
},
"settings": {
"title": "Configurações",
"appearance": {
"title": "Aparência",
"theme": "Tema",
"themeDescription": "Escolha seu tema preferido ou siga as configurações do sistema. Alterações de tema personalizado são aplicadas apenas ao salvar.",
"themePreset": "Tema Predefinido",
"customColors": "Cores Personalizadas",
"selectTheme": "Selecionar tema",
"selectThemePreset": "Selecionar tema predefinido",
"yourOwn": "Seu Próprio",
"light": "Claro",
"dark": "Escuro",
"system": "Sistema"
},
"language": {
"title": "Idioma",
"description": "Escolha seu idioma preferido para a interface do aplicativo.",
"systemDefault": "Padrão do Sistema",
"selectLanguage": "Selecionar idioma"
},
"defaultBrowser": {
"title": "Navegador Padrão",
"setAsDefault": "Definir como Navegador Padrão",
"alreadyDefault": "Já é o Navegador Padrão",
"description": "Quando definido como padrão, o Donut Browser lidará com links da web e permitirá que você escolha qual perfil usar."
},
"permissions": {
"title": "Permissões do Sistema",
"loading": "Carregando permissões...",
"description": "Essas permissões permitem que navegadores iniciados pelo Donut Browser acessem recursos do sistema. Cada site ainda pedirá sua permissão individualmente.",
"microphone": "Microfone",
"microphoneDescription": "Acesso ao microfone para aplicativos do navegador",
"camera": "Câmera",
"cameraDescription": "Acesso à câmera para aplicativos do navegador"
},
"integrations": {
"title": "Integrações",
"description": "Configure a API Local e MCP (Protocolo de Contexto de Modelo) para integração com ferramentas externas e assistentes de IA.",
"openSettings": "Abrir Configurações de Integrações"
},
"commercial": {
"title": "Licença Comercial",
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
"trialActiveDescription": "O uso comercial é gratuito durante o período de teste",
"trialExpired": "Teste expirado",
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença."
},
"advanced": {
"title": "Avançado",
"clearCache": "Limpar Todo o Cache de Versões",
"clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores."
}
},
"header": {
"searchPlaceholder": "Pesquisar perfis...",
"clearSearch": "Limpar pesquisa",
"moreActions": "Mais ações",
"createProfile": "Criar um novo perfil",
"menu": {
"settings": "Configurações",
"proxies": "Proxies",
"groups": "Grupos",
"syncService": "Serviço de Sincronização",
"integrations": "Integrações",
"importProfile": "Importar Perfil"
}
},
"profiles": {
"title": "Perfis",
"empty": "Nenhum perfil ainda",
"emptyDescription": "Crie seu primeiro perfil de navegador para começar.",
"createFirst": "Criar Perfil",
"noResults": "Nenhum perfil encontrado",
"noResultsDescription": "Nenhum perfil corresponde aos seus critérios de pesquisa.",
"table": {
"name": "Nome",
"browser": "Navegador",
"status": "Status",
"actions": "Ações",
"note": "Nota",
"group": "Grupo",
"proxy": "Proxy",
"lastLaunch": "Último Início"
},
"actions": {
"launch": "Iniciar",
"stop": "Parar",
"edit": "Editar",
"delete": "Excluir",
"copyCookies": "Copiar Cookies",
"configure": "Configurar"
}
},
"createProfile": {
"title": "Criar Novo Perfil",
"configureTitle": "Configurar Perfil",
"antiDetect": {
"title": "Navegador Anti-Detecção",
"description": "Escolha um navegador com capacidades anti-detecção",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"badge": "Navegador Anti-Detecção"
},
"regular": {
"title": "Navegadores Regulares",
"description": "Escolha entre navegadores regulares suportados",
"badge": "Navegador Regular"
},
"profileName": "Nome do Perfil",
"profileNamePlaceholder": "Digite o nome do perfil",
"proxy": {
"title": "Proxy",
"addProxy": "Adicionar Proxy",
"noProxy": "Sem proxy",
"noProxiesAvailable": "Nenhum proxy disponível. Adicione um para rotear o tráfego deste perfil."
},
"version": {
"fetching": "Buscando versões disponíveis...",
"fetchError": "Falha ao buscar versões do navegador. Por favor, verifique sua conexão com a internet e tente novamente.",
"needsDownload": "A versão do {{browser}} ({{version}}) precisa ser baixada",
"available": "A versão do {{browser}} ({{version}}) está disponível",
"downloading": "Baixando versão do {{browser}} ({{version}})...",
"latestNeedsDownload": "A versão mais recente ({{version}}) precisa ser baixada",
"latestAvailable": "A versão mais recente ({{version}}) está disponível",
"latestDownloading": "Baixando versão ({{version}})..."
}
},
"deleteDialog": {
"title": "Excluir Perfil",
"description": "Tem certeza de que deseja excluir este perfil? Esta ação não pode ser desfeita.",
"profilesTitle": "Excluir Perfis",
"profilesDescription": "Tem certeza de que deseja excluir os perfis selecionados? Esta ação não pode ser desfeita.",
"profilesToDelete": "Perfis a serem excluídos:"
},
"proxies": {
"title": "Proxies",
"management": "Gerenciamento de Proxies",
"add": "Adicionar Proxy",
"edit": "Editar Proxy",
"delete": "Excluir Proxy",
"import": "Importar",
"export": "Exportar",
"noProxies": "Nenhum proxy configurado",
"noProxiesDescription": "Adicione um proxy para rotear o tráfego do navegador através dele.",
"form": {
"name": "Nome",
"namePlaceholder": "Digite o nome do proxy",
"type": "Tipo",
"host": "Host",
"hostPlaceholder": "proxy.exemplo.com",
"port": "Porta",
"portPlaceholder": "8080",
"username": "Usuário",
"usernamePlaceholder": "Opcional",
"password": "Senha",
"passwordPlaceholder": "Opcional"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"check": {
"checking": "Verificando proxy...",
"valid": "O proxy é válido",
"invalid": "O proxy é inválido",
"lastChecked": "Última verificação: {{time}}"
},
"sync": {
"enabled": "Sincronização Ativada",
"disabled": "Sincronização Desativada"
}
},
"groups": {
"title": "Grupos",
"management": "Gerenciamento de Grupos",
"add": "Adicionar Grupo",
"edit": "Editar Grupo",
"delete": "Excluir Grupo",
"noGroups": "Nenhum grupo criado",
"noGroupsDescription": "Crie um grupo para organizar seus perfis.",
"form": {
"name": "Nome",
"namePlaceholder": "Digite o nome do grupo"
},
"profileCount": "{{count}} perfil",
"profileCount_plural": "{{count}} perfis",
"assignProfiles": "Atribuir Perfis",
"sync": {
"enabled": "Sincronização Ativada",
"disabled": "Sincronização Desativada"
}
},
"sync": {
"title": "Serviço de Sincronização",
"config": "Configuração de Sincronização",
"serverUrl": "URL do Servidor",
"serverUrlPlaceholder": "https://sync.exemplo.com",
"token": "Token de Sincronização",
"tokenPlaceholder": "Digite seu token de sincronização",
"status": {
"connected": "Conectado",
"disconnected": "Desconectado",
"syncing": "Sincronizando...",
"error": "Erro de Sincronização"
},
"description": "Conecte-se a um servidor de sincronização para sincronizar seus perfis, proxies e grupos entre dispositivos."
},
"integrations": {
"title": "Integrações",
"api": {
"title": "API Local",
"description": "Ative o servidor de API local para integrações externas.",
"enabled": "API Ativada",
"disabled": "API Desativada",
"port": "Porta",
"token": "Token da API",
"copyToken": "Copiar Token",
"regenerateToken": "Regenerar Token"
},
"mcp": {
"title": "Servidor MCP",
"description": "Ative o servidor MCP (Protocolo de Contexto de Modelo) para integrações com assistentes de IA.",
"enabled": "MCP Ativado",
"disabled": "MCP Desativado",
"port": "Porta",
"token": "Token MCP",
"config": "Configuração MCP",
"copyConfig": "Copiar Configuração"
}
},
"import": {
"title": "Importar Perfil",
"description": "Importe um perfil de navegador existente do seu sistema.",
"selectProfile": "Selecione um perfil para importar",
"noProfiles": "Nenhum perfil detectado",
"noProfilesDescription": "Nenhum perfil de navegador foi detectado no seu sistema.",
"importing": "Importando perfil...",
"success": "Perfil importado com sucesso",
"error": "Falha ao importar perfil"
},
"config": {
"camoufox": {
"title": "Configuração do Camoufox",
"fingerprint": {
"title": "Impressão Digital",
"randomize": "Aleatorizar ao Iniciar",
"randomizeDescription": "Gera uma nova impressão digital cada vez que o navegador é iniciado."
},
"os": {
"title": "Sistema Operacional",
"description": "O sistema operacional a emular para geração de impressão digital.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux"
},
"screen": {
"title": "Tamanho da Tela",
"minWidth": "Largura Mín",
"maxWidth": "Largura Máx",
"minHeight": "Altura Mín",
"maxHeight": "Altura Máx"
},
"geoip": {
"title": "GeoIP",
"auto": "Automático (baseado no proxy)",
"manual": "Manual",
"disabled": "Desativado"
},
"blocking": {
"title": "Bloqueio",
"images": "Bloquear Imagens",
"webrtc": "Bloquear WebRTC",
"webgl": "Bloquear WebGL"
}
},
"wayfern": {
"title": "Configuração do Wayfern",
"fingerprint": {
"title": "Impressão Digital",
"randomize": "Aleatorizar ao Iniciar",
"randomizeDescription": "Gera uma nova impressão digital cada vez que o navegador é iniciado."
},
"os": {
"title": "Sistema Operacional",
"description": "O sistema operacional a emular para geração de impressão digital.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"android": "Android",
"ios": "iOS"
},
"screen": {
"title": "Tamanho da Tela",
"minWidth": "Largura Mín",
"maxWidth": "Largura Máx",
"minHeight": "Altura Mín",
"maxHeight": "Altura Máx"
},
"blocking": {
"title": "Bloqueio",
"webrtc": "Bloquear WebRTC",
"webgl": "Bloquear WebGL"
}
}
},
"cookies": {
"title": "Cookies",
"copy": {
"title": "Copiar Cookies",
"description": "Selecione cookies para copiar para outros perfis.",
"selectSource": "Selecionar Perfil de Origem",
"selectTarget": "Selecionar Perfis de Destino",
"selectCookies": "Selecionar Cookies",
"allDomains": "Todos os Domínios",
"selectedCount": "{{count}} cookie selecionado",
"selectedCount_plural": "{{count}} cookies selecionados"
},
"success": "Cookies copiados com sucesso",
"error": "Falha ao copiar cookies"
},
"toasts": {
"success": {
"profileCreated": "Perfil criado com sucesso",
"profileDeleted": "Perfil excluído com sucesso",
"profileUpdated": "Perfil atualizado com sucesso",
"profileLaunched": "Perfil iniciado com sucesso",
"proxyCreated": "Proxy criado com sucesso",
"proxyDeleted": "Proxy excluído com sucesso",
"proxyUpdated": "Proxy atualizado com sucesso",
"groupCreated": "Grupo criado com sucesso",
"groupDeleted": "Grupo excluído com sucesso",
"groupUpdated": "Grupo atualizado com sucesso",
"settingsSaved": "Configurações salvas com sucesso",
"copied": "Copiado para a área de transferência",
"permissionRequested": "Acesso ao {{permission}} solicitado",
"downloadComplete": "{{browser}} {{version}} baixado com sucesso!",
"importSuccess": "{{count}} itens importados com sucesso",
"exportSuccess": "{{count}} itens exportados com sucesso",
"syncSuccess": "Sincronização concluída com sucesso",
"cacheCleared": "Cache limpo com sucesso"
},
"error": {
"profileCreateFailed": "Falha ao criar perfil",
"profileDeleteFailed": "Falha ao excluir perfil",
"profileUpdateFailed": "Falha ao atualizar perfil",
"profileLaunchFailed": "Falha ao iniciar perfil",
"proxyCreateFailed": "Falha ao criar proxy",
"proxyDeleteFailed": "Falha ao excluir proxy",
"proxyUpdateFailed": "Falha ao atualizar proxy",
"groupCreateFailed": "Falha ao criar grupo",
"groupDeleteFailed": "Falha ao excluir grupo",
"groupUpdateFailed": "Falha ao atualizar grupo",
"settingsSaveFailed": "Falha ao salvar configurações",
"copyFailed": "Falha ao copiar para a área de transferência",
"downloadFailed": "Falha ao baixar {{browser}}",
"importFailed": "Falha ao importar",
"exportFailed": "Falha ao exportar",
"syncFailed": "Falha na sincronização",
"cacheClearFailed": "Falha ao limpar cache",
"unknown": "Ocorreu um erro desconhecido"
},
"loading": {
"downloading": "Baixando {{browser}} {{version}}",
"extracting": "Extraindo {{browser}} {{version}}",
"verifying": "Verificando {{browser}} {{version}}",
"syncing": "Sincronizando...",
"updatingVersions": "Atualizando versões de navegadores..."
}
},
"errors": {
"required": "Este campo é obrigatório",
"invalidUrl": "Por favor, insira uma URL válida",
"invalidPort": "Por favor, insira um número de porta válido (1-65535)",
"invalidEmail": "Por favor, insira um email válido",
"minLength": "Deve ter pelo menos {{min}} caracteres",
"maxLength": "Deve ter no máximo {{max}} caracteres",
"networkError": "Erro de rede. Por favor, verifique sua conexão.",
"serverError": "Erro do servidor. Por favor, tente novamente mais tarde.",
"unknownError": "Ocorreu um erro desconhecido. Por favor, tente novamente."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
}
}
+460
View File
@@ -0,0 +1,460 @@
{
"common": {
"buttons": {
"save": "Сохранить",
"cancel": "Отмена",
"close": "Закрыть",
"delete": "Удалить",
"create": "Создать",
"back": "Назад",
"retry": "Повторить",
"download": "Скачать",
"confirm": "Подтвердить",
"apply": "Применить",
"reset": "Сбросить",
"add": "Добавить",
"edit": "Редактировать",
"copy": "Копировать",
"clear": "Очистить",
"search": "Поиск",
"select": "Выбрать",
"grant": "Разрешить",
"start": "Запустить",
"stop": "Остановить",
"enable": "Включить",
"disable": "Выключить",
"import": "Импорт",
"export": "Экспорт",
"refresh": "Обновить",
"loading": "Загрузка...",
"saveSettings": "Сохранить настройки"
},
"status": {
"active": "Активен",
"inactive": "Неактивен",
"running": "Запущен",
"stopped": "Остановлен",
"enabled": "Включено",
"disabled": "Выключено",
"granted": "Разрешено",
"notGranted": "Не разрешено",
"connected": "Подключено",
"disconnected": "Отключено",
"synced": "Синхронизировано",
"syncing": "Синхронизация",
"pending": "Ожидание",
"error": "Ошибка"
},
"labels": {
"name": "Название",
"type": "Тип",
"status": "Статус",
"actions": "Действия",
"description": "Описание",
"none": "Нет",
"default": "По умолчанию",
"custom": "Пользовательский",
"optional": "Необязательно",
"required": "Обязательно"
},
"time": {
"days": "дней",
"hours": "часов",
"minutes": "минут",
"seconds": "секунд",
"remaining": "осталось"
}
},
"settings": {
"title": "Настройки",
"appearance": {
"title": "Внешний вид",
"theme": "Тема",
"themeDescription": "Выберите предпочитаемую тему или следуйте системным настройкам. Изменения пользовательской темы применяются только при сохранении.",
"themePreset": "Предустановка темы",
"customColors": "Пользовательские цвета",
"selectTheme": "Выберите тему",
"selectThemePreset": "Выберите предустановку темы",
"yourOwn": "Собственная",
"light": "Светлая",
"dark": "Тёмная",
"system": "Системная"
},
"language": {
"title": "Язык",
"description": "Выберите предпочитаемый язык интерфейса приложения.",
"systemDefault": "Системный по умолчанию",
"selectLanguage": "Выберите язык"
},
"defaultBrowser": {
"title": "Браузер по умолчанию",
"setAsDefault": "Установить браузером по умолчанию",
"alreadyDefault": "Уже браузер по умолчанию",
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования."
},
"permissions": {
"title": "Системные разрешения",
"loading": "Загрузка разрешений...",
"description": "Эти разрешения позволяют браузерам, запущенным из Donut Browser, получать доступ к системным ресурсам. Каждый сайт всё равно будет запрашивать разрешение отдельно.",
"microphone": "Микрофон",
"microphoneDescription": "Доступ к микрофону для браузерных приложений",
"camera": "Камера",
"cameraDescription": "Доступ к камере для браузерных приложений"
},
"integrations": {
"title": "Интеграции",
"description": "Настройте локальный API и MCP (Model Context Protocol) для интеграции с внешними инструментами и AI-ассистентами.",
"openSettings": "Открыть настройки интеграций"
},
"commercial": {
"title": "Коммерческая лицензия",
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода",
"trialExpired": "Пробный период истёк",
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия."
},
"advanced": {
"title": "Дополнительно",
"clearCache": "Очистить весь кэш версий",
"clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров."
}
},
"header": {
"searchPlaceholder": "Поиск профилей...",
"clearSearch": "Очистить поиск",
"moreActions": "Дополнительные действия",
"createProfile": "Создать новый профиль",
"menu": {
"settings": "Настройки",
"proxies": "Прокси",
"groups": "Группы",
"syncService": "Служба синхронизации",
"integrations": "Интеграции",
"importProfile": "Импорт профиля"
}
},
"profiles": {
"title": "Профили",
"empty": "Профилей пока нет",
"emptyDescription": "Создайте свой первый профиль браузера для начала работы.",
"createFirst": "Создать профиль",
"noResults": "Профили не найдены",
"noResultsDescription": "Нет профилей, соответствующих критериям поиска.",
"table": {
"name": "Название",
"browser": "Браузер",
"status": "Статус",
"actions": "Действия",
"note": "Заметка",
"group": "Группа",
"proxy": "Прокси",
"lastLaunch": "Последний запуск"
},
"actions": {
"launch": "Запустить",
"stop": "Остановить",
"edit": "Редактировать",
"delete": "Удалить",
"copyCookies": "Копировать Cookie",
"configure": "Настроить"
}
},
"createProfile": {
"title": "Создать новый профиль",
"configureTitle": "Настроить профиль",
"antiDetect": {
"title": "Антидетект браузер",
"description": "Выберите браузер с возможностями защиты от обнаружения",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"badge": "Антидетект браузер"
},
"regular": {
"title": "Обычные браузеры",
"description": "Выберите из поддерживаемых обычных браузеров",
"badge": "Обычный браузер"
},
"profileName": "Название профиля",
"profileNamePlaceholder": "Введите название профиля",
"proxy": {
"title": "Прокси",
"addProxy": "Добавить прокси",
"noProxy": "Без прокси",
"noProxiesAvailable": "Нет доступных прокси. Добавьте один для маршрутизации трафика этого профиля."
},
"version": {
"fetching": "Получение доступных версий...",
"fetchError": "Не удалось получить версии браузера. Проверьте интернет-соединение и попробуйте снова.",
"needsDownload": "Версию {{browser}} ({{version}}) необходимо скачать",
"available": "Версия {{browser}} ({{version}}) доступна",
"downloading": "Загрузка версии {{browser}} ({{version}})...",
"latestNeedsDownload": "Последнюю версию ({{version}}) необходимо скачать",
"latestAvailable": "Последняя версия ({{version}}) доступна",
"latestDownloading": "Загрузка версии ({{version}})..."
}
},
"deleteDialog": {
"title": "Удалить профиль",
"description": "Вы уверены, что хотите удалить этот профиль? Это действие нельзя отменить.",
"profilesTitle": "Удалить профили",
"profilesDescription": "Вы уверены, что хотите удалить выбранные профили? Это действие нельзя отменить.",
"profilesToDelete": "Профили для удаления:"
},
"proxies": {
"title": "Прокси",
"management": "Управление прокси",
"add": "Добавить прокси",
"edit": "Редактировать прокси",
"delete": "Удалить прокси",
"import": "Импорт",
"export": "Экспорт",
"noProxies": "Прокси не настроены",
"noProxiesDescription": "Добавьте прокси для маршрутизации трафика браузера через него.",
"form": {
"name": "Название",
"namePlaceholder": "Введите название прокси",
"type": "Тип",
"host": "Хост",
"hostPlaceholder": "proxy.example.com",
"port": "Порт",
"portPlaceholder": "8080",
"username": "Имя пользователя",
"usernamePlaceholder": "Необязательно",
"password": "Пароль",
"passwordPlaceholder": "Необязательно"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"check": {
"checking": "Проверка прокси...",
"valid": "Прокси действителен",
"invalid": "Прокси недействителен",
"lastChecked": "Последняя проверка: {{time}}"
},
"sync": {
"enabled": "Синхронизация включена",
"disabled": "Синхронизация выключена"
}
},
"groups": {
"title": "Группы",
"management": "Управление группами",
"add": "Добавить группу",
"edit": "Редактировать группу",
"delete": "Удалить группу",
"noGroups": "Группы не созданы",
"noGroupsDescription": "Создайте группу для организации профилей.",
"form": {
"name": "Название",
"namePlaceholder": "Введите название группы"
},
"profileCount": "{{count}} профиль",
"profileCount_plural": "{{count}} профилей",
"assignProfiles": "Назначить профили",
"sync": {
"enabled": "Синхронизация включена",
"disabled": "Синхронизация выключена"
}
},
"sync": {
"title": "Служба синхронизации",
"config": "Настройка синхронизации",
"serverUrl": "URL сервера",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "Токен синхронизации",
"tokenPlaceholder": "Введите токен синхронизации",
"status": {
"connected": "Подключено",
"disconnected": "Отключено",
"syncing": "Синхронизация...",
"error": "Ошибка синхронизации"
},
"description": "Подключитесь к серверу синхронизации для синхронизации профилей, прокси и групп между устройствами."
},
"integrations": {
"title": "Интеграции",
"api": {
"title": "Локальный API",
"description": "Включите локальный API-сервер для внешних интеграций.",
"enabled": "API включён",
"disabled": "API выключен",
"port": "Порт",
"token": "API токен",
"copyToken": "Копировать токен",
"regenerateToken": "Перегенерировать токен"
},
"mcp": {
"title": "MCP сервер",
"description": "Включите MCP (Model Context Protocol) сервер для интеграции с AI-ассистентами.",
"enabled": "MCP включён",
"disabled": "MCP выключен",
"port": "Порт",
"token": "MCP токен",
"config": "Конфигурация MCP",
"copyConfig": "Копировать конфигурацию"
}
},
"import": {
"title": "Импорт профиля",
"description": "Импортируйте существующий профиль браузера из вашей системы.",
"selectProfile": "Выберите профиль для импорта",
"noProfiles": "Профили не обнаружены",
"noProfilesDescription": "В вашей системе не обнаружены профили браузеров.",
"importing": "Импорт профиля...",
"success": "Профиль успешно импортирован",
"error": "Ошибка импорта профиля"
},
"config": {
"camoufox": {
"title": "Настройки Camoufox",
"fingerprint": {
"title": "Отпечаток",
"randomize": "Случайный при запуске",
"randomizeDescription": "Генерировать новый отпечаток при каждом запуске браузера."
},
"os": {
"title": "Операционная система",
"description": "Операционная система для эмуляции при генерации отпечатка.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux"
},
"screen": {
"title": "Размер экрана",
"minWidth": "Мин. ширина",
"maxWidth": "Макс. ширина",
"minHeight": "Мин. высота",
"maxHeight": "Макс. высота"
},
"geoip": {
"title": "GeoIP",
"auto": "Автоматически (на основе прокси)",
"manual": "Вручную",
"disabled": "Выключено"
},
"blocking": {
"title": "Блокировка",
"images": "Блокировать изображения",
"webrtc": "Блокировать WebRTC",
"webgl": "Блокировать WebGL"
}
},
"wayfern": {
"title": "Настройки Wayfern",
"fingerprint": {
"title": "Отпечаток",
"randomize": "Случайный при запуске",
"randomizeDescription": "Генерировать новый отпечаток при каждом запуске браузера."
},
"os": {
"title": "Операционная система",
"description": "Операционная система для эмуляции при генерации отпечатка.",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"android": "Android",
"ios": "iOS"
},
"screen": {
"title": "Размер экрана",
"minWidth": "Мин. ширина",
"maxWidth": "Макс. ширина",
"minHeight": "Мин. высота",
"maxHeight": "Макс. высота"
},
"blocking": {
"title": "Блокировка",
"webrtc": "Блокировать WebRTC",
"webgl": "Блокировать WebGL"
}
}
},
"cookies": {
"title": "Cookie",
"copy": {
"title": "Копировать Cookie",
"description": "Выберите cookie для копирования в другие профили.",
"selectSource": "Выберите исходный профиль",
"selectTarget": "Выберите целевые профили",
"selectCookies": "Выберите Cookie",
"allDomains": "Все домены",
"selectedCount": "Выбрано {{count}} cookie",
"selectedCount_plural": "Выбрано {{count}} cookie"
},
"success": "Cookie успешно скопированы",
"error": "Ошибка копирования cookie"
},
"toasts": {
"success": {
"profileCreated": "Профиль успешно создан",
"profileDeleted": "Профиль успешно удалён",
"profileUpdated": "Профиль успешно обновлён",
"profileLaunched": "Профиль успешно запущен",
"proxyCreated": "Прокси успешно создан",
"proxyDeleted": "Прокси успешно удалён",
"proxyUpdated": "Прокси успешно обновлён",
"groupCreated": "Группа успешно создана",
"groupDeleted": "Группа успешно удалена",
"groupUpdated": "Группа успешно обновлена",
"settingsSaved": "Настройки успешно сохранены",
"copied": "Скопировано в буфер обмена",
"permissionRequested": "Запрошен доступ к {{permission}}",
"downloadComplete": "{{browser}} {{version}} успешно загружен!",
"importSuccess": "Успешно импортировано {{count}} элементов",
"exportSuccess": "Успешно экспортировано {{count}} элементов",
"syncSuccess": "Синхронизация успешно завершена",
"cacheCleared": "Кэш успешно очищен"
},
"error": {
"profileCreateFailed": "Ошибка создания профиля",
"profileDeleteFailed": "Ошибка удаления профиля",
"profileUpdateFailed": "Ошибка обновления профиля",
"profileLaunchFailed": "Ошибка запуска профиля",
"proxyCreateFailed": "Ошибка создания прокси",
"proxyDeleteFailed": "Ошибка удаления прокси",
"proxyUpdateFailed": "Ошибка обновления прокси",
"groupCreateFailed": "Ошибка создания группы",
"groupDeleteFailed": "Ошибка удаления группы",
"groupUpdateFailed": "Ошибка обновления группы",
"settingsSaveFailed": "Ошибка сохранения настроек",
"copyFailed": "Ошибка копирования в буфер обмена",
"downloadFailed": "Ошибка загрузки {{browser}}",
"importFailed": "Ошибка импорта",
"exportFailed": "Ошибка экспорта",
"syncFailed": "Ошибка синхронизации",
"cacheClearFailed": "Ошибка очистки кэша",
"unknown": "Произошла неизвестная ошибка"
},
"loading": {
"downloading": "Загрузка {{browser}} {{version}}",
"extracting": "Распаковка {{browser}} {{version}}",
"verifying": "Проверка {{browser}} {{version}}",
"syncing": "Синхронизация...",
"updatingVersions": "Обновление версий браузеров..."
}
},
"errors": {
"required": "Это поле обязательно",
"invalidUrl": "Введите корректный URL",
"invalidPort": "Введите корректный номер порта (1-65535)",
"invalidEmail": "Введите корректный адрес электронной почты",
"minLength": "Минимум {{min}} символов",
"maxLength": "Максимум {{max}} символов",
"networkError": "Сетевая ошибка. Проверьте подключение.",
"serverError": "Ошибка сервера. Попробуйте позже.",
"unknownError": "Произошла неизвестная ошибка. Попробуйте снова."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
}
}
+460
View File
@@ -0,0 +1,460 @@
{
"common": {
"buttons": {
"save": "保存",
"cancel": "取消",
"close": "关闭",
"delete": "删除",
"create": "创建",
"back": "返回",
"retry": "重试",
"download": "下载",
"confirm": "确认",
"apply": "应用",
"reset": "重置",
"add": "添加",
"edit": "编辑",
"copy": "复制",
"clear": "清除",
"search": "搜索",
"select": "选择",
"grant": "授权",
"start": "启动",
"stop": "停止",
"enable": "启用",
"disable": "禁用",
"import": "导入",
"export": "导出",
"refresh": "刷新",
"loading": "加载中...",
"saveSettings": "保存设置"
},
"status": {
"active": "活跃",
"inactive": "未活跃",
"running": "运行中",
"stopped": "已停止",
"enabled": "已启用",
"disabled": "已禁用",
"granted": "已授权",
"notGranted": "未授权",
"connected": "已连接",
"disconnected": "已断开",
"synced": "已同步",
"syncing": "同步中",
"pending": "待处理",
"error": "错误"
},
"labels": {
"name": "名称",
"type": "类型",
"status": "状态",
"actions": "操作",
"description": "描述",
"none": "无",
"default": "默认",
"custom": "自定义",
"optional": "可选",
"required": "必填"
},
"time": {
"days": "天",
"hours": "小时",
"minutes": "分钟",
"seconds": "秒",
"remaining": "剩余"
}
},
"settings": {
"title": "设置",
"appearance": {
"title": "外观",
"theme": "主题",
"themeDescription": "选择您喜欢的主题或跟随系统设置。自定义主题更改仅在保存时应用。",
"themePreset": "主题预设",
"customColors": "自定义颜色",
"selectTheme": "选择主题",
"selectThemePreset": "选择主题预设",
"yourOwn": "自定义",
"light": "浅色",
"dark": "深色",
"system": "跟随系统"
},
"language": {
"title": "语言",
"description": "选择应用程序界面的首选语言。",
"systemDefault": "系统默认",
"selectLanguage": "选择语言"
},
"defaultBrowser": {
"title": "默认浏览器",
"setAsDefault": "设为默认浏览器",
"alreadyDefault": "已是默认浏览器",
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。"
},
"permissions": {
"title": "系统权限",
"loading": "加载权限中...",
"description": "这些权限允许从 Donut Browser 启动的浏览器访问系统资源。每个网站仍会单独请求您的权限。",
"microphone": "麦克风",
"microphoneDescription": "浏览器应用程序的麦克风访问权限",
"camera": "摄像头",
"cameraDescription": "浏览器应用程序的摄像头访问权限"
},
"integrations": {
"title": "集成",
"description": "配置本地 API 和 MCP(模型上下文协议)以与外部工具和 AI 助手集成。",
"openSettings": "打开集成设置"
},
"commercial": {
"title": "商业许可",
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
"trialActiveDescription": "试用期内商业使用免费",
"trialExpired": "试用期已过期",
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。"
},
"advanced": {
"title": "高级",
"clearCache": "清除所有版本缓存",
"clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。"
}
},
"header": {
"searchPlaceholder": "搜索配置文件...",
"clearSearch": "清除搜索",
"moreActions": "更多操作",
"createProfile": "创建新配置文件",
"menu": {
"settings": "设置",
"proxies": "代理",
"groups": "分组",
"syncService": "同步服务",
"integrations": "集成",
"importProfile": "导入配置文件"
}
},
"profiles": {
"title": "配置文件",
"empty": "暂无配置文件",
"emptyDescription": "创建您的第一个浏览器配置文件以开始使用。",
"createFirst": "创建配置文件",
"noResults": "未找到配置文件",
"noResultsDescription": "没有配置文件匹配您的搜索条件。",
"table": {
"name": "名称",
"browser": "浏览器",
"status": "状态",
"actions": "操作",
"note": "备注",
"group": "分组",
"proxy": "代理",
"lastLaunch": "最后启动"
},
"actions": {
"launch": "启动",
"stop": "停止",
"edit": "编辑",
"delete": "删除",
"copyCookies": "复制 Cookies",
"configure": "配置"
}
},
"createProfile": {
"title": "创建新配置文件",
"configureTitle": "配置配置文件",
"antiDetect": {
"title": "防检测浏览器",
"description": "选择具有防检测功能的浏览器",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"badge": "防检测浏览器"
},
"regular": {
"title": "常规浏览器",
"description": "从支持的常规浏览器中选择",
"badge": "常规浏览器"
},
"profileName": "配置文件名称",
"profileNamePlaceholder": "输入配置文件名称",
"proxy": {
"title": "代理",
"addProxy": "添加代理",
"noProxy": "无代理",
"noProxiesAvailable": "没有可用的代理。添加一个代理来路由此配置文件的流量。"
},
"version": {
"fetching": "正在获取可用版本...",
"fetchError": "获取浏览器版本失败。请检查您的网络连接并重试。",
"needsDownload": "{{browser}} 版本 ({{version}}) 需要下载",
"available": "{{browser}} 版本 ({{version}}) 可用",
"downloading": "正在下载 {{browser}} 版本 ({{version}})...",
"latestNeedsDownload": "最新版本 ({{version}}) 需要下载",
"latestAvailable": "最新版本 ({{version}}) 可用",
"latestDownloading": "正在下载版本 ({{version}})..."
}
},
"deleteDialog": {
"title": "删除配置文件",
"description": "您确定要删除此配置文件吗?此操作无法撤消。",
"profilesTitle": "删除配置文件",
"profilesDescription": "您确定要删除选中的配置文件吗?此操作无法撤消。",
"profilesToDelete": "将被删除的配置文件:"
},
"proxies": {
"title": "代理",
"management": "代理管理",
"add": "添加代理",
"edit": "编辑代理",
"delete": "删除代理",
"import": "导入",
"export": "导出",
"noProxies": "未配置代理",
"noProxiesDescription": "添加代理以通过它路由浏览器流量。",
"form": {
"name": "名称",
"namePlaceholder": "输入代理名称",
"type": "类型",
"host": "主机",
"hostPlaceholder": "proxy.example.com",
"port": "端口",
"portPlaceholder": "8080",
"username": "用户名",
"usernamePlaceholder": "可选",
"password": "密码",
"passwordPlaceholder": "可选"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"check": {
"checking": "检查代理中...",
"valid": "代理有效",
"invalid": "代理无效",
"lastChecked": "最后检查:{{time}}"
},
"sync": {
"enabled": "同步已启用",
"disabled": "同步已禁用"
}
},
"groups": {
"title": "分组",
"management": "分组管理",
"add": "添加分组",
"edit": "编辑分组",
"delete": "删除分组",
"noGroups": "暂无分组",
"noGroupsDescription": "创建分组来组织您的配置文件。",
"form": {
"name": "名称",
"namePlaceholder": "输入分组名称"
},
"profileCount": "{{count}} 个配置文件",
"profileCount_plural": "{{count}} 个配置文件",
"assignProfiles": "分配配置文件",
"sync": {
"enabled": "同步已启用",
"disabled": "同步已禁用"
}
},
"sync": {
"title": "同步服务",
"config": "同步配置",
"serverUrl": "服务器 URL",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "同步令牌",
"tokenPlaceholder": "输入您的同步令牌",
"status": {
"connected": "已连接",
"disconnected": "已断开",
"syncing": "同步中...",
"error": "同步错误"
},
"description": "连接到同步服务器以在设备之间同步您的配置文件、代理和分组。"
},
"integrations": {
"title": "集成",
"api": {
"title": "本地 API",
"description": "启用本地 API 服务器以进行外部集成。",
"enabled": "API 已启用",
"disabled": "API 已禁用",
"port": "端口",
"token": "API 令牌",
"copyToken": "复制令牌",
"regenerateToken": "重新生成令牌"
},
"mcp": {
"title": "MCP 服务器",
"description": "启用 MCP(模型上下文协议)服务器以与 AI 助手集成。",
"enabled": "MCP 已启用",
"disabled": "MCP 已禁用",
"port": "端口",
"token": "MCP 令牌",
"config": "MCP 配置",
"copyConfig": "复制配置"
}
},
"import": {
"title": "导入配置文件",
"description": "从您的系统导入现有的浏览器配置文件。",
"selectProfile": "选择要导入的配置文件",
"noProfiles": "未检测到配置文件",
"noProfilesDescription": "您的系统上未检测到浏览器配置文件。",
"importing": "正在导入配置文件...",
"success": "配置文件导入成功",
"error": "导入配置文件失败"
},
"config": {
"camoufox": {
"title": "Camoufox 配置",
"fingerprint": {
"title": "指纹",
"randomize": "启动时随机化",
"randomizeDescription": "每次启动浏览器时生成新的指纹。"
},
"os": {
"title": "操作系统",
"description": "用于生成指纹的模拟操作系统。",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux"
},
"screen": {
"title": "屏幕尺寸",
"minWidth": "最小宽度",
"maxWidth": "最大宽度",
"minHeight": "最小高度",
"maxHeight": "最大高度"
},
"geoip": {
"title": "GeoIP",
"auto": "自动(基于代理)",
"manual": "手动",
"disabled": "禁用"
},
"blocking": {
"title": "阻止",
"images": "阻止图片",
"webrtc": "阻止 WebRTC",
"webgl": "阻止 WebGL"
}
},
"wayfern": {
"title": "Wayfern 配置",
"fingerprint": {
"title": "指纹",
"randomize": "启动时随机化",
"randomizeDescription": "每次启动浏览器时生成新的指纹。"
},
"os": {
"title": "操作系统",
"description": "用于生成指纹的模拟操作系统。",
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"android": "Android",
"ios": "iOS"
},
"screen": {
"title": "屏幕尺寸",
"minWidth": "最小宽度",
"maxWidth": "最大宽度",
"minHeight": "最小高度",
"maxHeight": "最大高度"
},
"blocking": {
"title": "阻止",
"webrtc": "阻止 WebRTC",
"webgl": "阻止 WebGL"
}
}
},
"cookies": {
"title": "Cookies",
"copy": {
"title": "复制 Cookies",
"description": "选择要复制到其他配置文件的 cookies。",
"selectSource": "选择源配置文件",
"selectTarget": "选择目标配置文件",
"selectCookies": "选择 Cookies",
"allDomains": "所有域名",
"selectedCount": "已选择 {{count}} 个 cookie",
"selectedCount_plural": "已选择 {{count}} 个 cookies"
},
"success": "Cookies 复制成功",
"error": "复制 cookies 失败"
},
"toasts": {
"success": {
"profileCreated": "配置文件创建成功",
"profileDeleted": "配置文件删除成功",
"profileUpdated": "配置文件更新成功",
"profileLaunched": "配置文件启动成功",
"proxyCreated": "代理创建成功",
"proxyDeleted": "代理删除成功",
"proxyUpdated": "代理更新成功",
"groupCreated": "分组创建成功",
"groupDeleted": "分组删除成功",
"groupUpdated": "分组更新成功",
"settingsSaved": "设置保存成功",
"copied": "已复制到剪贴板",
"permissionRequested": "已请求 {{permission}} 访问权限",
"downloadComplete": "{{browser}} {{version}} 下载成功!",
"importSuccess": "成功导入 {{count}} 个项目",
"exportSuccess": "成功导出 {{count}} 个项目",
"syncSuccess": "同步成功完成",
"cacheCleared": "缓存清除成功"
},
"error": {
"profileCreateFailed": "创建配置文件失败",
"profileDeleteFailed": "删除配置文件失败",
"profileUpdateFailed": "更新配置文件失败",
"profileLaunchFailed": "启动配置文件失败",
"proxyCreateFailed": "创建代理失败",
"proxyDeleteFailed": "删除代理失败",
"proxyUpdateFailed": "更新代理失败",
"groupCreateFailed": "创建分组失败",
"groupDeleteFailed": "删除分组失败",
"groupUpdateFailed": "更新分组失败",
"settingsSaveFailed": "保存设置失败",
"copyFailed": "复制到剪贴板失败",
"downloadFailed": "下载 {{browser}} 失败",
"importFailed": "导入失败",
"exportFailed": "导出失败",
"syncFailed": "同步失败",
"cacheClearFailed": "清除缓存失败",
"unknown": "发生未知错误"
},
"loading": {
"downloading": "正在下载 {{browser}} {{version}}",
"extracting": "正在解压 {{browser}} {{version}}",
"verifying": "正在验证 {{browser}} {{version}}",
"syncing": "同步中...",
"updatingVersions": "正在更新浏览器版本..."
}
},
"errors": {
"required": "此字段为必填项",
"invalidUrl": "请输入有效的 URL",
"invalidPort": "请输入有效的端口号(1-65535",
"invalidEmail": "请输入有效的电子邮件地址",
"minLength": "至少需要 {{min}} 个字符",
"maxLength": "最多 {{max}} 个字符",
"networkError": "网络错误。请检查您的连接。",
"serverError": "服务器错误。请稍后重试。",
"unknownError": "发生未知错误。请重试。"
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox 开发者版",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
}
}
+67
View File
@@ -526,3 +526,70 @@ export interface CookieCopyResult {
cookies_replaced: number;
errors: string[];
}
// Proxy import/export types
export interface ProxyExportData {
version: string;
proxies: ExportedProxy[];
exported_at: string;
source: string;
}
export interface ExportedProxy {
name: string;
type: string;
host: string;
port: number;
username?: string;
password?: string;
}
export interface ProxyImportResult {
imported_count: number;
skipped_count: number;
errors: string[];
proxies: StoredProxy[];
}
export interface ParsedProxyLine {
proxy_type: string;
host: string;
port: number;
username?: string;
password?: string;
original_line: string;
}
export type ProxyParseResult =
| ({ status: "parsed" } & ParsedProxyLine)
| { status: "ambiguous"; line: string; possible_formats: string[] }
| { status: "invalid"; line: string; reason: string };
// VPN types
export type VpnType = "WireGuard" | "OpenVPN";
export interface VpnConfig {
id: string;
name: string;
vpn_type: VpnType;
config_data: string; // Raw config content (may be empty in list view)
created_at: number;
last_used?: number;
}
export interface VpnImportResult {
success: boolean;
vpn_id?: string;
vpn_type?: VpnType;
name: string;
error?: string;
}
export interface VpnStatus {
connected: boolean;
vpn_id: string;
connected_at?: number;
bytes_sent?: number;
bytes_received?: number;
last_handshake?: number;
}